Skip to content

Commit a67499c

Browse files
authored
Merge pull request scratchfoundation#111 from cwillisf/fix-macos-camera-and-mic
macOS: request camera & microphone permissions on demand
2 parents 8222b71 + 7ee12dc commit a67499c

File tree

7 files changed

+178
-34
lines changed

7 files changed

+178
-34
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,20 @@ To generate a signed NSIS installer:
7979
4. Build the NSIS installer only: building the APPX installer will fail if these environment variables are set.
8080
- `npm run dist -- -w nsis`
8181

82+
#### Workaround for code signing issue in macOS
83+
84+
Sometimes the macOS build process will result in a build which crashes on startup. If this happens, check in `Console`
85+
for an entry similar to this:
86+
87+
```text
88+
failed to parse entitlements for Scratch Desktop[12345]: OSUnserializeXML: syntax error near line 1
89+
```
90+
91+
This appears to be an issue with `codesign` itself. Rebooting your computer and trying to build again might help. Yes,
92+
really.
93+
94+
See this issue for more detail: <https://github.com/electron/electron-osx-sign/issues/218>
95+
8296
### Make a semi-packaged build
8397

8498
This will simulate a packaged build without actually packaging it: instead the files will be copied to a subdirectory

buildResources/entitlements.inherit.plist

Lines changed: 0 additions & 12 deletions
This file was deleted.

buildResources/entitlements.plist renamed to buildResources/entitlements.mac.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5-
<key>com.apple.security.app-sandbox</key>
5+
<key>com.apple.security.cs.allow-jit</key>
66
<true/>
77
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
88
<true/>

buildResources/entitlements.mas.plist

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.app-sandbox</key>
6+
<true/>
7+
<key>com.apple.security.cs.allow-jit</key>
8+
<true/>
9+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
10+
<true/>
11+
<key>com.apple.security.device.audio-input</key>
12+
<true/>
13+
<key>com.apple.security.device.camera</key>
14+
<true/>
15+
<key>com.apple.security.files.user-selected.read-only</key>
16+
<true/>
17+
<key>com.apple.security.files.user-selected.read-write</key>
18+
<true/>
19+
<key>com.apple.security.network.client</key>
20+
<true/>
21+
</dict>
22+
</plist>

electron-builder.yaml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ productName: "Scratch Desktop"
66
afterSign: "scripts/afterSign.js"
77
mac:
88
category: public.app-category.education
9+
entitlements: buildResources/entitlements.mac.plist
10+
extendInfo:
11+
NSCameraUsageDescription: >-
12+
This app requires camera access when taking a photo in the paint editor or using the video sensing blocks.
13+
NSMicrophoneUsageDescription: >-
14+
This app requires microphone access when recording sounds or detecting loudness.
15+
gatekeeperAssess: true
916
hardenedRuntime: true
1017
icon: buildResources/ScratchDesktop.icns
1118
provisioningProfile: embedded.provisionprofile
1219
target:
1320
- dmg
1421
- mas
15-
mas:
1622
type: distribution
23+
mas:
1724
category: public.app-category.education
18-
entitlements: buildResources/entitlements.plist
19-
entitlementsInherit: buildResources/entitlements.inherit.plist
25+
entitlements: buildResources/entitlements.mas.plist
2026
icon: buildResources/ScratchDesktop.icns
2127
win:
2228
icon: buildResources/ScratchDesktop.ico

