UNPKG

electron-bookmarks

Version:

Access to macOS Security-Scoped bookmarks in an electron application

829 lines (688 loc) 25.5 kB
// electron-bookmarks, Copyright (c) by Callum Osmotherly // Distributed under an MIT license: https://gitlab.com/callodacity/electron-bookmarks/blob/master/LICENSE // // This is a library for enabling macOS sandbox Security-Scoped bookmarks // inside an electron application. // Give node support for stack traces. require('source-map-support').install(); 'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var fs = _interopDefault(require('fs')); var $ = _interopDefault(require('nodobjc')); var electron = require('electron'); var crypto = _interopDefault(require('crypto')); var path = _interopDefault(require('path')); checkAppInitialized(); var moduleKey = "electron-bookmarks::" + (electron.app.getName()) + "::"; /** * [init description] */ function init() { checkAppInitialized(); return checkImports(); } /** * [checkImports description] */ function checkImports() { if ( !('NSOpenPanel' in $) || !('NSSavePanel' in $) || !('NSUserDefaults') in $ ) { $.import('AppKit'); $.import('Foundation'); return true; } // NOTE: These can be `undefined` at peculiar times, so we just put the // raw values in if they're not already there. if (!'NSURLBookmarkCreationWithSecurityScope' in $) { $.NSURLBookmarkCreationWithSecurityScope = 2048; } if (!'NSURLBookmarkResolutionWithSecurityScope' in $) { $.NSURLBookmarkResolutionWithSecurityScope = 1024; } return false; } /** * [checkAppInitialized description] */ function checkAppInitialized() { if (process.platform != 'darwin') { throw new Error('electron-bookmarks can only run on a darwin system.'); } if (!process.mas) { throw new Error('electron-bookmarks must run within a signed, mas-packaged electron application.'); } if (require('is-electron-renderer')) { throw new Error("electron-bookmarks cannot run in electron's renderer process. Please run it in the main process only."); } if (!electron.app.isReady()) { throw new Error('electron-bookmarks can only be used after app is ready.'); } } /** * Checks if a file or directory exists. */ function exists(path$$1, callback) { fs.stat(path$$1, function (err, s) { if (err && err.code == 'ENOENT') { callback(null, false); } else { callback(err, err ? null : s.isFile() || s.isDirectory()); } }); } /** * [checkArguments description] */ function checkArguments(win, opts, cb) { // Shift. if (win != null && win.constructor != electron.BrowserWindow) { var assign; (assign = [opts, win, null], cb = assign[0], opts = assign[1], win = assign[2]); } // Shift. if ((cb == null) && typeof opts == 'function') { var assign$1; (assign$1 = [opts, null], cb = assign$1[0], opts = assign$1[1]); } // Fallback to using very last argument as the callback function. var last = arguments[arguments.length - 1]; if ((cb == null) && typeof last == 'function') { cb = last; } } var 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(?) */ 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'] }; } var buttonLabel = opts.buttonLabel; var defaultPath = opts.defaultPath; var filters = opts.filters; var properties = opts.properties; var title = opts.title; var message = opts.message; var bookmarkType = opts.bookmarkType; 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'); } var dialogProperties = 0; for (var 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: title, buttonLabel: buttonLabel, defaultPath: defaultPath, filters: filters, message: message, properties: dialogProperties, bookmarkType: 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(?) */ function showSaveDialog(win, opts, cb) { checkAppInitialized(); checkArguments(win, opts, cb); if (opts == null) { opts = { title: 'Open', bookmarkType: 'app' }; } var buttonLabel = opts.buttonLabel; var defaultPath = opts.defaultPath; var filters = opts.filters; var title = opts.title; var message = opts.message; var nameFieldLabel = opts.nameFieldLabel; var showsTagField = opts.showsTagField; var bookmarkType = opts.bookmarkType; 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: title, buttonLabel: buttonLabel, defaultPath: defaultPath, filters: filters, message: message, nameFieldLabel: nameFieldLabel, showsTagField: showsTagField, bookmarkType: bookmarkType }; objc.showSaveDialog(win, opts, cb); } /** * OBJECTIVE-C BRIDGING. */ var objc = { showOpenDialog: function (win, opts, cb) { checkImports(); var 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 var handler = $(this.runModal(dialog, opts, cb, 'openPanel'), ['v',['?', 'i']]); dialog('beginSheetModalForWindow', win ? this.findNativeWindow(win) : $.NIL, 'completionHandler', handler); }, showSaveDialog: function (win, opts, cb) { checkImports(); var 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 var 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) { var this$1 = this; return function (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$1.readDialogURLs(dialog, cb, opts.bookmarkType); } else if (type == 'savePanel') { this$1.readDialogURL(dialog, cb, opts.bookmarkType); } } }, /** * Reads the url from an NSSavePanel. */ readDialogURL: function (dialog, callback, bookmarkType) { var this$1 = this; var url = dialog('URL'); var path$$1 = url('path')('UTF8String'); if (!bookmarkType) { return callback(path$$1); } // Retain url since we'll make async calls here. url('retain'); // This will create the bookmark, and release the url. var createBookmark = function () { var defaults = $.NSUserDefaults('standardUserDefaults'), bookmark = this$1.createSecurityBookmark(defaults, url, bookmarkType); if (bookmark.key) { defaults('synchronize'); } // Release url to be garbage-collected. url('release'); callback(path$$1, bookmark); }; // Check if the chosen path exists. exists(path$$1, function (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$$1, '', function (err) { if (err) { url('release'); // Don't want any memory leaks. throw err; } createBookmark(); }); } }); }, /** * Reads urls from an NSOpenPanel. */ readDialogURLs: function (dialog, cb, bookmarkType) { var this$1 = this; var filenames = [], bookmarks, defaults; if (bookmarkType) { bookmarks = { keys: [], errors: [] }; defaults = $.NSUserDefaults('standardUserDefaults'); } var urls = dialog('URLs'); for (var i = 0, c = urls('count'); i < c; i++) { var url = urls('objectAtIndex', i); if (url('isFileURL')) { filenames.push(url('path')('UTF8String')); // Create Security-Scoped bookmark from NSURL. if (bookmarkType) { var bookmark = this$1.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) { var path$$1 = 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 var relativeToURL = isAppBookmark ? $.NIL : $.NSURL('fileURLWithPath', path$$1, 'isDirectory', $.NO); var 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 { var err$1 = error.deref(); console.error({ userInfo: err$1('userInfo'), context: bookmark }); throw new Error(("[electron-bookmarks] Error creating security-scoped bookmark:\nNativeError: " + (err$1('localizedDescription')))); } catch (err) { if (err.message.startsWith('[electron-bookmarks]')) { throw err; } // Ignore Dereferencing error. } // We hash the path to avoid super long keys. var hash = crypto.createHash('md5').update(path$$1('UTF8String')).digest('hex'), key = "" + moduleKey + hash, bookmark = $.NSMutableDictionary('alloc')('init'); // Save to NSUserDefaults as { path, bookmark: NSData, type: "app" or "document" }. bookmark('setObject', path$$1, '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) { var dir = path.dirname(opts.defaultPath); if (dir) { dialog('setDirectoryURL', $.NSURL('fileURLWithPath', $(dir))); } var 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) { var file_type_set = $.NSMutableSet('set'); for (var 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. var 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) { var windows = $.NSApplication('sharedApplication')('windows'), test = 'electron-bookmarks:__window__:AtomNSWindow'; // Remember old window title, and set title to our test. var windowTitle = win.getTitle(); win.setTitle(test); // Look through each NSWindow for our title. for (var i = 0, c = windows('count'); i < c; i++) { var 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; } }; var dialog = Object.freeze({ showOpenDialog: showOpenDialog, showSaveDialog: showSaveDialog }); /** * Return an array of all bookmarks saved in NSUserDefaults. * @return {array} */ function list() { checkAppInitialized(); checkImports(); var bookmarks = [], defaultsDictionary = $.NSUserDefaults('standardUserDefaults')('dictionaryRepresentation'); var keys = defaultsDictionary('allKeys'); for (var i = 0, c = keys('count'); i < c; i++) { var key = keys('objectAtIndex', i)('UTF8String'); if (key.startsWith(moduleKey)) { bookmarks.push({ key: key, type: defaultsDictionary('objectForKey', $(key))('objectForKey', $('type'))('UTF8String'), path: defaultsDictionary('objectForKey', $(key))('objectForKey', $('path'))('UTF8String') }); } } return bookmarks; } /** * [open description] * @param {[type]} key [description] * @param {Function} callback [description] * @return {[type]} [description] */ function open(key, callback) { checkAppInitialized(); if (!key || typeof key !== 'string') { throw new TypeError(("Invalid bookmark value.\nBookmark.key must be of type \"string\", got \"" + key + "\"")); } if (!callback || typeof callback !== 'function') { throw new TypeError(("Callback must be of type \"function\", got \"" + key + "\"")); } checkImports(); var defaults = $.NSUserDefaults('standardUserDefaults'), store = defaults('objectForKey', $(key)), data = store('objectForKey', $('bookmark')), type = store('objectForKey', $('type')), path$$1 = store('objectForKey', $('path')); if (!data || typeof data != 'function') { throw new TypeError(("Retrieved value from NSUserDefaults is not of type \"NSData\", got \"" + data + "\"")); } else if (!data('isKindOfClass', $.NSData)) { throw new TypeError(("Retrieved value from NSUserDefaults is not of type \"NSData\", got \"" + (data('className')) + "\"")); } // Convert bookmark data to NSURL. var error = $.alloc($.NSError, $.NIL).ref(), stale = $.alloc($.BOOL).ref(), isAppBookmark = type == 'app'; var relativeToURL = isAppBookmark ? $.NIL : $.NSURL('fileURLWithPath', path$$1, 'isDirectory', $.NO); var bookmarkData = $.NSURL('URLByResolvingBookmarkData', data, 'options', $.NSURLBookmarkResolutionWithSecurityScope, 'relativeToURL', relativeToURL, 'bookmarkDataIsStale', stale, 'error', error); // TODO: the (document-scoped) bookmark data must be placed and retrieved from the NSURL entry of the file itself! // console.log(relativeToURL, path); // console.log(bookmarkData); // console.log(error.deref()); // Dereference the error pointer to see if an error has occurred. But this // may result in an error (null pointer exception ?), hence try/catch. try { var err$1 = error.deref(); console.error({ userInfo: err$1('userInfo'), context: bookmark }); throw new Error(("[electron-bookmarks] Error opening bookmark:\nNativeError: " + (err$1('localizedDescription')))); } catch (err) { if (err.message.startsWith('[electron-bookmarks]')) { throw err; } // Ignore Dereferencing error. } // Is the bookmark stale? if (stale == $.YES) { replaceStaleBookmark(bookmarkData, store, defaults); } else if (!(stale instanceof Buffer)) { // TODO: We haven't been able to test stale bookmarks. I don't know if // there's a way to "make" a bookmark stale... So we log here in the chance // that when it's stale, and `stale != $.YES` we can see what it is. // That's my justification for being noisy here. console.log('STALE: ', stale); // In any case, attempt to replace it if we reach this point. replaceStaleBookmark(bookmarkData, store, defaults); } // Begin accessing the bookmarked resource outside of the sandbox. var didAccess = bookmarkData('startAccessingSecurityScopedResource'); // Retain the object to ensure it's not garbage-collected. bookmarkData('retain'); // If the user hasn't called the close function in 10 seconds, call it now. // This *MUST* be called, otherwise the OS makes bad things happen. var timeout = setTimeout(function () { close(); throw new Error(("Bookmark has not been closed! You *MUST* do this otherwise your app will leak kernel resources.\nForce closing \"" + key + "\" now.")); }, 10e3); // The resource *MUST* be closed, and the object released. function close() { clearTimeout(timeout); // Stop accessing the bookmarked resource. if (didAccess) { bookmarkData('stopAccessingSecurityScopedResource'); } // Release the object so it can be garbage-collected. bookmarkData('release'); } // Call the user's callback passing a correct path and the close function. var filepath = bookmarkData('path')('UTF8String'); callback(filepath, close); } /** * Deletes a bookmark with the passed key if it exists. */ function deleteOne(key) { checkAppInitialized(); checkImports(); var defaults = $.NSUserDefaults('standardUserDefaults'); defaults('removeObjectForKey', $(key)); } /** * Deletes all bookmarks associated with the app. */ function deleteAll(key) { checkAppInitialized(); checkImports(); var defaults = $.NSUserDefaults('standardUserDefaults'), keys = defaults('dictionaryRepresentation')('allKeys'); for (var i = 0, c = keys('count'); i < c; i++) { var key$1 = keys('objectAtIndex', i)('UTF8String'); if (key$1.startsWith(moduleKey)) { defaults('removeObjectForKey', $(key$1)); } } } // [from Apple's Docs] We should create a new bookmark using the returned URL // and use it in place of any stored copies of the existing bookmark. function replaceStaleBookmark(bookmarkData, store, defaults) { var error = $.alloc($.NSError, $.NIL).ref(); var type = store('objectForKey', $('type')), path$$1 = store('objectForKey', $('path')), key = store('objectForKey', $('key')), isAppBookmark = type('UTF8String') == 'app'; // Create new bookmark. var relativeToURL = isAppBookmark ? $.NIL : $.NSURL('fileURLWithPath', path$$1, 'isDirectory', $.NO); var newData = bookmarkData('bookmarkDataWithOptions', $.NSURLBookmarkCreationWithSecurityScope, 'includingResourceValuesForKeys', $.NIL, 'relativeToURL', relativeToURL, 'error', error); // Dereference the error pointer to see if an error has occurred. But this // may result in an error (null pointer exception ?), hence try/catch. try { var err = error.deref(); console.error(("[electron-bookmarks] Error replacing stale bookmark:\nNativeError: " + (err('localizedDescription')))); console.error({ userInfo: err('userInfo') }); console.error('[electron-bookmarks] Removing stale bookmark since it cannot be replaced.'); // Remove bookmark since it's not working and can't be replaced. defaults('removeObjectForKey', $(key)); return; } catch (e) { /* it didn't error */ } // Save bookmark in place of the old one. var replacement = $.NSMutableDictionary('alloc')('init'); replacement('setObject', path$$1, 'forKey', $('path')); replacement('setObject', type, 'forKey', $('type')); replacement('setObject', newData, 'forKey', $('bookmark')); defaults('setObject', replacement, 'forKey', $(key)); defaults('synchronize'); } var bookmarks = Object.freeze({ list: list, open: open, deleteOne: deleteOne, deleteAll: deleteAll }); var electronBookmarks = Object.assign({}, dialog, bookmarks, { init: init }); module.exports = electronBookmarks; //# sourceMappingURL=electron-bookmarks.js.map