Merge pull request #111 from cwillisf/fix-macos-camera-and-mic

macOS: request camera & microphone permissions on demand
This commit is contained in:
Chris Willis-Ford 2020-03-18 11:10:48 -07:00 committed by GitHub
commit a67499c33e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 179 additions and 35 deletions

View file

@ -79,6 +79,20 @@ To generate a signed NSIS installer:
4. Build the NSIS installer only: building the APPX installer will fail if these environment variables are set.
- `npm run dist -- -w nsis`
#### Workaround for code signing issue in macOS
Sometimes the macOS build process will result in a build which crashes on startup. If this happens, check in `Console`
for an entry similar to this:
```text
failed to parse entitlements for Scratch Desktop[12345]: OSUnserializeXML: syntax error near line 1
```
This appears to be an issue with `codesign` itself. Rebooting your computer and trying to build again might help. Yes,
really.
See this issue for more detail: <https://github.com/electron/electron-osx-sign/issues/218>
### Make a semi-packaged build
This will simulate a packaged build without actually packaging it: instead the files will be copied to a subdirectory

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View file

@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -6,17 +6,23 @@ productName: "Scratch Desktop"
afterSign: "scripts/afterSign.js"
mac:
category: public.app-category.education
entitlements: buildResources/entitlements.mac.plist
extendInfo:
NSCameraUsageDescription: >-
This app requires camera access when taking a photo in the paint editor or using the video sensing blocks.
NSMicrophoneUsageDescription: >-
This app requires microphone access when recording sounds or detecting loudness.
gatekeeperAssess: true
hardenedRuntime: true
icon: buildResources/ScratchDesktop.icns
provisioningProfile: embedded.provisionprofile
target:
- dmg
- mas
mas:
type: distribution
mas:
category: public.app-category.education
entitlements: buildResources/entitlements.plist
entitlementsInherit: buildResources/entitlements.inherit.plist
entitlements: buildResources/entitlements.mas.plist
icon: buildResources/ScratchDesktop.icns
win:
icon: buildResources/ScratchDesktop.ico

View file

