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 <ronan@jouchet.fr>
This commit is contained in:
Adam Weeden 2021-02-28 10:24:14 -05:00 committed by GitHub
parent 75aa10382b
commit adcf21a3df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 94 additions and 19 deletions

View File

@ -47,3 +47,5 @@ node_modules
*.iml
out
gen
.vscode

2
.gitignore vendored
View File

@ -52,3 +52,5 @@ gen
# Builds when testing npm pack
nativefier*.tgz
.vscode

View File

@ -19,3 +19,4 @@ app/*
!app/inject/
!app/nativefier.json
!app/package.json
.vscode/

View File

@ -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') {

View File

@ -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 &&

View File

@ -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 <your-website> --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]

View File

@ -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

View File

@ -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",

View File

@ -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,

View File

@ -8,6 +8,7 @@ export interface ElectronPackagerOptions extends electronPackager.Options {
export interface AppOptions {
packager: ElectronPackagerOptions;
nativefier: {
accessibilityPrompt: boolean;
alwaysOnTop: boolean;
backgroundColor: string;
basicAuthPassword: string;

View File

@ -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);
});

View File

@ -47,6 +47,7 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
},
},
nativefier: {
accessibilityPrompt: true,
alwaysOnTop: rawOptions.alwaysOnTop || false,
backgroundColor: rawOptions.backgroundColor || null,
basicAuthPassword: rawOptions.basicAuthPassword || null,