From 74968704c8ed8bf81f2c650553f0d8a086c61852 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Thu, 12 Mar 2020 17:17:56 -0700 Subject: [PATCH] improve user experience around mic/camera permission - ask for permission when trying to use a feature, not on startup - if permission is denied, explain the consequence and provide a hint for fixing it --- src/main/index.js | 130 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 111 insertions(+), 19 deletions(-) diff --git a/src/main/index.js b/src/main/index.js index a3a9424..2b83665 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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,77 @@ const createWindow = ({search = null, url = 'index.html', ...browserWindowOption search, slashes: 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 systemPreferences.askForMediaAccess('microphone'); + if (!microphoneResult) { + displayPermissionDeniedWarning(parentWindow, 'microphone'); + return callback(false); + } + } + if (askForCamera) { + const cameraResult = await systemPreferences.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; @@ -140,10 +236,6 @@ const createMainWindow = () => { if (process.platform === 'darwin') { const osxMenu = Menu.buildFromTemplate(MacOSMenu(app)); Menu.setApplicationMenu(osxMenu); - (async () => { - await systemPreferences.askForMediaAccess('microphone'); - await systemPreferences.askForMediaAccess('camera'); - })(); } else { // disable menu for other platforms Menu.setApplicationMenu(null);