From adcf21a3df5ad24ce74b745bd9c5df05151655ca Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Sun, 28 Feb 2021 10:24:14 -0500 Subject: [PATCH] macOS: Prompt for accessibility permissions if needed by Global Shortcuts using Media Keys (Fix #1120) (PR #1121) When setting a media key (play, pause, next/previous track) as global shortcut in Mac OS 10.14+, accessibility permissions must be given to the app for it to work (see https://www.electronjs.org/docs/api/global-shortcut?q=MediaPlayPause#globalshortcutregisteraccelerator-callback). This PR will accomplish the following on generated app launch: - Check if global shortcuts are being setup - Check if the host OS is Mac OS - Check if the global shortcuts were one of the media keys - If the above are true, check if the app has accessibility permissions - If the app does not have the accessibility permissions it will ask the user if they would like to be prompted for these permissions, and then ask Mac OS to prompt for accessibility permissions. ~~As well, a new command line flag is added (`--no-accessibility-prompt`) to preventatively suppress these prompts if desired.~~ Screenshots of the new behavior: ![Screen Shot 2021-02-26 at 2 41 21 PM](https://user-images.githubusercontent.com/547567/109356260-76bfde00-784e-11eb-8c36-3a51b911b780.png) ![Screen Shot 2021-02-26 at 2 41 28 PM](https://user-images.githubusercontent.com/547567/109356266-79223800-784e-11eb-94eb-66437c05fd10.png) ![Screen Shot 2021-02-26 at 2 41 50 PM](https://user-images.githubusercontent.com/547567/109356270-7aebfb80-784e-11eb-9e90-e09bb49752c6.png) Co-authored-by: Ronan Jouchet --- .dockerignore | 2 ++ .gitignore | 2 ++ .npmignore | 1 + app/src/components/mainWindow.ts | 23 +++++++------ app/src/main.ts | 59 +++++++++++++++++++++++++++++--- docs/api.md | 4 ++- docs/development.md | 2 +- package.json | 2 +- src/build/prepareElectronApp.ts | 1 + src/options/model.ts | 1 + src/options/optionsMain.test.ts | 15 ++++++++ src/options/optionsMain.ts | 1 + 12 files changed, 94 insertions(+), 19 deletions(-) diff --git a/.dockerignore b/.dockerignore index 5206a3c..003f817 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,3 +47,5 @@ node_modules *.iml out gen + +.vscode diff --git a/.gitignore b/.gitignore index 9005f8b..81a0c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ gen # Builds when testing npm pack nativefier*.tgz + +.vscode diff --git a/.npmignore b/.npmignore index 9957c84..bbb2abd 100644 --- a/.npmignore +++ b/.npmignore @@ -19,3 +19,4 @@ app/* !app/inject/ !app/nativefier.json !app/package.json +.vscode/ diff --git a/app/src/components/mainWindow.ts b/app/src/components/mainWindow.ts index 93e8892..85219c1 100644 --- a/app/src/components/mainWindow.ts +++ b/app/src/components/mainWindow.ts @@ -17,6 +17,7 @@ import { initContextMenu } from './contextMenu'; import { onNewWindowHelper } from './mainWindowHelpers'; import { createMenu } from './menu'; +export const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json'); const ZOOM_INTERVAL = 0.1; function hideWindow( @@ -72,6 +73,16 @@ function setProxyRules(browserWindow: BrowserWindow, proxyRules): void { }); } +export function saveAppArgs(newAppArgs: any) { + try { + fs.writeFileSync(APP_ARGS_FILE_PATH, JSON.stringify(newAppArgs)); + } catch (err) { + // eslint-disable-next-line no-console + console.log( + `WARNING: Ignored nativefier.json rewrital (${(err as Error).toString()})`, + ); + } +} /** * @param {{}} nativefierOptions AppArgs from nativefier.json * @param {function} onAppQuit @@ -133,17 +144,7 @@ export function createMainWindow( if (options.maximize) { mainWindow.maximize(); options.maximize = undefined; - try { - fs.writeFileSync( - path.join(__dirname, '..', 'nativefier.json'), - JSON.stringify(options), - ); - } catch (err) { - // eslint-disable-next-line no-console - console.log( - `WARNING: Ignored nativefier.json rewrital (${(err as Error).toString()})`, - ); - } + saveAppArgs(options); } if (options.tray === 'start-in-tray') { diff --git a/app/src/main.ts b/app/src/main.ts index e1d5dde..f341c97 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -1,19 +1,23 @@ import 'source-map-support/register'; import fs from 'fs'; -import path from 'path'; import { app, crashReporter, - globalShortcut, - BrowserWindow, dialog, + globalShortcut, + systemPreferences, + BrowserWindow, } from 'electron'; import electronDownload from 'electron-dl'; import { createLoginWindow } from './components/loginWindow'; -import { createMainWindow } from './components/mainWindow'; +import { + createMainWindow, + saveAppArgs, + APP_ARGS_FILE_PATH, +} from './components/mainWindow'; import { createTrayIcon } from './components/trayIcon'; import { isOSX } from './helpers/helpers'; import { inferFlashPath } from './helpers/inferFlash'; @@ -23,7 +27,6 @@ if (require('electron-squirrel-startup')) { app.exit(); } -const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json'); const appArgs = JSON.parse(fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8')); const OLD_BUILD_WARNING_THRESHOLD_DAYS = 60; @@ -168,6 +171,52 @@ if (shouldQuit) { }); }); }); + + if (isOSX() && appArgs.accessibilityPrompt) { + const mediaKeys = [ + 'MediaPlayPause', + 'MediaNextTrack', + 'MediaPreviousTrack', + 'MediaStop', + ]; + const globalShortcutsKeys = appArgs.globalShortcuts.map((g) => g.key); + const mediaKeyWasSet = globalShortcutsKeys.find((g) => + mediaKeys.includes(g), + ); + if ( + mediaKeyWasSet && + !systemPreferences.isTrustedAccessibilityClient(false) + ) { + // Since we're trying to set global keyboard shortcuts for media keys, we need to prompt + // the user for permission on Mac. + // For reference: + // https://www.electronjs.org/docs/api/global-shortcut?q=MediaPlayPause#globalshortcutregisteraccelerator-callback + const accessibilityPromptResult = dialog.showMessageBoxSync(null, { + type: 'question', + message: 'Accessibility Permissions Needed', + buttons: ['Yes', 'No', 'No and never ask again'], + defaultId: 0, + detail: + `${appArgs.name} would like to use one or more of your keyboard's media keys (start, stop, next track, or previous track) to control it.\n\n` + + `Would you like Mac OS to ask for your permission to do so?\n\n` + + `If so, you will need to restart ${appArgs.name} after granting permissions for these keyboard shortcuts to begin working.`, + }); + switch (accessibilityPromptResult) { + // User clicked Yes, prompt for accessibility + case 0: + systemPreferences.isTrustedAccessibilityClient(true); + break; + // User cliecked Never Ask Me Again, save that info + case 2: + appArgs.accessibilityPrompt = false; + saveAppArgs(appArgs); + break; + // User clicked No + default: + break; + } + } + } } if ( !appArgs.disableOldBuildWarning && diff --git a/docs/api.md b/docs/api.md index 28ad340..0e804f4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -220,6 +220,8 @@ The icon parameter can either be a `.icns` or a `.png` file if the [optional dep If you have the optional dependencies `iconutil`, Imagemagick `convert`, and Imagemagick `identify` in your `PATH`, Nativefier will automatically convert the `.png` to a `.icns` for you. +On MacOS 10.14+, if you have set a global shortcut that includes a Media key, the user will need to be prompted for permissions to enable these keys in System Preferences > Security & Privacy > Accessibility. + ###### Manually Converting `.icns` [iConvertIcons](https://iconverticons.com/online/) can be used to convert `.pngs`, though it can be quite cumbersome. @@ -766,7 +768,7 @@ nativefier --browserwindow-options '{ "webPreferences": { "defaul --darwin-dark-mode-support ``` -Enables Dark Mode support on macOS 10.4+. +Enables Dark Mode support on macOS 10.14+. #### [background-color] diff --git a/docs/development.md b/docs/development.md index cfd88df..a311844 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,7 +47,7 @@ Then run your nativefier app _through the command line too_ (to see logs & error your-test-site-win32-x64/your-test-site.exe # Under macOS -open -a YourTestSite.app +./YourTestSite-darwin-x64/YourTestSite.app/Contents/MacOS/YourTestSite --verbose ``` ## Linting & formatting diff --git a/package.json b/package.json index 55b0d6f..7789efb 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "changelog": "./docs/generate-changelog", "ci": "npm run lint && npm test", "clean": "rimraf lib/ app/lib/ app/dist/", - "clean:full": "rimraf lib/ app/lib/ app/dist/ node_modules/ app/node_modules/", + "clean:full": "rimraf lib/ app/lib/ app/dist/ app/node_modules/ node_modules/", "lint:fix": "eslint . --fix", "lint:format": "prettier --write 'src/**/*.js' 'app/src/**/*.js'", "lint": "eslint . --ext .ts", diff --git a/src/build/prepareElectronApp.ts b/src/build/prepareElectronApp.ts index 3394c47..7632886 100644 --- a/src/build/prepareElectronApp.ts +++ b/src/build/prepareElectronApp.ts @@ -15,6 +15,7 @@ const writeFileAsync = promisify(fs.writeFile); */ function pickElectronAppArgs(options: AppOptions): any { return { + accessibilityPrompt: options.nativefier.accessibilityPrompt, alwaysOnTop: options.nativefier.alwaysOnTop, appCopyright: options.packager.appCopyright, appVersion: options.packager.appVersion, diff --git a/src/options/model.ts b/src/options/model.ts index a04071f..7c29f47 100644 --- a/src/options/model.ts +++ b/src/options/model.ts @@ -8,6 +8,7 @@ export interface ElectronPackagerOptions extends electronPackager.Options { export interface AppOptions { packager: ElectronPackagerOptions; nativefier: { + accessibilityPrompt: boolean; alwaysOnTop: boolean; backgroundColor: string; basicAuthPassword: string; diff --git a/src/options/optionsMain.test.ts b/src/options/optionsMain.test.ts index 2e1b229..eec1d7a 100644 --- a/src/options/optionsMain.test.ts +++ b/src/options/optionsMain.test.ts @@ -23,3 +23,18 @@ test('it should call the async config', async () => { ); expect(result.packager.targetUrl).toEqual(params.targetUrl); }); + +test('it should set the accessibility prompt option to true by default', async () => { + const params = { + targetUrl: 'https://example.com/', + }; + const result = await getOptions(params); + expect(asyncConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + nativefier: expect.objectContaining({ + accessibilityPrompt: true, + }), + }), + ); + expect(result.nativefier.accessibilityPrompt).toEqual(true); +}); diff --git a/src/options/optionsMain.ts b/src/options/optionsMain.ts index 654d6b2..8942eba 100644 --- a/src/options/optionsMain.ts +++ b/src/options/optionsMain.ts @@ -47,6 +47,7 @@ export async function getOptions(rawOptions: any): Promise { }, }, nativefier: { + accessibilityPrompt: true, alwaysOnTop: rawOptions.alwaysOnTop || false, backgroundColor: rawOptions.backgroundColor || null, basicAuthPassword: rawOptions.basicAuthPassword || null,