diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-07-24 02:23:48 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-07-24 02:23:48 +0200 |
commit | 9c3c441941ad5060ec2db89b805a958a914547f3 (patch) | |
tree | a4224d7bd2576797b14a2ffa1d25ba6e167351ae /src/webview | |
parent | Update submodules, browserslist data updates and linter fixes [skip ci] (diff) | |
download | ferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.tar.gz ferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.tar.zst ferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.zip |
Recipe context isolation (#1456)
* Enable service contextIsolation
* Enable contextIsolation on the service webviews
* Expose a new API window.ferdi in the service main world to allow
calling back into the service isolated world
* Expose a new IPC message inject-js-unsafe from the service isolated
world to execute Javascript in the service main world (i.e., run code
without context isolation). While the name contains the "unsafe"
suffix to show the lack of context isolation, this should mostly be
safe, as no nodejs APIs are available in the injected code.
* Refactor the Notifications shim into a part in the isolated world that
handles displaying and modifying notifications, and a shim in the main
world for the Notifications class. The two communicate via the
window.ferdi endpoint and a Promise object can be used to detect
notification clicks.
* Refactor the screen sharing shim into a part in the isolated world
that enumerated shareable screens and windows and a shim in the main
world that displays the media selector and completes the media
selection promise.
* Expose the injectJSUnsafe API to recipes to inject javascript code
into the main world without context isolation.
* Expose setBadge to the main world
The window.ferdi.setBadge API can be used to update the service badge
from injected unsafe Javascript
* Safer script injection into the service main world
Make sure that we don't try to serialize stray objects back from the
main world to the isolated world by always surrounding the script to be
executed by an anonymous function.
* Always read recipe assets as utf8
* Remove window.log from recipes
We didn't use it anywhere and its behavior was confusing in production
mode.
* Inject multiple unsafe scripts at the same time
* Find in page without remote module
Remove the @electron/remote dependency from the find in page (Ctrl+F)
functionality. The remote webContents is replaced with Electron IPC.
Synchronous IPC messages are handled in the main Electron process,
because the renderer process cannot reply to IPC messages synchronously.
* Update to latest contextIsolation recipes
* Fixing issue with missing 'fs' functions.
Co-authored-by: Vijay A <avijayr@protonmail.com>
Diffstat (limited to 'src/webview')
-rw-r--r-- | src/webview/badge.js | 33 | ||||
-rw-r--r-- | src/webview/find.js | 23 | ||||
-rw-r--r-- | src/webview/lib/RecipeWebview.js | 48 | ||||
-rw-r--r-- | src/webview/notifications.js | 88 | ||||
-rw-r--r-- | src/webview/recipe.js | 132 | ||||
-rw-r--r-- | src/webview/screenshare.js | 81 |
6 files changed, 239 insertions, 166 deletions
diff --git a/src/webview/badge.js b/src/webview/badge.js new file mode 100644 index 000000000..1e02fb56a --- /dev/null +++ b/src/webview/badge.js | |||
@@ -0,0 +1,33 @@ | |||
1 | const { ipcRenderer } = require('electron'); | ||
2 | |||
3 | const debug = require('debug')('Ferdi:Plugin:BadgeHandler'); | ||
4 | |||
5 | export class BadgeHandler { | ||
6 | constructor() { | ||
7 | this.countCache = { | ||
8 | direct: 0, | ||
9 | indirect: 0, | ||
10 | }; | ||
11 | } | ||
12 | |||
13 | setBadge(direct, indirect) { | ||
14 | if (this.countCache.direct === direct | ||
15 | && this.countCache.indirect === indirect) return; | ||
16 | |||
17 | // Parse number to integer | ||
18 | // This will correct errors that recipes may introduce, e.g. | ||
19 | // by sending a String instead of an integer | ||
20 | const directInt = parseInt(direct, 10); | ||
21 | const indirectInt = parseInt(indirect, 10); | ||
22 | |||
23 | const count = { | ||
24 | direct: Math.max(directInt, 0), | ||
25 | indirect: Math.max(indirectInt, 0), | ||
26 | }; | ||
27 | |||
28 | ipcRenderer.sendToHost('message-counts', count); | ||
29 | Object.assign(this.countCache, count); | ||
30 | |||
31 | debug('Sending badge count to host', count); | ||
32 | } | ||
33 | } | ||
diff --git a/src/webview/find.js b/src/webview/find.js new file mode 100644 index 000000000..040811d68 --- /dev/null +++ b/src/webview/find.js | |||
@@ -0,0 +1,23 @@ | |||
1 | import { ipcRenderer } from 'electron'; | ||
2 | import { FindInPage as ElectronFindInPage } from 'electron-find'; | ||
3 | |||
4 | // Shim to expose webContents functionality to electron-find without @electron/remote | ||
5 | const webContentsShim = { | ||
6 | findInPage: (text, options = {}) => ipcRenderer.sendSync('find-in-page', text, options), | ||
7 | stopFindInPage: (action) => { | ||
8 | ipcRenderer.sendSync('stop-find-in-page', action); | ||
9 | }, | ||
10 | on: (eventName, listener) => { | ||
11 | if (eventName === 'found-in-page') { | ||
12 | ipcRenderer.on('found-in-page', (_, result) => { | ||
13 | listener({ sender: this }, result); | ||
14 | }); | ||
15 | } | ||
16 | }, | ||
17 | }; | ||
18 | |||
19 | export default class FindInPage extends ElectronFindInPage { | ||
20 | constructor(options = {}) { | ||
21 | super(webContentsShim, options); | ||
22 | } | ||
23 | } | ||
diff --git a/src/webview/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js index b8fe7dc52..3bb9352f6 100644 --- a/src/webview/lib/RecipeWebview.js +++ b/src/webview/lib/RecipeWebview.js | |||
@@ -1,14 +1,12 @@ | |||
1 | import { ipcRenderer } from 'electron'; | 1 | import { ipcRenderer } from 'electron'; |
2 | import { pathExistsSync, readFile } from 'fs-extra'; | 2 | import { exists, pathExistsSync, readFile } from 'fs-extra'; |
3 | 3 | ||
4 | const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); | 4 | const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); |
5 | 5 | ||
6 | class RecipeWebview { | 6 | class RecipeWebview { |
7 | constructor() { | 7 | constructor(badgeHandler, notificationsHandler) { |
8 | this.countCache = { | 8 | this.badgeHandler = badgeHandler; |
9 | direct: 0, | 9 | this.notificationsHandler = notificationsHandler; |
10 | indirect: 0, | ||
11 | }; | ||
12 | 10 | ||
13 | ipcRenderer.on('poll', () => { | 11 | ipcRenderer.on('poll', () => { |
14 | this.loopFunc(); | 12 | this.loopFunc(); |
@@ -45,24 +43,7 @@ class RecipeWebview { | |||
45 | * me directly to me eg. in a channel | 43 | * me directly to me eg. in a channel |
46 | */ | 44 | */ |
47 | setBadge(direct = 0, indirect = 0) { | 45 | setBadge(direct = 0, indirect = 0) { |
48 | if (this.countCache.direct === direct | 46 | this.badgeHandler.setBadge(direct, indirect); |
49 | && this.countCache.indirect === indirect) return; | ||
50 | |||
51 | // Parse number to integer | ||
52 | // This will correct errors that recipes may introduce, e.g. | ||
53 | // by sending a String instead of an integer | ||
54 | const directInt = parseInt(direct, 10); | ||
55 | const indirectInt = parseInt(indirect, 10); | ||
56 | |||
57 | const count = { | ||
58 | direct: Math.max(directInt, 0), | ||
59 | indirect: Math.max(indirectInt, 0), | ||
60 | }; | ||
61 | |||
62 | ipcRenderer.sendToHost('message-counts', count); | ||
63 | Object.assign(this.countCache, count); | ||
64 | |||
65 | debug('Sending badge count to host', count); | ||
66 | } | 47 | } |
67 | 48 | ||
68 | /** | 49 | /** |
@@ -85,6 +66,23 @@ class RecipeWebview { | |||
85 | }); | 66 | }); |
86 | } | 67 | } |
87 | 68 | ||
69 | injectJSUnsafe(...files) { | ||
70 | Promise.all(files.map(async (file) => { | ||
71 | if (await exists(file)) { | ||
72 | const data = await readFile(file, 'utf8'); | ||
73 | return data; | ||
74 | } | ||
75 | debug('Script not found', file); | ||
76 | return null; | ||
77 | })).then(async (scripts) => { | ||
78 | const scriptsFound = scripts.filter(script => script !== null); | ||
79 | if (scriptsFound.length > 0) { | ||
80 | debug('Inject scripts to main world', scriptsFound); | ||
81 | ipcRenderer.sendToHost('inject-js-unsafe', ...scriptsFound); | ||
82 | } | ||
83 | }); | ||
84 | } | ||
85 | |||
88 | /** | 86 | /** |
89 | * Set a custom handler for turning on and off dark mode | 87 | * Set a custom handler for turning on and off dark mode |
90 | * | 88 | * |
@@ -96,7 +94,7 @@ class RecipeWebview { | |||
96 | 94 | ||
97 | onNotify(fn) { | 95 | onNotify(fn) { |
98 | if (typeof fn === 'function') { | 96 | if (typeof fn === 'function') { |
99 | window.Notification.prototype.onNotify = fn; | 97 | this.notificationsHandler.onNotify = fn; |
100 | } | 98 | } |
101 | } | 99 | } |
102 | 100 | ||
diff --git a/src/webview/notifications.js b/src/webview/notifications.js index 021f05cc3..39a515143 100644 --- a/src/webview/notifications.js +++ b/src/webview/notifications.js | |||
@@ -3,49 +3,65 @@ import uuidV1 from 'uuid/v1'; | |||
3 | 3 | ||
4 | const debug = require('debug')('Ferdi:Notifications'); | 4 | const debug = require('debug')('Ferdi:Notifications'); |
5 | 5 | ||
6 | class Notification { | 6 | export class NotificationsHandler { |
7 | static permission = 'granted'; | 7 | onNotify = data => data; |
8 | 8 | ||
9 | constructor(title = '', options = {}) { | 9 | displayNotification(title, options) { |
10 | debug('New notification', title, options); | 10 | return new Promise((resolve) => { |
11 | this.title = title; | 11 | debug('New notification', title, options); |
12 | this.options = options; | 12 | |
13 | this.notificationId = uuidV1(); | 13 | const notificationId = uuidV1(); |
14 | 14 | ||
15 | ipcRenderer.sendToHost('notification', this.onNotify({ | 15 | ipcRenderer.sendToHost('notification', this.onNotify({ |
16 | title: this.title, | 16 | title, |
17 | options: this.options, | 17 | options, |
18 | notificationId: this.notificationId, | 18 | notificationId, |
19 | })); | 19 | })); |
20 | 20 | ||
21 | ipcRenderer.once(`notification-onclick:${this.notificationId}`, () => { | 21 | ipcRenderer.once(`notification-onclick:${notificationId}`, () => { |
22 | if (typeof this.onclick === 'function') { | 22 | resolve(); |
23 | this.onclick(); | 23 | }); |
24 | } | ||
25 | }); | 24 | }); |
26 | } | 25 | } |
26 | } | ||
27 | 27 | ||
28 | static requestPermission(cb = null) { | 28 | export const notificationsClassDefinition = `(() => { |
29 | if (!cb) { | 29 | class Notification { |
30 | return new Promise((resolve) => { | 30 | static permission = 'granted'; |
31 | resolve(Notification.permission); | ||
32 | }); | ||
33 | } | ||
34 | 31 | ||
35 | if (typeof (cb) === 'function') { | 32 | constructor(title = '', options = {}) { |
36 | return cb(Notification.permission); | 33 | this.title = title; |
34 | this.options = options; | ||
35 | window.ferdi.displayNotification(title, options) | ||
36 | .then(() => { | ||
37 | if (typeof (this.onClick) === 'function') { | ||
38 | this.onClick(); | ||
39 | } | ||
40 | }); | ||
37 | } | 41 | } |
38 | 42 | ||
39 | return Notification.permission; | 43 | static requestPermission(cb = null) { |
40 | } | 44 | if (!cb) { |
45 | return new Promise((resolve) => { | ||
46 | resolve(Notification.permission); | ||
47 | }); | ||
48 | } | ||
41 | 49 | ||
42 | onNotify(data) { | 50 | if (typeof (cb) === 'function') { |
43 | return data; | 51 | return cb(Notification.permission); |
44 | } | 52 | } |
45 | 53 | ||
46 | onClick() {} | 54 | return Notification.permission; |
55 | } | ||
47 | 56 | ||
48 | close() {} | 57 | onNotify(data) { |
49 | } | 58 | return data; |
59 | } | ||
60 | |||
61 | onClick() {} | ||
62 | |||
63 | close() {} | ||
64 | } | ||
50 | 65 | ||
51 | window.Notification = Notification; | 66 | window.Notification = Notification; |
67 | })();`; | ||
diff --git a/src/webview/recipe.js b/src/webview/recipe.js index 8da45864b..d143675dc 100644 --- a/src/webview/recipe.js +++ b/src/webview/recipe.js | |||
@@ -1,11 +1,9 @@ | |||
1 | /* eslint-disable import/first */ | 1 | /* eslint-disable import/first */ |
2 | import { ipcRenderer } from 'electron'; | 2 | import { contextBridge, ipcRenderer } from 'electron'; |
3 | import { getCurrentWebContents } from '@electron/remote'; | ||
4 | import path from 'path'; | 3 | import path from 'path'; |
5 | import { autorun, computed, observable } from 'mobx'; | 4 | import { autorun, computed, observable } from 'mobx'; |
6 | import fs from 'fs-extra'; | 5 | import fs from 'fs-extra'; |
7 | import { debounce } from 'lodash'; | 6 | import { debounce } from 'lodash'; |
8 | import { FindInPage } from 'electron-find'; | ||
9 | 7 | ||
10 | // For some services darkreader tries to use the chrome extension message API | 8 | // For some services darkreader tries to use the chrome extension message API |
11 | // This will cause the service to fail loading | 9 | // This will cause the service to fail loading |
@@ -23,16 +21,81 @@ import customDarkModeCss from './darkmode/custom'; | |||
23 | import RecipeWebview from './lib/RecipeWebview'; | 21 | import RecipeWebview from './lib/RecipeWebview'; |
24 | import Userscript from './lib/Userscript'; | 22 | import Userscript from './lib/Userscript'; |
25 | 23 | ||
26 | import { switchDict, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; | 24 | import { BadgeHandler } from './badge'; |
27 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; | ||
28 | import contextMenu from './contextMenu'; | 25 | import contextMenu from './contextMenu'; |
29 | import './notifications'; | 26 | import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode'; |
30 | import { screenShareCss } from './screenshare'; | 27 | import FindInPage from './find'; |
28 | import { NotificationsHandler, notificationsClassDefinition } from './notifications'; | ||
29 | import { getDisplayMediaSelector, screenShareCss, screenShareJs } from './screenshare'; | ||
30 | import { switchDict, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; | ||
31 | 31 | ||
32 | import { DEFAULT_APP_SETTINGS, isDevMode } from '../environment'; | 32 | import { DEFAULT_APP_SETTINGS } from '../environment'; |
33 | 33 | ||
34 | const debug = require('debug')('Ferdi:Plugin'); | 34 | const debug = require('debug')('Ferdi:Plugin'); |
35 | 35 | ||
36 | const badgeHandler = new BadgeHandler(); | ||
37 | |||
38 | const notificationsHandler = new NotificationsHandler(); | ||
39 | |||
40 | // Patching window.open | ||
41 | const originalWindowOpen = window.open; | ||
42 | |||
43 | window.open = (url, frameName, features) => { | ||
44 | debug('window.open', url, frameName, features); | ||
45 | if (!url) { | ||
46 | // The service hasn't yet supplied a URL (as used in Skype). | ||
47 | // Return a new dummy window object and wait for the service to change the properties | ||
48 | const newWindow = { | ||
49 | location: { | ||
50 | href: '', | ||
51 | }, | ||
52 | }; | ||
53 | |||
54 | const checkInterval = setInterval(() => { | ||
55 | // Has the service changed the URL yet? | ||
56 | if (newWindow.location.href !== '') { | ||
57 | if (features) { | ||
58 | originalWindowOpen(newWindow.location.href, frameName, features); | ||
59 | } else { | ||
60 | // Open the new URL | ||
61 | ipcRenderer.sendToHost('new-window', newWindow.location.href); | ||
62 | } | ||
63 | clearInterval(checkInterval); | ||
64 | } | ||
65 | }, 0); | ||
66 | |||
67 | setTimeout(() => { | ||
68 | // Stop checking for location changes after 1 second | ||
69 | clearInterval(checkInterval); | ||
70 | }, 1000); | ||
71 | |||
72 | return newWindow; | ||
73 | } | ||
74 | |||
75 | // We need to differentiate if the link should be opened in a popup or in the systems default browser | ||
76 | if (!frameName && !features && typeof features !== 'string') { | ||
77 | return ipcRenderer.sendToHost('new-window', url); | ||
78 | } | ||
79 | |||
80 | if (url) { | ||
81 | return originalWindowOpen(url, frameName, features); | ||
82 | } | ||
83 | }; | ||
84 | |||
85 | // We can't override APIs here, so we first expose functions via window.ferdi, | ||
86 | // then overwrite the corresponding field of the window object by injected JS. | ||
87 | contextBridge.exposeInMainWorld('ferdi', { | ||
88 | open: window.open, | ||
89 | setBadge: (direct, indirect) => badgeHandler.setBadge(direct || 0, indirect || 0), | ||
90 | displayNotification: (title, options) => notificationsHandler.displayNotification(title, options), | ||
91 | getDisplayMediaSelector, | ||
92 | }); | ||
93 | |||
94 | ipcRenderer.sendToHost('inject-js-unsafe', | ||
95 | 'window.open = window.ferdi.open;', | ||
96 | notificationsClassDefinition, | ||
97 | screenShareJs); | ||
98 | |||
36 | class RecipeController { | 99 | class RecipeController { |
37 | @observable settings = { | 100 | @observable settings = { |
38 | overrideSpellcheckerLanguage: false, | 101 | overrideSpellcheckerLanguage: false, |
@@ -97,7 +160,7 @@ class RecipeController { | |||
97 | autorun(() => this.update()); | 160 | autorun(() => this.update()); |
98 | 161 | ||
99 | document.addEventListener('DOMContentLoaded', () => { | 162 | document.addEventListener('DOMContentLoaded', () => { |
100 | this.findInPage = new FindInPage(getCurrentWebContents(), { | 163 | this.findInPage = new FindInPage({ |
101 | inputFocusColor: '#CE9FFC', | 164 | inputFocusColor: '#CE9FFC', |
102 | textColor: '#212121', | 165 | textColor: '#212121', |
103 | }); | 166 | }); |
@@ -111,7 +174,7 @@ class RecipeController { | |||
111 | // Delete module from cache | 174 | // Delete module from cache |
112 | delete require.cache[require.resolve(modulePath)]; | 175 | delete require.cache[require.resolve(modulePath)]; |
113 | try { | 176 | try { |
114 | this.recipe = new RecipeWebview(); | 177 | this.recipe = new RecipeWebview(badgeHandler, notificationsHandler); |
115 | // eslint-disable-next-line | 178 | // eslint-disable-next-line |
116 | require(modulePath)(this.recipe, {...config, recipe,}); | 179 | require(modulePath)(this.recipe, {...config, recipe,}); |
117 | debug('Initialize Recipe', config, recipe); | 180 | debug('Initialize Recipe', config, recipe); |
@@ -327,52 +390,3 @@ class RecipeController { | |||
327 | /* eslint-disable no-new */ | 390 | /* eslint-disable no-new */ |
328 | new RecipeController(); | 391 | new RecipeController(); |
329 | /* eslint-enable no-new */ | 392 | /* eslint-enable no-new */ |
330 | |||
331 | // Patching window.open | ||
332 | const originalWindowOpen = window.open; | ||
333 | |||
334 | window.open = (url, frameName, features) => { | ||
335 | debug('window.open', url, frameName, features); | ||
336 | if (!url) { | ||
337 | // The service hasn't yet supplied a URL (as used in Skype). | ||
338 | // Return a new dummy window object and wait for the service to change the properties | ||
339 | const newWindow = { | ||
340 | location: { | ||
341 | href: '', | ||
342 | }, | ||
343 | }; | ||
344 | |||
345 | const checkInterval = setInterval(() => { | ||
346 | // Has the service changed the URL yet? | ||
347 | if (newWindow.location.href !== '') { | ||
348 | if (features) { | ||
349 | originalWindowOpen(newWindow.location.href, frameName, features); | ||
350 | } else { | ||
351 | // Open the new URL | ||
352 | ipcRenderer.sendToHost('new-window', newWindow.location.href); | ||
353 | } | ||
354 | clearInterval(checkInterval); | ||
355 | } | ||
356 | }, 0); | ||
357 | |||
358 | setTimeout(() => { | ||
359 | // Stop checking for location changes after 1 second | ||
360 | clearInterval(checkInterval); | ||
361 | }, 1000); | ||
362 | |||
363 | return newWindow; | ||
364 | } | ||
365 | |||
366 | // We need to differentiate if the link should be opened in a popup or in the systems default browser | ||
367 | if (!frameName && !features && typeof features !== 'string') { | ||
368 | return ipcRenderer.sendToHost('new-window', url); | ||
369 | } | ||
370 | |||
371 | if (url) { | ||
372 | return originalWindowOpen(url, frameName, features); | ||
373 | } | ||
374 | }; | ||
375 | |||
376 | if (isDevMode) { | ||
377 | window.log = console.log; | ||
378 | } | ||
diff --git a/src/webview/screenshare.js b/src/webview/screenshare.js index 84d2e1e95..ab548a625 100644 --- a/src/webview/screenshare.js +++ b/src/webview/screenshare.js | |||
@@ -2,6 +2,27 @@ import { desktopCapturer } from 'electron'; | |||
2 | 2 | ||
3 | const CANCEL_ID = 'desktop-capturer-selection__cancel'; | 3 | const CANCEL_ID = 'desktop-capturer-selection__cancel'; |
4 | 4 | ||
5 | export async function getDisplayMediaSelector() { | ||
6 | const sources = await desktopCapturer.getSources({ types: ['screen', 'window'] }); | ||
7 | return `<div class="desktop-capturer-selection__scroller"> | ||
8 | <ul class="desktop-capturer-selection__list"> | ||
9 | ${sources.map(({ id, name, thumbnail }) => ` | ||
10 | <li class="desktop-capturer-selection__item"> | ||
11 | <button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}"> | ||
12 | <img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" /> | ||
13 | <span class="desktop-capturer-selection__name">${name}</span> | ||
14 | </button> | ||
15 | </li> | ||
16 | `).join('')} | ||
17 | <li class="desktop-capturer-selection__item"> | ||
18 | <button class="desktop-capturer-selection__btn" data-id="${CANCEL_ID}" title="Cancel"> | ||
19 | <span class="desktop-capturer-selection__name desktop-capturer-selection__name--cancel">Cancel</span> | ||
20 | </button> | ||
21 | </li> | ||
22 | </ul> | ||
23 | </div>`; | ||
24 | } | ||
25 | |||
5 | export const screenShareCss = ` | 26 | export const screenShareCss = ` |
6 | .desktop-capturer-selection { | 27 | .desktop-capturer-selection { |
7 | position: fixed; | 28 | position: fixed; |
@@ -72,38 +93,12 @@ export const screenShareCss = ` | |||
72 | } | 93 | } |
73 | `; | 94 | `; |
74 | 95 | ||
75 | // Patch getDisplayMedia for screen sharing | 96 | export const screenShareJs = ` |
76 | window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) => { | 97 | window.navigator.mediaDevices.getDisplayMedia = () => new Promise(async (resolve, reject) => { |
77 | try { | 98 | try { |
78 | const sources = await desktopCapturer.getSources({ | ||
79 | types: ['screen', 'window'], | ||
80 | }); | ||
81 | |||
82 | const selectionElem = document.createElement('div'); | 99 | const selectionElem = document.createElement('div'); |
83 | selectionElem.classList = 'desktop-capturer-selection'; | 100 | selectionElem.classList = ['desktop-capturer-selection']; |
84 | selectionElem.innerHTML = ` | 101 | selectionElem.innerHTML = await window.ferdi.getDisplayMediaSelector(); |
85 | <div class="desktop-capturer-selection__scroller"> | ||
86 | <ul class="desktop-capturer-selection__list"> | ||
87 | ${sources | ||
88 | .map( | ||
89 | ({ id, name, thumbnail }) => ` | ||
90 | <li class="desktop-capturer-selection__item"> | ||
91 | <button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}"> | ||
92 | <img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" /> | ||
93 | <span class="desktop-capturer-selection__name">${name}</span> | ||
94 | </button> | ||
95 | </li> | ||
96 | `, | ||
97 | ) | ||
98 | .join('')} | ||
99 | <li class="desktop-capturer-selection__item"> | ||
100 | <button class="desktop-capturer-selection__btn" data-id="${CANCEL_ID}" title="Cancel"> | ||
101 | <span class="desktop-capturer-selection__name desktop-capturer-selection__name--cancel">Cancel</span> | ||
102 | </button> | ||
103 | </li> | ||
104 | </ul> | ||
105 | </div> | ||
106 | `; | ||
107 | document.body.appendChild(selectionElem); | 102 | document.body.appendChild(selectionElem); |
108 | 103 | ||
109 | document | 104 | document |
@@ -112,25 +107,18 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) => | |||
112 | button.addEventListener('click', async () => { | 107 | button.addEventListener('click', async () => { |
113 | try { | 108 | try { |
114 | const id = button.getAttribute('data-id'); | 109 | const id = button.getAttribute('data-id'); |
115 | if (id === CANCEL_ID) { | 110 | if (id === '${CANCEL_ID}') { |
116 | reject(new Error('Cancelled by user')); | 111 | reject(new Error('Cancelled by user')); |
117 | } else { | 112 | } else { |
118 | const mediaSource = sources.find((source) => source.id === id); | 113 | const stream = await window.navigator.mediaDevices.getUserMedia({ |
119 | if (!mediaSource) { | 114 | audio: false, |
120 | throw new Error(`Source with id ${id} does not exist`); | 115 | video: { |
121 | } | 116 | mandatory: { |
122 | 117 | chromeMediaSource: 'desktop', | |
123 | const stream = await window.navigator.mediaDevices.getUserMedia( | 118 | chromeMediaSourceId: id, |
124 | { | ||
125 | audio: false, | ||
126 | video: { | ||
127 | mandatory: { | ||
128 | chromeMediaSource: 'desktop', | ||
129 | chromeMediaSourceId: mediaSource.id, | ||
130 | }, | ||
131 | }, | 119 | }, |
132 | }, | 120 | }, |
133 | ); | 121 | }); |
134 | resolve(stream); | 122 | resolve(stream); |
135 | } | 123 | } |
136 | } catch (err) { | 124 | } catch (err) { |
@@ -143,4 +131,5 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) => | |||
143 | } catch (err) { | 131 | } catch (err) { |
144 | reject(err); | 132 | reject(err); |
145 | } | 133 | } |
146 | }; | 134 | }); |
135 | `; | ||