aboutsummaryrefslogtreecommitdiffstats
path: root/src/webview
diff options
context:
space:
mode:
Diffstat (limited to 'src/webview')
-rw-r--r--src/webview/badge.js33
-rw-r--r--src/webview/contextMenuBuilder.js25
-rw-r--r--src/webview/darkmode.js10
-rw-r--r--src/webview/find.js23
-rw-r--r--src/webview/lib/RecipeWebview.js50
-rw-r--r--src/webview/notifications.js88
-rw-r--r--src/webview/recipe.js294
-rw-r--r--src/webview/screenshare.js91
-rw-r--r--src/webview/spellchecker.js6
9 files changed, 376 insertions, 244 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 @@
1const { ipcRenderer } = require('electron');
2
3const debug = require('debug')('Ferdi:Plugin:BadgeHandler');
4
5export 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/contextMenuBuilder.js b/src/webview/contextMenuBuilder.js
index 63eed2ebe..602ce06f5 100644
--- a/src/webview/contextMenuBuilder.js
+++ b/src/webview/contextMenuBuilder.js
@@ -10,7 +10,7 @@ import {
10 clipboard, ipcRenderer, nativeImage, shell, 10 clipboard, ipcRenderer, nativeImage, shell,
11} from 'electron'; 11} from 'electron';
12import { Menu, MenuItem } from '@electron/remote'; 12import { Menu, MenuItem } from '@electron/remote';
13import { isMac } from '../environment'; 13import { shortcutKey, isMac } from '../environment';
14 14
15import { SEARCH_ENGINE_NAMES, SEARCH_ENGINE_URLS } from '../config'; 15import { SEARCH_ENGINE_NAMES, SEARCH_ENGINE_URLS } from '../config';
16 16
@@ -28,6 +28,7 @@ const contextMenuStringTable = {
28 cut: () => 'Cut', 28 cut: () => 'Cut',
29 copy: () => 'Copy', 29 copy: () => 'Copy',
30 paste: () => 'Paste', 30 paste: () => 'Paste',
31 pasteAndMatchStyle: () => 'Paste and match style',
31 searchWith: ({ searchEngine }) => `Search with ${searchEngine}`, 32 searchWith: ({ searchEngine }) => `Search with ${searchEngine}`,
32 openLinkUrl: () => 'Open Link', 33 openLinkUrl: () => 'Open Link',
33 openLinkInFerdiUrl: () => 'Open Link in Ferdi', 34 openLinkInFerdiUrl: () => 'Open Link in Ferdi',
@@ -61,7 +62,7 @@ module.exports = class ContextMenuBuilder {
61 * @param {function} processMenu If passed, this method will be passed the menu to change 62 * @param {function} processMenu If passed, this method will be passed the menu to change
62 * it prior to display. Signature: (menu, info) => menu 63 * it prior to display. Signature: (menu, info) => menu
63 */ 64 */
64 constructor(webContents, debugMode = false, processMenu = m => m) { 65 constructor(webContents, debugMode = false, processMenu = (m) => m) {
65 this.debugMode = debugMode; 66 this.debugMode = debugMode;
66 this.processMenu = processMenu; 67 this.processMenu = processMenu;
67 this.menu = null; 68 this.menu = null;
@@ -261,7 +262,7 @@ module.exports = class ContextMenuBuilder {
261 if (menuInfo.misspelledWord) { 262 if (menuInfo.misspelledWord) {
262 menu.append( 263 menu.append(
263 new MenuItem({ 264 new MenuItem({
264 label: 'Add to dictionary', 265 label: this.stringTable.addToDictionary(),
265 click: () => webContents.session.addWordToSpellCheckerDictionary(menuInfo.misspelledWord), 266 click: () => webContents.session.addWordToSpellCheckerDictionary(menuInfo.misspelledWord),
266 }), 267 }),
267 ); 268 );
@@ -320,7 +321,7 @@ module.exports = class ContextMenuBuilder {
320 label: this.stringTable.copyImage(), 321 label: this.stringTable.copyImage(),
321 click: () => { 322 click: () => {
322 const result = this.convertImageToBase64(menuInfo.srcURL, 323 const result = this.convertImageToBase64(menuInfo.srcURL,
323 dataURL => clipboard.writeImage(nativeImage.createFromDataURL(dataURL))); 324 (dataURL) => clipboard.writeImage(nativeImage.createFromDataURL(dataURL)));
324 325
325 this._sendNotificationOnClipboardEvent(menuInfo.clipboardNotifications, () => `Image copied from URL: ${menuInfo.srcURL}`); 326 this._sendNotificationOnClipboardEvent(menuInfo.clipboardNotifications, () => `Image copied from URL: ${menuInfo.srcURL}`);
326 return result; 327 return result;
@@ -375,7 +376,7 @@ module.exports = class ContextMenuBuilder {
375 const webContents = this.getWebContents(); 376 const webContents = this.getWebContents();
376 menu.append(new MenuItem({ 377 menu.append(new MenuItem({
377 label: this.stringTable.cut(), 378 label: this.stringTable.cut(),
378 accelerator: 'CommandOrControl+X', 379 accelerator: `${shortcutKey()}+X`,
379 enabled: menuInfo.editFlags.canCut, 380 enabled: menuInfo.editFlags.canCut,
380 click: () => webContents.cut(), 381 click: () => webContents.cut(),
381 })); 382 }));
@@ -390,7 +391,7 @@ module.exports = class ContextMenuBuilder {
390 const webContents = this.getWebContents(); 391 const webContents = this.getWebContents();
391 menu.append(new MenuItem({ 392 menu.append(new MenuItem({
392 label: this.stringTable.copy(), 393 label: this.stringTable.copy(),
393 accelerator: 'CommandOrControl+C', 394 accelerator: `${shortcutKey()}+C`,
394 enabled: menuInfo.editFlags.canCopy, 395 enabled: menuInfo.editFlags.canCopy,
395 click: () => webContents.copy(), 396 click: () => webContents.copy(),
396 })); 397 }));
@@ -405,7 +406,7 @@ module.exports = class ContextMenuBuilder {
405 const webContents = this.getWebContents(); 406 const webContents = this.getWebContents();
406 menu.append(new MenuItem({ 407 menu.append(new MenuItem({
407 label: this.stringTable.paste(), 408 label: this.stringTable.paste(),
408 accelerator: 'CommandOrControl+V', 409 accelerator: `${shortcutKey()}+V`,
409 enabled: menuInfo.editFlags.canPaste, 410 enabled: menuInfo.editFlags.canPaste,
410 click: () => webContents.paste(), 411 click: () => webContents.paste(),
411 })); 412 }));
@@ -422,8 +423,8 @@ module.exports = class ContextMenuBuilder {
422 const webContents = this.getWebContents(); 423 const webContents = this.getWebContents();
423 menu.append( 424 menu.append(
424 new MenuItem({ 425 new MenuItem({
425 label: 'Paste as plain text', 426 label: this.stringTable.pasteAndMatchStyle(),
426 accelerator: 'CommandOrControl+Shift+V', 427 accelerator: `${shortcutKey()}+Shift+V`,
427 click: () => webContents.pasteAndMatchStyle(), 428 click: () => webContents.pasteAndMatchStyle(),
428 }), 429 }),
429 ); 430 );
@@ -489,7 +490,7 @@ module.exports = class ContextMenuBuilder {
489 const webContents = this.getWebContents(); 490 const webContents = this.getWebContents();
490 menu.append(new MenuItem({ 491 menu.append(new MenuItem({
491 label: this.stringTable.goBack(), 492 label: this.stringTable.goBack(),
492 accelerator: 'CommandOrControl+left', 493 accelerator: `${shortcutKey()}+left`,
493 enabled: webContents.canGoBack(), 494 enabled: webContents.canGoBack(),
494 click: () => webContents.goBack(), 495 click: () => webContents.goBack(),
495 })); 496 }));
@@ -504,7 +505,7 @@ module.exports = class ContextMenuBuilder {
504 const webContents = this.getWebContents(); 505 const webContents = this.getWebContents();
505 menu.append(new MenuItem({ 506 menu.append(new MenuItem({
506 label: this.stringTable.goForward(), 507 label: this.stringTable.goForward(),
507 accelerator: 'CommandOrControl+right', 508 accelerator: `${shortcutKey()}+right`,
508 enabled: webContents.canGoForward(), 509 enabled: webContents.canGoForward(),
509 click: () => webContents.goForward(), 510 click: () => webContents.goForward(),
510 })); 511 }));
@@ -535,7 +536,7 @@ module.exports = class ContextMenuBuilder {
535 const baseURL = new URL(menuInfo.pageURL); 536 const baseURL = new URL(menuInfo.pageURL);
536 menu.append(new MenuItem({ 537 menu.append(new MenuItem({
537 label: this.stringTable.goToHomePage(), 538 label: this.stringTable.goToHomePage(),
538 accelerator: 'CommandOrControl+Home', 539 accelerator: `${shortcutKey()}+Home`,
539 enabled: true, 540 enabled: true,
540 click: () => { 541 click: () => {
541 // webContents.loadURL(baseURL.origin); 542 // webContents.loadURL(baseURL.origin);
diff --git a/src/webview/darkmode.js b/src/webview/darkmode.js
index ab629435c..7435d6404 100644
--- a/src/webview/darkmode.js
+++ b/src/webview/darkmode.js
@@ -1,7 +1,7 @@
1/* eslint no-bitwise: ["error", { "int32Hint": true }] */ 1/* eslint no-bitwise: ["error", { "int32Hint": true }] */
2 2
3import path from 'path'; 3import { join } from 'path';
4import fs from 'fs-extra'; 4import { pathExistsSync, readFileSync } from 'fs-extra';
5 5
6const debug = require('debug')('Ferdi:DarkMode'); 6const debug = require('debug')('Ferdi:DarkMode');
7 7
@@ -10,9 +10,9 @@ const chars = [...'abcdefghijklmnopqrstuvwxyz'];
10const ID = [...Array(20)].map(() => chars[Math.random() * chars.length | 0]).join``; 10const ID = [...Array(20)].map(() => chars[Math.random() * chars.length | 0]).join``;
11 11
12export function injectDarkModeStyle(recipePath) { 12export function injectDarkModeStyle(recipePath) {
13 const darkModeStyle = path.join(recipePath, 'darkmode.css'); 13 const darkModeStyle = join(recipePath, 'darkmode.css');
14 if (fs.pathExistsSync(darkModeStyle)) { 14 if (pathExistsSync(darkModeStyle)) {
15 const data = fs.readFileSync(darkModeStyle); 15 const data = readFileSync(darkModeStyle);
16 const styles = document.createElement('style'); 16 const styles = document.createElement('style');
17 styles.id = ID; 17 styles.id = ID;
18 styles.innerHTML = data.toString(); 18 styles.innerHTML = data.toString();
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 @@
1import { ipcRenderer } from 'electron';
2import { FindInPage as ElectronFindInPage } from 'electron-find';
3
4// Shim to expose webContents functionality to electron-find without @electron/remote
5const 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
19export 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..96caa125e 100644
--- a/src/webview/lib/RecipeWebview.js
+++ b/src/webview/lib/RecipeWebview.js
@@ -1,14 +1,12 @@
1import { ipcRenderer } from 'electron'; 1import { ipcRenderer } from 'electron';
2import { pathExistsSync, readFile } from 'fs-extra'; 2import { exists, pathExistsSync, readFileSync } from 'fs-extra';
3 3
4const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); 4const debug = require('debug')('Ferdi:Plugin:RecipeWebview');
5 5
6class RecipeWebview { 6class 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 /**
@@ -74,9 +55,8 @@ class RecipeWebview {
74 injectCSS(...files) { 55 injectCSS(...files) {
75 files.forEach(async (file) => { 56 files.forEach(async (file) => {
76 if (pathExistsSync(file)) { 57 if (pathExistsSync(file)) {
77 const data = await readFile(file, 'utf8');
78 const styles = document.createElement('style'); 58 const styles = document.createElement('style');
79 styles.innerHTML = data; 59 styles.innerHTML = readFileSync(file, 'utf8');
80 60
81 document.querySelector('head').appendChild(styles); 61 document.querySelector('head').appendChild(styles);
82 62
@@ -85,6 +65,22 @@ class RecipeWebview {
85 }); 65 });
86 } 66 }
87 67
68 injectJSUnsafe(...files) {
69 Promise.all(files.map(async (file) => {
70 if (await exists(file)) {
71 return readFileSync(file, 'utf8');
72 }
73 debug('Script not found', file);
74 return null;
75 })).then(async (scripts) => {
76 const scriptsFound = scripts.filter((script) => script !== null);
77 if (scriptsFound.length > 0) {
78 debug('Inject scripts to main world', scriptsFound);
79 ipcRenderer.sendToHost('inject-js-unsafe', ...scriptsFound);
80 }
81 });
82 }
83
88 /** 84 /**
89 * Set a custom handler for turning on and off dark mode 85 * Set a custom handler for turning on and off dark mode
90 * 86 *
@@ -96,7 +92,7 @@ class RecipeWebview {
96 92
97 onNotify(fn) { 93 onNotify(fn) {
98 if (typeof fn === 'function') { 94 if (typeof fn === 'function') {
99 window.Notification.prototype.onNotify = fn; 95 this.notificationsHandler.onNotify = fn;
100 } 96 }
101 } 97 }
102 98
diff --git a/src/webview/notifications.js b/src/webview/notifications.js
index 021f05cc3..205a3220c 100644
--- a/src/webview/notifications.js
+++ b/src/webview/notifications.js
@@ -3,49 +3,65 @@ import uuidV1 from 'uuid/v1';
3 3
4const debug = require('debug')('Ferdi:Notifications'); 4const debug = require('debug')('Ferdi:Notifications');
5 5
6class Notification { 6export 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) { 28export 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
51window.Notification = Notification; 66 window.Notification = Notification;
67})();`;
diff --git a/src/webview/recipe.js b/src/webview/recipe.js
index 8da45864b..a45c34002 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 */
2import { ipcRenderer } from 'electron'; 2import { contextBridge, ipcRenderer } from 'electron';
3import { getCurrentWebContents } from '@electron/remote'; 3import { join } from 'path';
4import path from 'path';
5import { autorun, computed, observable } from 'mobx'; 4import { autorun, computed, observable } from 'mobx';
6import fs from 'fs-extra'; 5import { pathExistsSync, readFileSync } from 'fs-extra';
7import { debounce } from 'lodash'; 6import { debounce } from 'lodash';
8import { 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,99 @@ import customDarkModeCss from './darkmode/custom';
23import RecipeWebview from './lib/RecipeWebview'; 21import RecipeWebview from './lib/RecipeWebview';
24import Userscript from './lib/Userscript'; 22import Userscript from './lib/Userscript';
25 23
26import { switchDict, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; 24import { BadgeHandler } from './badge';
27import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode';
28import contextMenu from './contextMenu'; 25import contextMenu from './contextMenu';
29import './notifications'; 26import {
30import { screenShareCss } from './screenshare'; 27 injectDarkModeStyle,
28 isDarkModeStyleInjected,
29 removeDarkModeStyle,
30} from './darkmode';
31import FindInPage from './find';
32import {
33 NotificationsHandler,
34 notificationsClassDefinition,
35} from './notifications';
36import {
37 getDisplayMediaSelector,
38 screenShareCss,
39 screenShareJs,
40} from './screenshare';
41import {
42 switchDict,
43 getSpellcheckerLocaleByFuzzyIdentifier,
44} from './spellchecker';
31 45
32import { DEFAULT_APP_SETTINGS, isDevMode } from '../environment'; 46import { DEFAULT_APP_SETTINGS } from '../environment';
33 47
34const debug = require('debug')('Ferdi:Plugin'); 48const debug = require('debug')('Ferdi:Plugin');
35 49
50const badgeHandler = new BadgeHandler();
51
52const notificationsHandler = new NotificationsHandler();
53
54// Patching window.open
55const originalWindowOpen = window.open;
56
57window.open = (url, frameName, features) => {
58 debug('window.open', url, frameName, features);
59 if (!url) {
60 // The service hasn't yet supplied a URL (as used in Skype).
61 // Return a new dummy window object and wait for the service to change the properties
62 const newWindow = {
63 location: {
64 href: '',
65 },
66 };
67
68 const checkInterval = setInterval(() => {
69 // Has the service changed the URL yet?
70 if (newWindow.location.href !== '') {
71 if (features) {
72 originalWindowOpen(newWindow.location.href, frameName, features);
73 } else {
74 // Open the new URL
75 ipcRenderer.sendToHost('new-window', newWindow.location.href);
76 }
77 clearInterval(checkInterval);
78 }
79 }, 0);
80
81 setTimeout(() => {
82 // Stop checking for location changes after 1 second
83 clearInterval(checkInterval);
84 }, 1000);
85
86 return newWindow;
87 }
88
89 // We need to differentiate if the link should be opened in a popup or in the systems default browser
90 if (!frameName && !features && typeof features !== 'string') {
91 return ipcRenderer.sendToHost('new-window', url);
92 }
93
94 if (url) {
95 return originalWindowOpen(url, frameName, features);
96 }
97};
98
99// We can't override APIs here, so we first expose functions via window.ferdi,
100// then overwrite the corresponding field of the window object by injected JS.
101contextBridge.exposeInMainWorld('ferdi', {
102 open: window.open,
103 setBadge: (direct, indirect) =>
104 badgeHandler.setBadge(direct || 0, indirect || 0),
105 displayNotification: (title, options) =>
106 notificationsHandler.displayNotification(title, options),
107 getDisplayMediaSelector,
108});
109
110ipcRenderer.sendToHost(
111 'inject-js-unsafe',
112 'window.open = window.ferdi.open;',
113 notificationsClassDefinition,
114 screenShareJs,
115);
116
36class RecipeController { 117class RecipeController {
37 @observable settings = { 118 @observable settings = {
38 overrideSpellcheckerLanguage: false, 119 overrideSpellcheckerLanguage: false,
@@ -66,7 +147,9 @@ class RecipeController {
66 } 147 }
67 148
68 @computed get spellcheckerLanguage() { 149 @computed get spellcheckerLanguage() {
69 const selected = this.settings.service.spellcheckerLanguage || this.settings.app.spellcheckerLanguage; 150 const selected =
151 this.settings.service.spellcheckerLanguage ||
152 this.settings.app.spellcheckerLanguage;
70 return selected; 153 return selected;
71 } 154 }
72 155
@@ -75,7 +158,7 @@ class RecipeController {
75 findInPage = null; 158 findInPage = null;
76 159
77 async initialize() { 160 async initialize() {
78 Object.keys(this.ipcEvents).forEach((channel) => { 161 Object.keys(this.ipcEvents).forEach(channel => {
79 ipcRenderer.on(channel, (...args) => { 162 ipcRenderer.on(channel, (...args) => {
80 debug('Received IPC event for channel', channel, 'with', ...args); 163 debug('Received IPC event for channel', channel, 'with', ...args);
81 this[this.ipcEvents[channel]](...args); 164 this[this.ipcEvents[channel]](...args);
@@ -97,7 +180,7 @@ class RecipeController {
97 autorun(() => this.update()); 180 autorun(() => this.update());
98 181
99 document.addEventListener('DOMContentLoaded', () => { 182 document.addEventListener('DOMContentLoaded', () => {
100 this.findInPage = new FindInPage(getCurrentWebContents(), { 183 this.findInPage = new FindInPage({
101 inputFocusColor: '#CE9FFC', 184 inputFocusColor: '#CE9FFC',
102 textColor: '#212121', 185 textColor: '#212121',
103 }); 186 });
@@ -106,14 +189,14 @@ class RecipeController {
106 189
107 loadRecipeModule(event, config, recipe) { 190 loadRecipeModule(event, config, recipe) {
108 debug('loadRecipeModule'); 191 debug('loadRecipeModule');
109 const modulePath = path.join(recipe.path, 'webview.js'); 192 const modulePath = join(recipe.path, 'webview.js');
110 debug('module path', modulePath); 193 debug('module path', modulePath);
111 // Delete module from cache 194 // Delete module from cache
112 delete require.cache[require.resolve(modulePath)]; 195 delete require.cache[require.resolve(modulePath)];
113 try { 196 try {
114 this.recipe = new RecipeWebview(); 197 this.recipe = new RecipeWebview(badgeHandler, notificationsHandler);
115 // eslint-disable-next-line 198 // eslint-disable-next-line
116 require(modulePath)(this.recipe, {...config, recipe,}); 199 require(modulePath)(this.recipe, { ...config, recipe });
117 debug('Initialize Recipe', config, recipe); 200 debug('Initialize Recipe', config, recipe);
118 201
119 this.settings.service = Object.assign(config, { recipe }); 202 this.settings.service = Object.assign(config, { recipe });
@@ -131,15 +214,15 @@ class RecipeController {
131 const styles = document.createElement('style'); 214 const styles = document.createElement('style');
132 styles.innerHTML = screenShareCss; 215 styles.innerHTML = screenShareCss;
133 216
134 const userCss = path.join(recipe.path, 'user.css'); 217 const userCss = join(recipe.path, 'user.css');
135 if (await fs.exists(userCss)) { 218 if (pathExistsSync(userCss)) {
136 const data = await fs.readFile(userCss); 219 const data = readFileSync(userCss);
137 styles.innerHTML += data.toString(); 220 styles.innerHTML += data.toString();
138 } 221 }
139 document.querySelector('head').appendChild(styles); 222 document.querySelector('head').appendChild(styles);
140 223
141 const userJs = path.join(recipe.path, 'user.js'); 224 const userJs = join(recipe.path, 'user.js');
142 if (await fs.exists(userJs)) { 225 if (pathExistsSync(userJs)) {
143 const loadUserJs = () => { 226 const loadUserJs = () => {
144 // eslint-disable-next-line 227 // eslint-disable-next-line
145 const userJsModule = require(userJs); 228 const userJsModule = require(userJs);
@@ -167,8 +250,14 @@ class RecipeController {
167 update() { 250 update() {
168 debug('enableSpellchecking', this.settings.app.enableSpellchecking); 251 debug('enableSpellchecking', this.settings.app.enableSpellchecking);
169 debug('isDarkModeEnabled', this.settings.service.isDarkModeEnabled); 252 debug('isDarkModeEnabled', this.settings.service.isDarkModeEnabled);
170 debug('System spellcheckerLanguage', this.settings.app.spellcheckerLanguage); 253 debug(
171 debug('Service spellcheckerLanguage', this.settings.service.spellcheckerLanguage); 254 'System spellcheckerLanguage',
255 this.settings.app.spellcheckerLanguage,
256 );
257 debug(
258 'Service spellcheckerLanguage',
259 this.settings.service.spellcheckerLanguage,
260 );
172 debug('darkReaderSettigs', this.settings.service.darkReaderSettings); 261 debug('darkReaderSettigs', this.settings.service.darkReaderSettings);
173 debug('searchEngine', this.settings.app.searchEngine); 262 debug('searchEngine', this.settings.app.searchEngine);
174 263
@@ -181,7 +270,10 @@ class RecipeController {
181 let { spellcheckerLanguage } = this; 270 let { spellcheckerLanguage } = this;
182 if (spellcheckerLanguage.includes('automatic')) { 271 if (spellcheckerLanguage.includes('automatic')) {
183 this.automaticLanguageDetection(); 272 this.automaticLanguageDetection();
184 debug('Found `automatic` locale, falling back to user locale until detected', this.settings.app.locale); 273 debug(
274 'Found `automatic` locale, falling back to user locale until detected',
275 this.settings.app.locale,
276 );
185 spellcheckerLanguage = this.settings.app.locale; 277 spellcheckerLanguage = this.settings.app.locale;
186 } 278 }
187 switchDict(spellcheckerLanguage); 279 switchDict(spellcheckerLanguage);
@@ -193,7 +285,7 @@ class RecipeController {
193 this.hasUpdatedBeforeRecipeLoaded = true; 285 this.hasUpdatedBeforeRecipeLoaded = true;
194 } 286 }
195 287
196 console.log( 288 debug(
197 'Darkmode enabled?', 289 'Darkmode enabled?',
198 this.settings.service.isDarkModeEnabled, 290 this.settings.service.isDarkModeEnabled,
199 'Dark theme active?', 291 'Dark theme active?',
@@ -204,22 +296,29 @@ class RecipeController {
204 removeDarkModeStyle, 296 removeDarkModeStyle,
205 disableDarkMode, 297 disableDarkMode,
206 enableDarkMode, 298 enableDarkMode,
207 injectDarkModeStyle: () => injectDarkModeStyle(this.settings.service.recipe.path), 299 injectDarkModeStyle: () =>
300 injectDarkModeStyle(this.settings.service.recipe.path),
208 isDarkModeStyleInjected, 301 isDarkModeStyleInjected,
209 }; 302 };
210 303
211 if (this.settings.service.isDarkModeEnabled && this.settings.app.isDarkThemeActive !== false) { 304 if (
305 this.settings.service.isDarkModeEnabled &&
306 this.settings.app.isDarkThemeActive !== false
307 ) {
212 debug('Enable dark mode'); 308 debug('Enable dark mode');
213 309
214 // Check if recipe has a darkmode.css 310 // Check if recipe has a darkmode.css
215 const darkModeStyle = path.join(this.settings.service.recipe.path, 'darkmode.css'); 311 const darkModeStyle = join(
216 const darkModeExists = fs.pathExistsSync(darkModeStyle); 312 this.settings.service.recipe.path,
313 'darkmode.css',
314 );
315 const darkModeExists = pathExistsSync(darkModeStyle);
217 316
218 console.log('darkmode.css exists? ', darkModeExists ? 'Yes' : 'No'); 317 debug('darkmode.css exists? ', darkModeExists ? 'Yes' : 'No');
219 318
220 // Check if recipe has a custom dark mode handler 319 // Check if recipe has a custom dark mode handler
221 if (this.recipe && this.recipe.darkModeHandler) { 320 if (this.recipe && this.recipe.darkModeHandler) {
222 console.log('Using custom dark mode handler'); 321 debug('Using custom dark mode handler');
223 322
224 // Remove other dark mode styles if they were already loaded 323 // Remove other dark mode styles if they were already loaded
225 if (this.hasUpdatedBeforeRecipeLoaded) { 324 if (this.hasUpdatedBeforeRecipeLoaded) {
@@ -230,25 +329,32 @@ class RecipeController {
230 329
231 this.recipe.darkModeHandler(true, handlerConfig); 330 this.recipe.darkModeHandler(true, handlerConfig);
232 } else if (darkModeExists) { 331 } else if (darkModeExists) {
233 console.log('Injecting darkmode.css'); 332 debug('Injecting darkmode.css');
234 injectDarkModeStyle(this.settings.service.recipe.path); 333 injectDarkModeStyle(this.settings.service.recipe.path);
235 334
236 // Make sure universal dark mode is disabled 335 // Make sure universal dark mode is disabled
237 disableDarkMode(); 336 disableDarkMode();
238 this.universalDarkModeInjected = false; 337 this.universalDarkModeInjected = false;
239 } else if (this.settings.app.universalDarkMode && !ignoreList.includes(window.location.host)) { 338 } else if (
240 console.log('Injecting Dark Reader'); 339 this.settings.app.universalDarkMode &&
340 !ignoreList.includes(window.location.host)
341 ) {
342 debug('Injecting Dark Reader');
241 343
242 // Use Dark Reader instead 344 // Use Dark Reader instead
243 const { brightness, contrast, sepia } = this.settings.service.darkReaderSettings; 345 const { brightness, contrast, sepia } =
244 enableDarkMode({ brightness, contrast, sepia }, { 346 this.settings.service.darkReaderSettings;
245 css: customDarkModeCss[window.location.host] || '', 347 enableDarkMode(
246 }); 348 { brightness, contrast, sepia },
349 {
350 css: customDarkModeCss[window.location.host] || '',
351 },
352 );
247 this.universalDarkModeInjected = true; 353 this.universalDarkModeInjected = true;
248 } 354 }
249 } else { 355 } else {
250 debug('Remove dark mode'); 356 debug('Remove dark mode');
251 console.log('DarkMode disabled - removing remaining styles'); 357 debug('DarkMode disabled - removing remaining styles');
252 358
253 if (this.recipe && this.recipe.darkModeHandler) { 359 if (this.recipe && this.recipe.darkModeHandler) {
254 // Remove other dark mode styles if they were already loaded 360 // Remove other dark mode styles if they were already loaded
@@ -260,10 +366,10 @@ class RecipeController {
260 366
261 this.recipe.darkModeHandler(false, handlerConfig); 367 this.recipe.darkModeHandler(false, handlerConfig);
262 } else if (isDarkModeStyleInjected()) { 368 } else if (isDarkModeStyleInjected()) {
263 console.log('Removing injected darkmode.css'); 369 debug('Removing injected darkmode.css');
264 removeDarkModeStyle(); 370 removeDarkModeStyle();
265 } else { 371 } else {
266 console.log('Removing Dark Reader'); 372 debug('Removing Dark Reader');
267 373
268 disableDarkMode(); 374 disableDarkMode();
269 this.universalDarkModeInjected = false; 375 this.universalDarkModeInjected = false;
@@ -273,9 +379,9 @@ class RecipeController {
273 // Remove dark reader if (universal) dark mode was just disabled 379 // Remove dark reader if (universal) dark mode was just disabled
274 if (this.universalDarkModeInjected) { 380 if (this.universalDarkModeInjected) {
275 if ( 381 if (
276 !this.settings.app.darkMode 382 !this.settings.app.darkMode ||
277 || !this.settings.service.isDarkModeEnabled 383 !this.settings.service.isDarkModeEnabled ||
278 || !this.settings.app.universalDarkMode 384 !this.settings.app.universalDarkMode
279 ) { 385 ) {
280 disableDarkMode(); 386 disableDarkMode();
281 this.universalDarkModeInjected = false; 387 this.universalDarkModeInjected = false;
@@ -297,82 +403,42 @@ class RecipeController {
297 } 403 }
298 404
299 async automaticLanguageDetection() { 405 async automaticLanguageDetection() {
300 window.addEventListener('keyup', debounce(async (e) => { 406 window.addEventListener(
301 const element = e.target; 407 'keyup',
302 408 debounce(async e => {
303 if (!element) return; 409 const element = e.target;
304 410
305 let value = ''; 411 if (!element) return;
306 if (element.isContentEditable) { 412
307 value = element.textContent; 413 let value = '';
308 } else if (element.value) { 414 if (element.isContentEditable) {
309 value = element.value; 415 value = element.textContent;
310 } 416 } else if (element.value) {
417 value = element.value;
418 }
311 419
312 // Force a minimum length to get better detection results 420 // Force a minimum length to get better detection results
313 if (value.length < 25) return; 421 if (value.length < 25) return;
314 422
315 debug('Detecting language for', value); 423 debug('Detecting language for', value);
316 const locale = await ipcRenderer.invoke('detect-language', { sample: value }); 424 const locale = await ipcRenderer.invoke('detect-language', {
425 sample: value,
426 });
317 427
318 const spellcheckerLocale = getSpellcheckerLocaleByFuzzyIdentifier(locale); 428 const spellcheckerLocale =
319 debug('Language detected reliably, setting spellchecker language to', spellcheckerLocale); 429 getSpellcheckerLocaleByFuzzyIdentifier(locale);
320 if (spellcheckerLocale) { 430 debug(
321 switchDict(spellcheckerLocale); 431 'Language detected reliably, setting spellchecker language to',
322 } 432 spellcheckerLocale,
323 }, 225)); 433 );
434 if (spellcheckerLocale) {
435 switchDict(spellcheckerLocale);
436 }
437 }, 225),
438 );
324 } 439 }
325} 440}
326 441
327/* eslint-disable no-new */ 442/* eslint-disable no-new */
328new RecipeController(); 443new RecipeController();
329/* eslint-enable no-new */ 444/* eslint-enable no-new */
330
331// Patching window.open
332const originalWindowOpen = window.open;
333
334window.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
376if (isDevMode) {
377 window.log = console.log;
378}
diff --git a/src/webview/screenshare.js b/src/webview/screenshare.js
index 84d2e1e95..e7e43c04e 100644
--- a/src/webview/screenshare.js
+++ b/src/webview/screenshare.js
@@ -2,6 +2,33 @@ import { desktopCapturer } from 'electron';
2 2
3const CANCEL_ID = 'desktop-capturer-selection__cancel'; 3const CANCEL_ID = 'desktop-capturer-selection__cancel';
4 4
5export async function getDisplayMediaSelector() {
6 const sources = await desktopCapturer.getSources({
7 types: ['screen', 'window'],
8 });
9 return `<div class="desktop-capturer-selection__scroller">
10 <ul class="desktop-capturer-selection__list">
11 ${sources
12 .map(
13 ({ id, name, thumbnail }) => `
14 <li class="desktop-capturer-selection__item">
15 <button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}">
16 <img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" />
17 <span class="desktop-capturer-selection__name">${name}</span>
18 </button>
19 </li>
20 `,
21 )
22 .join('')}
23 <li class="desktop-capturer-selection__item">
24 <button class="desktop-capturer-selection__btn" data-id="${CANCEL_ID}" title="Cancel">
25 <span class="desktop-capturer-selection__name desktop-capturer-selection__name--cancel">Cancel</span>
26 </button>
27 </li>
28 </ul>
29</div>`;
30}
31
5export const screenShareCss = ` 32export const screenShareCss = `
6.desktop-capturer-selection { 33.desktop-capturer-selection {
7 position: fixed; 34 position: fixed;
@@ -46,7 +73,9 @@ export const screenShareCss = `
46 padding: 4px; 73 padding: 4px;
47 background: #252626; 74 background: #252626;
48 text-align: left; 75 text-align: left;
49 transition: background-color .15s, box-shadow .15s, color .15s; 76 @media (prefers-reduced-motion: no-preference) {
77 transition: background-color .15s, box-shadow .15s, color .15s;
78 }
50 color: #dedede; 79 color: #dedede;
51} 80}
52.desktop-capturer-selection__btn:hover, 81.desktop-capturer-selection__btn:hover,
@@ -72,38 +101,12 @@ export const screenShareCss = `
72} 101}
73`; 102`;
74 103
75// Patch getDisplayMedia for screen sharing 104export const screenShareJs = `
76window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) => { 105window.navigator.mediaDevices.getDisplayMedia = () => new Promise(async (resolve, reject) => {
77 try { 106 try {
78 const sources = await desktopCapturer.getSources({
79 types: ['screen', 'window'],
80 });
81
82 const selectionElem = document.createElement('div'); 107 const selectionElem = document.createElement('div');
83 selectionElem.classList = 'desktop-capturer-selection'; 108 selectionElem.classList = ['desktop-capturer-selection'];
84 selectionElem.innerHTML = ` 109 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); 110 document.body.appendChild(selectionElem);
108 111
109 document 112 document
@@ -112,25 +115,18 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) =>
112 button.addEventListener('click', async () => { 115 button.addEventListener('click', async () => {
113 try { 116 try {
114 const id = button.getAttribute('data-id'); 117 const id = button.getAttribute('data-id');
115 if (id === CANCEL_ID) { 118 if (id === '${CANCEL_ID}') {
116 reject(new Error('Cancelled by user')); 119 reject(new Error('Cancelled by user'));
117 } else { 120 } else {
118 const mediaSource = sources.find((source) => source.id === id); 121 const stream = await window.navigator.mediaDevices.getUserMedia({
119 if (!mediaSource) { 122 audio: false,
120 throw new Error(`Source with id ${id} does not exist`); 123 video: {
121 } 124 mandatory: {
122 125 chromeMediaSource: 'desktop',
123 const stream = await window.navigator.mediaDevices.getUserMedia( 126 chromeMediaSourceId: id,
124 {
125 audio: false,
126 video: {
127 mandatory: {
128 chromeMediaSource: 'desktop',
129 chromeMediaSourceId: mediaSource.id,
130 },
131 }, 127 },
132 }, 128 },
133 ); 129 });
134 resolve(stream); 130 resolve(stream);
135 } 131 }
136 } catch (err) { 132 } catch (err) {
@@ -143,4 +139,5 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) =>
143 } catch (err) { 139 } catch (err) {
144 reject(err); 140 reject(err);
145 } 141 }
146}; 142});
143`;
diff --git a/src/webview/spellchecker.js b/src/webview/spellchecker.js
index 58a04b728..0f4715207 100644
--- a/src/webview/spellchecker.js
+++ b/src/webview/spellchecker.js
@@ -1,6 +1,6 @@
1import { getCurrentWebContents } from '@electron/remote'; 1import { getCurrentWebContents } from '@electron/remote';
2import { SPELLCHECKER_LOCALES } from '../i18n/languages'; 2import { SPELLCHECKER_LOCALES } from '../i18n/languages';
3import { isMac } from '../environment'; 3import { DEFAULT_APP_SETTINGS, isMac } from '../environment';
4 4
5const debug = require('debug')('Ferdi:spellchecker'); 5const debug = require('debug')('Ferdi:spellchecker');
6 6
@@ -9,7 +9,7 @@ const [defaultLocale] = webContents.session.getSpellCheckerLanguages();
9debug('Spellchecker default locale is', defaultLocale); 9debug('Spellchecker default locale is', defaultLocale);
10 10
11export function getSpellcheckerLocaleByFuzzyIdentifier(identifier) { 11export function getSpellcheckerLocaleByFuzzyIdentifier(identifier) {
12 const locales = Object.keys(SPELLCHECKER_LOCALES).filter(key => key.toLocaleLowerCase() === identifier.toLowerCase() || key.split('-')[0] === identifier.toLowerCase()); 12 const locales = Object.keys(SPELLCHECKER_LOCALES).filter((key) => key.toLocaleLowerCase() === identifier.toLowerCase() || key.split('-')[0] === identifier.toLowerCase());
13 13
14 if (locales.length >= 1) { 14 if (locales.length >= 1) {
15 return locales[0]; 15 return locales[0];
@@ -33,7 +33,7 @@ export function switchDict(locale) {
33 locales.push(foundLocale); 33 locales.push(foundLocale);
34 } 34 }
35 35
36 locales.push(defaultLocale, 'de'); 36 locales.push(defaultLocale, DEFAULT_APP_SETTINGS.fallbackLocale);
37 37
38 webContents.session.setSpellCheckerLanguages(locales); 38 webContents.session.setSpellCheckerLanguages(locales);
39} 39}