electron-bookmarks
Version:
Access to macOS Security-Scoped bookmarks in an electron application
829 lines (688 loc) • 25.5 kB
JavaScript
// 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();
;
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