mirror of
https://github.com/scratchfoundation/scratch-desktop.git
synced 2024-12-22 21:52:31 -05:00
Merge pull request #111 from cwillisf/fix-macos-camera-and-mic
macOS: request camera & microphone permissions on demand
This commit is contained in:
commit
a67499c33e
7 changed files with 179 additions and 35 deletions
14
README.md
14
README.md
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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/>
|
22
buildResources/entitlements.mas.plist
Normal file
22
buildResources/entitlements.mas.plist
Normal 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>
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue