From 08c25e4b584154c2080c2f45ea7f70d2ea5b9f43 Mon Sep 17 00:00:00 2001 From: Kiran Niranjan Date: Fri, 9 Mar 2018 15:31:44 +0530 Subject: [PATCH] Electron-256 (Changed the implementation from modal to electron window) (#308) 1. Completed functionality 2. Refactored and added code comments 3. Fixed some style issues 4. Added some safety checks 5. Updated Window size 6. Added some safety checks 7. Fixed some keyboard interaction 8. Fixed styles for Windows OS 9. Added a functionality to place screen picker based on the event sender 10. Updated the code to open the screen picker on top of the focused window instead of finding a ref using window name 11. Added a HTML content to display error message 12. Updated window title 13. Added missing return 14. Changed the method name from `openScreenPickerWindowWindow` to `openScreenPickerWindow` 15. Fixed a typo and added code comment 16. Changes as per PR review 17. Created Enum for key code 18. Updated for loop to for..of loop 19. Updated colors from hex to rgba 20. Setting cursor property as pointer for cancel button and item-container 21. Made window draggable 22. Changed font-family to system fonts 23. Added box shadow for buttons 24. Added a new API to support backward compatibility and deprecated the existing one 25. Fixed the condition prevent a new window from being opened if there is an existing window --- demo/index.html | 6 +- js/desktopCapturer/getSource.js | 96 +++++++++ js/desktopCapturer/getSources.js | 4 + js/desktopCapturer/index.js | 140 ++++++++++++++ js/desktopCapturer/renderer.js | 268 ++++++++++++++++++++++++++ js/desktopCapturer/screen-picker.html | 244 +++++++++++++++++++++++ js/enums/api.js | 3 +- js/mainApiMgr.js | 6 + js/preload/preloadMain.js | 14 ++ 9 files changed, 777 insertions(+), 4 deletions(-) create mode 100644 js/desktopCapturer/getSource.js create mode 100644 js/desktopCapturer/index.js create mode 100644 js/desktopCapturer/renderer.js create mode 100644 js/desktopCapturer/screen-picker.html diff --git a/demo/index.html b/demo/index.html index 43415cf0..3d6d4981 100644 --- a/demo/index.html +++ b/demo/index.html @@ -67,7 +67,7 @@

Get Media Sources:

- +
@@ -193,14 +193,14 @@ var getSources = document.getElementById('get-sources'); getSources.addEventListener('click', function() { - ssf.getMediaSources({types: ['window', 'screen']}, function(error, sources) { + ssf.getMediaSource({types: ['window', 'screen']}, function(error, source) { if (error) throw error navigator.webkitGetUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', - chromeMediaSourceId: sources[0].id, + chromeMediaSourceId: source.id, minWidth: 1280, maxWidth: 1280, minHeight: 720, diff --git a/js/desktopCapturer/getSource.js b/js/desktopCapturer/getSource.js new file mode 100644 index 00000000..f96cd73b --- /dev/null +++ b/js/desktopCapturer/getSource.js @@ -0,0 +1,96 @@ +'use strict'; + +// This code provides equivalent of desktopCapturer.getSources that works in +// a sandbox renderer. see: https://electron.atom.io/docs/api/desktop-capturer/ +// +// The code here is not entirely kosher/stable as it is using private ipc +// events. The code was take directly from electron.asar file provided in +// prebuilt node module. Note: the slight difference here is the thumbnail +// returns a base64 encoded image rather than a electron nativeImage. +// +// Until electron provides access to desktopCapturer in a sandboxed +// renderer process, this will have to do. See github issue posted here to +// electron: https://github.com/electron/electron/issues/9312 + +const { ipcRenderer, remote } = require('electron'); +const apiEnums = require('../enums/api.js'); +const apiCmds = apiEnums.cmds; +const apiName = apiEnums.apiName; +const { isWindowsOS } = require('../utils/misc'); +const USER_CANCELLED = 'User Cancelled'; + +let nextId = 0; +let includes = [].includes; + +function getNextId() { + return ++nextId; +} + +/** + * Checks if the options and their types are valid + * @param options |options.type| can not be empty and has to include 'window' or 'screen'. + * @returns {boolean} + */ +function isValid(options) { + return ((options !== null ? options.types : undefined) !== null) && Array.isArray(options.types); +} + +/** + * Gets the sources for capturing screens / windows + * @param options + * @param callback + * @returns {*} + */ +function getSource(options, callback) { + let captureScreen, captureWindow, id; + if (!isValid(options)) { + return callback(new Error('Invalid options')); + } + captureWindow = includes.call(options.types, 'window'); + captureScreen = includes.call(options.types, 'screen'); + + let updatedOptions = options; + if (!updatedOptions.thumbnailSize) { + updatedOptions.thumbnailSize = { + width: 150, + height: 150 + }; + } + + if (isWindowsOS) { + /** + * Sets the captureWindow to false if Desktop composition + * is disabled otherwise true + * + * Setting captureWindow to false returns only screen sources + * @type {boolean} + */ + captureWindow = remote.systemPreferences.isAeroGlassEnabled(); + } + + id = getNextId(); + ipcRenderer.send('ELECTRON_BROWSER_DESKTOP_CAPTURER_GET_SOURCES', captureWindow, captureScreen, updatedOptions.thumbnailSize, id); + + return ipcRenderer.once('ELECTRON_RENDERER_DESKTOP_CAPTURER_RESULT_' + id, function(event, sources) { + + ipcRenderer.send(apiName, { + cmd: apiCmds.openScreenPickerWindow, + sources: sources, + id: id + }); + + function successCallback(e, source) { + // Cleaning up the event listener to prevent memory leaks + if (!source) { + ipcRenderer.removeListener('start-share' + id, func); + return callback(new Error(USER_CANCELLED)); + } + return callback(null, source); + } + + const func = successCallback.bind(this); + ipcRenderer.once('start-share' + id, func); + }); +} + +module.exports = getSource; \ No newline at end of file diff --git a/js/desktopCapturer/getSources.js b/js/desktopCapturer/getSources.js index fbce373d..4e2ca10b 100644 --- a/js/desktopCapturer/getSources.js +++ b/js/desktopCapturer/getSources.js @@ -87,4 +87,8 @@ function getSources(options, callback) { }); } +/** + * @deprecated instead use getSource + * @type {getSources} + */ module.exports = getSources; \ No newline at end of file diff --git a/js/desktopCapturer/index.js b/js/desktopCapturer/index.js new file mode 100644 index 00000000..4d238c2c --- /dev/null +++ b/js/desktopCapturer/index.js @@ -0,0 +1,140 @@ +'use strict'; + +const electron = require('electron'); +const BrowserWindow = electron.BrowserWindow; +const ipc = electron.ipcMain; +const path = require('path'); +const fs = require('fs'); +const log = require('../log.js'); +const logLevels = require('../enums/logLevels.js'); +const { isMac, isWindowsOS } = require('./../utils/misc.js'); + +let screenPickerWindow; +let preloadWindow; +let eventId; + +let windowConfig = { + width: 580, + height: isMac ? 519 : 523, + show: false, + modal: true, + frame: false, + autoHideMenuBar: true, + resizable: false, + alwaysOnTop: true, + webPreferences: { + preload: path.join(__dirname, 'renderer.js'), + sandbox: true, + nodeIntegration: false + } +}; + +/** + * method to get the HTML template path + * @returns {string} + */ +function getTemplatePath() { + let templatePath = path.join(__dirname, 'screen-picker.html'); + try { + fs.statSync(templatePath).isFile(); + } catch (err) { + log.send(logLevels.ERROR, 'screen-picker: Could not find template ("' + templatePath + '").'); + } + return 'file://' + templatePath; +} + +/** + * Creates the screen picker window + * @param eventSender {RTCRtpSender} - event sender window object + * @param sources {Array} - list of object which has screens and applications + * @param id {Number} - event emitter id + */ +function openScreenPickerWindow(eventSender, sources, id) { + + // prevent a new window from being opened if there is an + // existing window / there is no event sender + if (!eventSender || screenPickerWindow) { + return; + } + + // Screen picker will always be placed on top of the focused window + const focusedWindow = BrowserWindow.getFocusedWindow(); + + // As screen picker is an independent window this will make sure + // it will open screen picker window center of the focused window + if (focusedWindow) { + const { x, y, width, height } = focusedWindow.getBounds(); + + if (x !== undefined && y !== undefined) { + const windowWidth = Math.round(width * 0.5); + const windowHeight = Math.round(height * 0.5); + + // Calculating the center of the parent window + // to place the configuration window + const centerX = x + width / 2.0; + const centerY = y + height / 2.0; + windowConfig.x = Math.round(centerX - (windowWidth / 2.0)); + windowConfig.y = Math.round(centerY - (windowHeight / 2.0)); + } + } + + // Store the window ref to send event + preloadWindow = eventSender; + eventId = id; + + screenPickerWindow = new BrowserWindow(windowConfig); + screenPickerWindow.setVisibleOnAllWorkspaces(true); + screenPickerWindow.loadURL(getTemplatePath()); + + screenPickerWindow.once('ready-to-show', () => { + screenPickerWindow.show(); + }); + + screenPickerWindow.webContents.on('did-finish-load', () => { + screenPickerWindow.webContents.send('desktop-capturer-sources', sources, isWindowsOS); + }); + + screenPickerWindow.on('close', () => { + destroyWindow(); + }); + + screenPickerWindow.on('closed', () => { + destroyWindow(); + }); + +} + +/** + * Destroys a window + */ +function destroyWindow() { + // sending null will clean up the event listener + startScreenShare(null); + screenPickerWindow = null; +} + +/** + * Sends an event to a specific with the selected source + * @param source {Object} - User selected source + */ +function startScreenShare(source) { + if (preloadWindow && !preloadWindow.isDestroyed()) { + preloadWindow.send('start-share' + eventId, source); + } +} + +// Emitted when user has selected a source and press the share button +ipc.on('share-selected-source', (event, source) => { + startScreenShare(source); +}); + +// Emitted when user closes the screen picker window +ipc.on('close-screen-picker', () => { + if (screenPickerWindow && !screenPickerWindow.isDestroyed()) { + screenPickerWindow.close(); + } +}); + +module.exports = { + openScreenPickerWindow +}; \ No newline at end of file diff --git a/js/desktopCapturer/renderer.js b/js/desktopCapturer/renderer.js new file mode 100644 index 00000000..06741365 --- /dev/null +++ b/js/desktopCapturer/renderer.js @@ -0,0 +1,268 @@ +'use strict'; +const { ipcRenderer } = require('electron'); + +const screenRegExp = new RegExp(/^Screen \d+$/gmi); + +// All the required Keyboard keyCode events +const keyCodeEnum = Object.freeze({ + pageDown: 34, + rightArrow: 39, + pageUp: 33, + leftArrow: 37, + homeKey: 36, + upArrow: 38, + endKey: 35, + arrowDown: 40, + enterKey: 13, + escapeKey: 27 +}); + +let availableSources; +let selectedSource; +let currentIndex = -1; + +document.addEventListener('DOMContentLoaded', () => { + renderDom(); +}); + +/** + * Method that renders application data + */ +function renderDom() { + const applicationTab = document.getElementById('application-tab'); + const screenTab = document.getElementById('screen-tab'); + const share = document.getElementById('share'); + const cancel = document.getElementById('cancel'); + const xButton = document.getElementById('x-button'); + + // Event listeners + xButton.addEventListener('click', () => { + closeScreenPickerWindow(); + }, false); + + share.addEventListener('click', () => { + startShare(); + }, false); + + cancel.addEventListener('click', () => { + closeScreenPickerWindow(); + }, false); + + screenTab.addEventListener('click', () => { + updateShareButtonText('Select Screen'); + }, false); + + applicationTab.addEventListener('click', () => { + updateShareButtonText('Select Application'); + }, false); + + document.addEventListener('keyup', handleKeyUpPress.bind(this), true); + +} + +/** + * Event emitted by main process with a list of available + * Screens and Applications + */ +ipcRenderer.on('desktop-capturer-sources', (event, sources, isWindowsOS) => { + + if (!Array.isArray(sources) && typeof isWindowsOS !== 'boolean') { + return; + } + availableSources = sources; + + if (isWindowsOS) { + document.body.classList.add('window-border'); + } + + const screenContent = document.getElementById('screen-contents'); + const applicationContent = document.getElementById('application-contents'); + const applicationTab = document.getElementById('applications'); + const screenTab = document.getElementById('screens'); + + let hasScreens = false; + let hasApplications = false; + + for (let source of sources) { + screenRegExp.lastIndex = 0; + if (source.name === 'Entire screen' || screenRegExp.exec(source.name)) { + source.fileName = 'fullscreen'; + screenContent.appendChild(createItem(source)); + hasScreens = true; + } else { + source.fileName = null; + applicationContent.appendChild(createItem(source)); + hasApplications = true; + } + } + + if (!hasScreens && !hasApplications) { + const errorContent = document.getElementById('error-content'); + const mainContent = document.getElementById('main-content'); + + errorContent.style.display = 'block'; + mainContent.style.display = 'none'; + } + + if (hasApplications) { + applicationTab.classList.remove('hidden'); + } + + if (hasScreens) { + screenTab.classList.remove('hidden'); + } +}); + +function startShare() { + if (selectedSource && selectedSource.id) { + ipcRenderer.send('share-selected-source', selectedSource); + closeScreenPickerWindow(); + } +} + +/** + * Creates DOM elements and injects data + * @param source + * @returns {HTMLDivElement} + */ +function createItem(source) { + const itemContainer = document.createElement("div"); + const sectionBox = document.createElement("div"); + const imageTag = document.createElement("img"); + const titleContainer = document.createElement("div"); + + // Added class names to the dom elements + itemContainer.classList.add('item-container'); + sectionBox.classList.add('screen-section-box'); + imageTag.classList.add('img-wrapper'); + titleContainer.classList.add('screen-source-title'); + + // Inject data to the dom element + imageTag.src = source.thumbnail; + titleContainer.innerText = source.name; + + sectionBox.appendChild(imageTag); + itemContainer.id = source.id; + itemContainer.appendChild(sectionBox); + itemContainer.appendChild(titleContainer); + + itemContainer.addEventListener('click', updateUI.bind(this, source, itemContainer), false); + + return itemContainer; +} + +/** + * When ever user select a source store it and update the UI + * @param source + * @param itemContainer + */ +function updateUI(source, itemContainer) { + selectedSource = source; + + let shareButton = document.getElementById('share'); + shareButton.className = 'share-button'; + + highlightSelectedSource(); + itemContainer.classList.add('selected'); + shareButton.innerText = 'Share' +} + +/** + * Loops through the items and removes + * the selected class property + */ +function highlightSelectedSource() { + let items = document.getElementsByClassName('item-container'); + for (const item of items) { + item.classList.remove('selected'); + } +} + +/** + * Method that updates the share button + * text based on the content type + * @param text + */ +function updateShareButtonText(text) { + let shareButton = document.getElementById('share'); + + if (shareButton && shareButton.classList[0] === 'share-button-disable') { + shareButton.innerText = text; + } +} + +/** + * Method handles used key up event + * @param e + */ +function handleKeyUpPress(e) { + const keyCode = e.keyCode || e.which; + + switch (keyCode) { + case keyCodeEnum.pageDown: + case keyCodeEnum.rightArrow: + updateSelectedSource(1); + break; + case keyCodeEnum.pageUp: + case keyCodeEnum.leftArrow: + updateSelectedSource(-1); + break; + case keyCodeEnum.homeKey: + if (currentIndex !== 0) { + updateSelectedSource(0); + } + break; + case keyCodeEnum.upArrow: + updateSelectedSource(-2); + break; + case keyCodeEnum.endKey: + if (currentIndex !== availableSources.length - 1) { + updateSelectedSource(availableSources.length - 1); + } + break; + case keyCodeEnum.arrowDown: + updateSelectedSource(2); + break; + case keyCodeEnum.enterKey: + startShare(); + break; + case keyCodeEnum.escapeKey: + closeScreenPickerWindow(); + break; + default: + break; + } +} + +/** + * Updates UI based on the key press + * @param index + */ +function updateSelectedSource(index) { + + let selectedElement = document.getElementsByClassName('selected')[0]; + if (selectedElement) { + currentIndex = availableSources.findIndex((source) => { + return source.id === selectedElement.id + }); + } + + // Find the next item to be selected + let nextIndex = (currentIndex + index + availableSources.length) % availableSources.length; + if (availableSources[nextIndex] && availableSources[nextIndex].id) { + let item = document.getElementById(availableSources[nextIndex] ? availableSources[nextIndex].id : ""); + + if (item) { + // Method that stores and update the selected source + updateUI(availableSources[nextIndex], item); + } + } +} + +/** + * Closes the screen picker window + */ +function closeScreenPickerWindow() { + document.removeEventListener('keyUp', handleKeyUpPress.bind(this), true); + ipcRenderer.send('close-screen-picker'); +} \ No newline at end of file diff --git a/js/desktopCapturer/screen-picker.html b/js/desktopCapturer/screen-picker.html new file mode 100644 index 00000000..63aab5b7 --- /dev/null +++ b/js/desktopCapturer/screen-picker.html @@ -0,0 +1,244 @@ + + + + + Screen Picker + + + +
+
+ Choose what you'd like to share +
+
+ + + + + +
+
+
+
+ No screens or applications are currently available. +
+
+ + + + +
+
+
+
+
+ + +
+ + + \ No newline at end of file diff --git a/js/enums/api.js b/js/enums/api.js index c6941e43..16fda17d 100644 --- a/js/enums/api.js +++ b/js/enums/api.js @@ -17,7 +17,8 @@ const cmds = keyMirror({ registerActivityDetection: null, showNotificationSettings: null, sanitize: null, - bringToFront: null + bringToFront: null, + openScreenPickerWindow: null }); module.exports = { diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js index 98bfbf32..f7349a9c 100644 --- a/js/mainApiMgr.js +++ b/js/mainApiMgr.js @@ -16,6 +16,7 @@ const configureNotification = require('./notify/settings/configure-notification- const { bringToFront } = require('./bringToFront.js'); const eventEmitter = require('./eventEmitter'); const { isMac } = require('./utils/misc'); +const { openScreenPickerWindow } = require('./desktopCapturer'); const apiEnums = require('./enums/api.js'); const apiCmds = apiEnums.cmds; @@ -135,6 +136,11 @@ electron.ipcMain.on(apiName, (event, arg) => { bringToFront(arg.windowName, arg.reason); } break; + case apiCmds.openScreenPickerWindow: + if (Array.isArray(arg.sources) && typeof arg.id === 'number') { + openScreenPickerWindow(event.sender, arg.sources, arg.id); + } + break; default: } diff --git a/js/preload/preloadMain.js b/js/preload/preloadMain.js index 84700a9d..8beba256 100644 --- a/js/preload/preloadMain.js +++ b/js/preload/preloadMain.js @@ -18,6 +18,7 @@ const apiEnums = require('../enums/api.js'); const apiCmds = apiEnums.cmds; const apiName = apiEnums.apiName; const getMediaSources = require('../desktopCapturer/getSources'); +const getMediaSource = require('../desktopCapturer/getSource'); require('../downloadManager'); @@ -260,9 +261,22 @@ function createAPI() { * a sandboxed renderer process. * see: https://electron.atom.io/docs/api/desktop-capturer/ * for interface: see documentation in desktopCapturer/getSources.js + * + * @deprecated instead use getMediaSource */ getMediaSources: getMediaSources, + /** + * Implements equivalent of desktopCapturer.getSources - that works in + * a sandboxed renderer process. + * see: https://electron.atom.io/docs/api/desktop-capturer/ + * for interface: see documentation in desktopCapturer/getSource.js + * + * This opens a window and displays all the desktop sources + * and returns selected source + */ + getMediaSource: getMediaSource, + /** * Opens a modal window to configure notification preference. */