@ -50,9 +50,10 @@ const runBuilder = function (targetGroup) {
throw new Error(`NSIS build requires CSC_LINK or WIN_CSC_LINK`);
}
const platformFlag = getPlatformFlag();
const command = `electron-builder ${platformFlag} ${targetGroup}`;
console.log(`running: ${command}`);
const result = spawnSync(command, {
const customArgs = process.argv.slice(2); // remove `node` and `this-script.js`
const allArgs = [platformFlag, targetGroup, ...customArgs];
console.log(`running electron-builder with arguments: ${allArgs}`);
const result = spawnSync('electron-builder', allArgs, {
env: childEnvironment,
shell: true,
stdio: 'inherit'

View file

@ -1,4 +1,4 @@
import {BrowserWindow, Menu, app, dialog, ipcMain} from 'electron';
import {BrowserWindow, Menu, app, dialog, ipcMain, systemPreferences} from 'electron';
import fs from 'fs';
import path from 'path';
import {format as formatUrl} from 'url';
@ -18,22 +18,48 @@ const isDevelopment = process.env.NODE_ENV !== 'production';
// global window references prevent them from being garbage-collected
const _windows = {};
const createWindow = ({search = null, url = 'index.html', ...browserWindowOptions}) => {
const window = new BrowserWindow({
useContentSize: true,
show: false,
webPreferences: {
nodeIntegration: true
},
...browserWindowOptions
});
const webContents = window.webContents;
if (isDevelopment) {
webContents.openDevTools({mode: 'detach', activate: true});
const displayPermissionDeniedWarning = (browserWindow, permissionType) => {
let title;
let message;
switch (permissionType) {
case 'camera':
title = 'Camera Permission Denied';
message = 'Permission to use the camera has been denied. ' +
'Scratch will not be able to take a photo or use video sensing blocks.';
break;
case 'microphone':
title = 'Microphone Permission Denied';
message = 'Permission to use the microphone has been denied. ' +
'Scratch will not be able to record sounds or detect loudness.';
break;
default: // shouldn't ever happen...
title = 'Permission Denied';
message = 'A permission has been denied.';
}
const fullUrl = formatUrl(isDevelopment ?
let instructions;
switch (process.platform) {
case 'darwin':
instructions = 'To change Scratch permissions, please check "Security & Privacy" in System Preferences.';
break;
default:
instructions = 'To change Scratch permissions, please check your system settings and restart Scratch.';
break;
}
message = `${message}\n\n${instructions}`;
dialog.showMessageBox(browserWindow, {type: 'warning', title, message});
};
/**
* Build an absolute URL from a relative one, optionally adding search query parameters.
* The base of the URL will depend on whether or not the application is running in development mode.
* @param {string} url - the relative URL, like 'index.html'
* @param {*} search - the optional "search" parameters (the part of the URL after '?'), like "route=about"
* @returns {string} - an absolute URL as a string
*/
const makeFullUrl = (url, search = null) =>
encodeURI(formatUrl(isDevelopment ?
{ // Webpack Dev Server
hostname: 'localhost',
pathname: url,
@ -47,7 +73,94 @@ const createWindow = ({search = null, url = 'index.html', ...browserWindowOption
search,
slashes: true
}
);
));
/**
* Prompt in a platform-specific way for permission to access the microphone or camera, if Electron supports doing so.
* Any application-level checks, such as whether or not a particular frame or document should be allowed to ask,
* should be done before calling this function.
*
* @param {string} mediaType - one of Electron's media types, like 'microphone' or 'camera'
* @returns {boolean} - true if permission granted, false otherwise.
*/
const askForMediaAccess = async mediaType => {
if (systemPreferences.askForMediaAccess) {
// Electron currently only implements this on macOS
return systemPreferences.askForMediaAccess(mediaType);
}
// For other platforms we can't reasonably do anything other than assume we have access.
return true;
};
const handlePermissionRequest = async (webContents, permission, callback, details) => {
if (webContents !== _windows.main.webContents) {
// deny: request came from somewhere other than the main window's web contents
return callback(false);
}
if (!details.isMainFrame) {
// deny: request came from a subframe of the main window, not the main frame
return callback(false);
}
if (permission !== 'media') {
// deny: request is for some other kind of access like notifications or pointerLock
return callback(false);
}
const requiredBase = makeFullUrl('/');
if (details.requestingUrl.indexOf(requiredBase) !== 0) {
// deny: request came from a URL outside of our "sandbox"
return callback(false);
}
let askForMicrophone = false;
let askForCamera = false;
for (const mediaType of details.mediaTypes) {
switch (mediaType) {
case 'audio':
askForMicrophone = true;
break;
case 'video':
askForCamera = true;
break;
default:
// deny: unhandled media type
return callback(false);
}
}
const parentWindow = _windows.main; // if we ever allow media in non-main windows we'll also need to change this
if (askForMicrophone) {
const microphoneResult = await askForMediaAccess('microphone');
if (!microphoneResult) {
displayPermissionDeniedWarning(parentWindow, 'microphone');
return callback(false);
}
}
if (askForCamera) {
const cameraResult = await askForMediaAccess('camera');
if (!cameraResult) {
displayPermissionDeniedWarning(parentWindow, 'camera');
return callback(false);
}
}
return callback(true);
};
const createWindow = ({search = null, url = 'index.html', ...browserWindowOptions}) => {
const window = new BrowserWindow({
useContentSize: true,
show: false,
webPreferences: {
nodeIntegration: true
},
...browserWindowOptions
});
const webContents = window.webContents;
webContents.session.setPermissionRequestHandler(handlePermissionRequest);
if (isDevelopment) {
webContents.openDevTools({mode: 'detach', activate: true});
}
const fullUrl = makeFullUrl(url, search);
window.loadURL(fullUrl);
return window;