scripts/electron-builder-wrapper.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ const runBuilder = function (targetGroup) {
5050
throw new Error(`NSIS build requires CSC_LINK or WIN_CSC_LINK`);
5151
}
5252
const platformFlag = getPlatformFlag();
53-
const command = `electron-builder ${platformFlag} ${targetGroup}`;
54-
console.log(`running: ${command}`);
55-
const result = spawnSync(command, {
53+
const customArgs = process.argv.slice(2); // remove `node` and `this-script.js`
54+
const allArgs = [platformFlag, targetGroup, ...customArgs];
55+
console.log(`running electron-builder with arguments: ${allArgs}`);
56+
const result = spawnSync('electron-builder', allArgs, {
5657
env: childEnvironment,
5758
shell: true,
5859
stdio: 'inherit'

src/main/index.js

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {BrowserWindow, Menu, app, dialog, ipcMain} from 'electron';
1+
import {BrowserWindow, Menu, app, dialog, ipcMain, systemPreferences} from 'electron';
22
import fs from 'fs';
33
import path from 'path';
44
import {format as formatUrl} from 'url';
@@ -18,22 +18,48 @@ const isDevelopment = process.env.NODE_ENV !== 'production';
1818
// global window references prevent them from being garbage-collected
1919
const _windows = {};
2020

21-
const createWindow = ({search = null, url = 'index.html', ...browserWindowOptions}) => {
22-
const window = new BrowserWindow({
23-
useContentSize: true,
24-
show: false,
25-
webPreferences: {
26-
nodeIntegration: true
27-
},
28-
...browserWindowOptions
29-
});
30-
const webContents = window.webContents;
21+
const displayPermissionDeniedWarning = (browserWindow, permissionType) => {
22+
let title;
23+
let message;
24+
switch (permissionType) {
25+
case 'camera':
26+
title = 'Camera Permission Denied';
27+
message = 'Permission to use the camera has been denied. ' +
28+
'Scratch will not be able to take a photo or use video sensing blocks.';
29+
break;
30+
case 'microphone':
31+
title = 'Microphone Permission Denied';
32+
message = 'Permission to use the microphone has been denied. ' +
33+
'Scratch will not be able to record sounds or detect loudness.';
34+
break;
35+
default: // shouldn't ever happen...
36+
title = 'Permission Denied';
37+
message = 'A permission has been denied.';
38+
}
3139

32-
if (isDevelopment) {
33-
webContents.openDevTools({mode: 'detach', activate: true});
40+
let instructions;
41+
switch (process.platform) {
42+
case 'darwin':
43+
instructions = 'To change Scratch permissions, please check "Security & Privacy" in System Preferences.';
44+
break;
45+
default:
46+
instructions = 'To change Scratch permissions, please check your system settings and restart Scratch.';
47+
break;
3448
}
49+
message = `${message}\n\n${instructions}`;
50+
51+
dialog.showMessageBox(browserWindow, {type: 'warning', title, message});
52+
};
3553

36-
const fullUrl = formatUrl(isDevelopment ?
54+
/**
55+
* Build an absolute URL from a relative one, optionally adding search query parameters.
56+
* The base of the URL will depend on whether or not the application is running in development mode.
57+
* @param {string} url - the relative URL, like 'index.html'
58+
* @param {*} search - the optional "search" parameters (the part of the URL after '?'), like "route=about"
59+
* @returns {string} - an absolute URL as a string
60+
*/
61+
const makeFullUrl = (url, search = null) =>
62+
encodeURI(formatUrl(isDevelopment ?
3763
{ // Webpack Dev Server
3864
hostname: 'localhost',
3965
pathname: url,
@@ -47,7 +73,94 @@ const createWindow = ({search = null, url = 'index.html', ...browserWindowOption
4773
search,
4874
slashes: true
4975
}
50-
);
76+
));
77+
78+
/**
79+
* Prompt in a platform-specific way for permission to access the microphone or camera, if Electron supports doing so.
80+
* Any application-level checks, such as whether or not a particular frame or document should be allowed to ask,
81+
* should be done before calling this function.
82+
*
83+
* @param {string} mediaType - one of Electron's media types, like 'microphone' or 'camera'
84+
* @returns {boolean} - true if permission granted, false otherwise.
85+
*/
86+
const askForMediaAccess = async mediaType => {
87+
if (systemPreferences.askForMediaAccess) {
88+
// Electron currently only implements this on macOS
89+
return systemPreferences.askForMediaAccess(mediaType);
90+
}
91+
// For other platforms we can't reasonably do anything other than assume we have access.
92+
return true;
93+
};
94+
95+
const handlePermissionRequest = async (webContents, permission, callback, details) => {
96+
if (webContents !== _windows.main.webContents) {
97+
// deny: request came from somewhere other than the main window's web contents
98+
return callback(false);
99+
}
100+
if (!details.isMainFrame) {
101+
// deny: request came from a subframe of the main window, not the main frame
102+
return callback(false);
103+
}
104+
if (permission !== 'media') {
105+
// deny: request is for some other kind of access like notifications or pointerLock
106+
return callback(false);
107+
}
108+
const requiredBase = makeFullUrl('/');
109+
if (details.requestingUrl.indexOf(requiredBase) !== 0) {
110+
// deny: request came from a URL outside of our "sandbox"
111+
return callback(false);
112+
}
113+
let askForMicrophone = false;
114+
let askForCamera = false;
115+
for (const mediaType of details.mediaTypes) {
116+
switch (mediaType) {
117+
case 'audio':
118+
askForMicrophone = true;
119+
break;
120+
case 'video':
121+
askForCamera = true;
122+
break;
123+
default:
124+
// deny: unhandled media type
125+
return callback(false);
126+
}
127+
}
128+
const parentWindow = _windows.main; // if we ever allow media in non-main windows we'll also need to change this
129+
if (askForMicrophone) {
130+
const microphoneResult = await askForMediaAccess('microphone');
131+
if (!microphoneResult) {
132+
displayPermissionDeniedWarning(parentWindow, 'microphone');
133+
return callback(false);
134+
}
135+
}
136+
if (askForCamera) {
137+
const cameraResult = await askForMediaAccess('camera');
138+
if (!cameraResult) {
139+
displayPermissionDeniedWarning(parentWindow, 'camera');
140+
return callback(false);
141+
}
142+
}
143+
return callback(true);
144+
};
145+
146+
const createWindow = ({search = null, url = 'index.html', ...browserWindowOptions}) => {
147+
const window = new BrowserWindow({
148+
useContentSize: true,
149+
show: false,
150+
webPreferences: {
151+
nodeIntegration: true
152+
},
153+
...browserWindowOptions
154+
});
155+
const webContents = window.webContents;
156+
157+
webContents.session.setPermissionRequestHandler(handlePermissionRequest);
158+
159+
if (isDevelopment) {
160+
webContents.openDevTools({mode: 'detach', activate: true});
161+
}
162+
163+
const fullUrl = makeFullUrl(url, search);
51164
window.loadURL(fullUrl);
52165

53166
return window;

0 commit comments

Comments
 (0)