UNPKG

electron-bookmarks

Version:

Access to macOS Security-Scoped bookmarks in an electron application

485 lines (408 loc) 14.2 kB
import crypto from 'crypto'; import path from 'path'; import $ from 'nodobjc'; import fs from 'fs'; import { exists, moduleKey, checkImports, checkArguments, checkAppInitialized } from './util'; const fileDialogProperties = { openFile: 1 << 0, openDirectory: 1 << 1, multiSelections: 1 << 2, createDirectory: 1 << 3, showHiddenFiles: 1 << 4, promptToCreate: 1 << 5, noResolveAliases: 1 << 6 }; /** * Clone of dialog.showOpenDialog which uses bookmarks. * @param {BrowserWindow} win - Optional; * @param {Object} opts - Optional; * @param {Function} cb - Optional; if not given will be synchronous(?) */ export function showOpenDialog(win, opts, cb) { checkAppInitialized(); checkArguments(win, opts, cb); if (typeof cb != 'function') { throw new TypeError('Callback must be a function'); } if (opts == null) { opts = { title: 'Open', bookmarkType: 'app', properties: ['openFile'] }; } let { buttonLabel, defaultPath, filters, properties, title, message, bookmarkType } = opts; if (bookmarkType == null) { bookmarkType = 'app'; } else if (typeof bookmarkType !== 'string' || (bookmarkType != 'app' && bookmarkType != 'document')) { throw new TypeError(`Bookmark Type must be a either "app" or "document". Got "${bookmarkType}".`); } if (properties == null) { properties = ['openFile']; } else if (!Array.isArray(properties)) { throw new TypeError('Properties must be an array'); } let dialogProperties = 0; for (const prop in fileDialogProperties) { if (properties.includes(prop)) { dialogProperties |= fileDialogProperties[prop]; } } if (title == null) { title = ''; } else if (typeof title !== 'string') { throw new TypeError('Title must be a string'); } if (buttonLabel == null) { buttonLabel = ''; } else if (typeof buttonLabel !== 'string') { throw new TypeError('Button label must be a string'); } if (defaultPath == null) { defaultPath = ''; } else if (typeof defaultPath !== 'string') { throw new TypeError('Default path must be a string'); } if (filters == null) { filters = []; } if (message == null) { message = ''; } else if (typeof message !== 'string') { throw new TypeError('Message must be a string'); } opts = { title, buttonLabel, defaultPath, filters, message, properties: dialogProperties, bookmarkType, }; objc.showOpenDialog(win, opts, cb); } /** * Clone of dialog.showSaveDialog which uses bookmarks. * @param {BrowserWindow} win - Optional; * @param {Object} opts - Optional; * @param {Function} cb - Optional; if not given will be synchronous(?) */ export function showSaveDialog(win, opts, cb) { checkAppInitialized(); checkArguments(win, opts, cb); if (opts == null) { opts = { title: 'Open', bookmarkType: 'app' }; } let { buttonLabel, defaultPath, filters, title, message, nameFieldLabel, showsTagField, bookmarkType } = opts; if (bookmarkType == null) { bookmarkType = 'app'; } else if (typeof bookmarkType !== 'string' || (bookmarkType != 'app' && bookmarkType != 'document')) { throw new TypeError(`Bookmark Type must be a either "app" or "document". Got "${bookmarkType}".`); } if (title == null) { title = ''; } else if (typeof title !== 'string') { throw new TypeError('Title must be a string'); } if (buttonLabel == null) { buttonLabel = ''; } else if (typeof buttonLabel !== 'string') { throw new TypeError('Button label must be a string'); } if (defaultPath == null) { defaultPath = ''; } else if (typeof defaultPath !== 'string') { throw new TypeError('Default path must be a string'); } if (filters == null) { filters = []; } if (message == null) { message = ''; } else if (typeof message !== 'string') { throw new TypeError('Message must be a string'); } if (nameFieldLabel == null) { nameFieldLabel = ''; } else if (typeof nameFieldLabel !== 'string') { throw new TypeError('Name field label must be a string'); } if (showsTagField == null) { showsTagField = true; } opts = { title, buttonLabel, defaultPath, filters, message, nameFieldLabel, showsTagField, bookmarkType }; objc.showSaveDialog(win, opts, cb); } /** * OBJECTIVE-C BRIDGING. */ const objc = { showOpenDialog: function (win, opts, cb) { checkImports(); const dialog = $.NSOpenPanel('openPanel'); this.setupDialog(dialog, opts); this.setupDialogProperties(dialog, opts.properties); // TODO: ensure that "['v',['?', 'i']]" is correct! // https://github.com/TooTallNate/NodObjC/issues/5#issuecomment-280985888 const handler = $(this.runModal(dialog, opts, cb, 'openPanel'), ['v',['?', 'i']]); dialog('beginSheetModalForWindow', win ? this.findNativeWindow(win) : $.NIL, 'completionHandler', handler); }, showSaveDialog: function (win, opts, cb) { checkImports(); const dialog = $.NSSavePanel('savePanel'); this.setupDialog(dialog, opts); dialog('setCanSelectHiddenExtension', $.YES); // TODO: ensure that "['v',['?', 'i']]" is correct! // https://github.com/TooTallNate/NodObjC/issues/5#issuecomment-280985888 const handler = $(this.runModal(dialog, opts, cb, 'savePanel'), ['v',['i']]); dialog('beginSheetModalForWindow', win ? this.findNativeWindow(win) : $.NIL, 'completionHandler', handler); }, /** * Runs the dialog, calling the appropriate callback. */ runModal: function (dialog, opts, cb, type) { return (self, chosen) => { // For some strange reason if we don't write to stdout here further output // is silenced, so we can't see potential errors, etc. process.stdout.write(''); if (chosen == $.NSFileHandlingPanelCancelButton) { cb(null); } else if (type == 'openPanel') { this.readDialogURLs(dialog, cb, opts.bookmarkType); } else if (type == 'savePanel') { this.readDialogURL(dialog, cb, opts.bookmarkType); } } }, /** * Reads the url from an NSSavePanel. */ readDialogURL: function (dialog, callback, bookmarkType) { const url = dialog('URL'); const path = url('path')('UTF8String'); if (!bookmarkType) return callback(path); // Retain url since we'll make async calls here. url('retain'); // This will create the bookmark, and release the url. const createBookmark = () => { const defaults = $.NSUserDefaults('standardUserDefaults'), bookmark = this.createSecurityBookmark(defaults, url, bookmarkType); if (bookmark.key) defaults('synchronize'); // Release url to be garbage-collected. url('release'); callback(path, bookmark); } // Check if the chosen path exists. exists(path, (err, yes) => { if (err) { url('release'); // Don't want any memory leaks. throw err; } // If the path exists we immediately create a bookmark. if (yes) createBookmark(); // If the path doesn't exist, we can't make a bookmark for it. So we // create an empty file, and then create a bookmark for it. else { fs.writeFile(path, '', (err) => { if (err) { url('release'); // Don't want any memory leaks. throw err; } createBookmark(); }); } }); }, /** * Reads urls from an NSOpenPanel. */ readDialogURLs: function (dialog, cb, bookmarkType) { let filenames = [], bookmarks, defaults; if (bookmarkType) { bookmarks = { keys: [], errors: [] }; defaults = $.NSUserDefaults('standardUserDefaults'); } const urls = dialog('URLs'); for (let i = 0, c = urls('count'); i < c; i++) { const url = urls('objectAtIndex', i); if (url('isFileURL')) { filenames.push(url('path')('UTF8String')); // Create Security-Scoped bookmark from NSURL. if (bookmarkType) { const bookmark = this.createSecurityBookmark(defaults, url, bookmarkType); if (bookmark.key) bookmarks.keys.push(bookmark.key); if (bookmark.error) bookmarks.errors.push(bookmark.error); } } } // Sync NSUserDefaults if we've accessed it. if (bookmarkType) defaults('synchronize'); cb(filenames, bookmarks); }, /** * Creates a security-scoped bookmark from the given url, saving it to * NSUserDefaults. */ createSecurityBookmark: function (defaults, url, bookmarkType) { let path = url('path'), error = $.alloc($.NSError, $.NIL).ref(), isAppBookmark = bookmarkType == 'app'; // TODO: document-scoped // TODO: document-scoped bookmarks must be placed in the file package itself! not core-data... // https://developer.apple.com/documentation/foundation/nsurl/1417795-bookmarkdatawithoptions?language=objc const relativeToURL = isAppBookmark ? $.NIL : $.NSURL('fileURLWithPath', path, 'isDirectory', $.NO); let data; try { data = url('bookmarkDataWithOptions', $.NSURLBookmarkCreationWithSecurityScope, 'includingResourceValuesForKeys', $.NIL, 'relativeToURL', relativeToURL, 'error', error); } catch (err) { throw err; } // Dereference the error pointer to see if an error has occurred. But this // may result in an error (null pointer ?), hence try/catch. try { const err = error.deref(); console.error({ userInfo: err('userInfo'), context: bookmark }); throw new Error(`[electron-bookmarks] Error creating security-scoped bookmark:\nNativeError: ${err('localizedDescription')}`); } catch (err) { if (err.message.startsWith('[electron-bookmarks]')) throw err; // Ignore Dereferencing error. } // We hash the path to avoid super long keys. const hash = crypto.createHash('md5').update(path('UTF8String')).digest('hex'), key = `${moduleKey}${hash}`, bookmark = $.NSMutableDictionary('alloc')('init'); // Save to NSUserDefaults as { path, bookmark: NSData, type: "app" or "document" }. bookmark('setObject', path, 'forKey', $('path')); bookmark('setObject', $(bookmarkType), 'forKey', $('type')); bookmark('setObject', data, 'forKey', $('bookmark')); // If it is a document-scoped bookmark, save the NSURL for later use. if (!isAppBookmark) { defaults('setURL', url, 'forKey', $(`URL:${key}`)); } defaults('setObject', bookmark, 'forKey', $(key)); return { key: key }; }, /** * Setup the given dialog with the passed options. */ setupDialog: function (dialog, opts) { if (opts.title) { dialog('setTitle', $(opts.title)); } if (opts.buttonLabel) { dialog('setPrompt', $(opts.buttonLabel)); } if (opts.message) { dialog('setMessage', $(opts.message)); } if (opts.nameFieldLabel) { dialog('setNameFieldLabel', $(opts.nameFieldLabel)); } if (opts.showsTagField) { dialog('setShowsTagField', opts.showsTagField ? $.YES : $.NO); } if (opts.defaultPath) { const dir = path.dirname(opts.defaultPath); if (dir) { dialog('setDirectoryURL', $.NSURL('fileURLWithPath', $(dir))); } const filename = path.basename(opts.defaultPath); if (filename) { dialog('setNameFieldStringValue', $(filename)); } } if (opts.filters.length == 0) { dialog('setAllowsOtherFileTypes', $.YES); } else { this.setupDialogFilters(dialog, opts.filters); } }, /** * Setup the dialog filters. */ setupDialogFilters: function (dialog, filters) { let file_type_set = $.NSMutableSet('set'); for (let i = 0; i < filters.length; i++) { // If we meet a '*' file extension, we allow all the file types and no // need to set the specified file types. if (filters[i] == '*') { dialog('setAllowsOtherFileTypes', $.YES); break; } file_type_set('addObject', $(filters[i])); } // Passing empty array to setAllowedFileTypes will cause an exception. let file_types = $.alloc($.NSArray).ref(); if (file_type_set('count')) { file_types = file_type_set('allObjects'); } dialog('setAllowedFileTypes', file_types); }, /** * Setup the dialog properties. */ setupDialogProperties: function (dialog, properties) { dialog('setCanChooseFiles', (properties & fileDialogProperties.openFile)); if (properties & fileDialogProperties.openDirectory) { dialog('setCanChooseDirectories', $.YES); } else if (properties & fileDialogProperties.createDirectory) { dialog('setCanCreateDirectories', $.YES); } else if (properties & fileDialogProperties.multiSelections) { dialog('setAllowsMultipleSelection', $.YES); } else if (properties & fileDialogProperties.showHiddenFiles) { dialog('setShowsHiddenFiles', $.YES); } else if (properties & fileDialogProperties.noResolveAliases) { dialog('setResolvesAliases', $.NO); } }, /** * HACK: Since we don't have the v8 runtime we can't access the electron * window's id or identify it any easy way. So we change it's title, look for * an NSWindow with that title and then restore the previous title. * @param {BrowserWindow} win * @return {OBJC:NSWindow | $.NIL} */ findNativeWindow: function (win) { const windows = $.NSApplication('sharedApplication')('windows'), test = 'electron-bookmarks:__window__:AtomNSWindow'; // Remember old window title, and set title to our test. const windowTitle = win.getTitle(); win.setTitle(test); // Look through each NSWindow for our title. for (let i = 0, c = windows('count'); i < c; i++) { let NSWindow = windows('objectAtIndex', i); if (NSWindow('title') == test) { win.setTitle(windowTitle); return NSWindow; } } // We couldn't find it. Ah well, return NIL. win.setTitle(windowTitle); return $.NIL; } };