diff options
Diffstat (limited to 'src/webview/recipe.js')
-rw-r--r-- | src/webview/recipe.js | 294 |
1 files changed, 180 insertions, 114 deletions
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 */ |
2 | import { ipcRenderer } from 'electron'; | 2 | import { contextBridge, ipcRenderer } from 'electron'; |
3 | import { getCurrentWebContents } from '@electron/remote'; | 3 | import { join } from 'path'; |
4 | 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 { pathExistsSync, readFileSync } 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,99 @@ 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 { |
30 | import { screenShareCss } from './screenshare'; | 27 | injectDarkModeStyle, |
28 | isDarkModeStyleInjected, | ||
29 | removeDarkModeStyle, | ||
30 | } from './darkmode'; | ||
31 | import FindInPage from './find'; | ||
32 | import { | ||
33 | NotificationsHandler, | ||
34 | notificationsClassDefinition, | ||
35 | } from './notifications'; | ||
36 | import { | ||
37 | getDisplayMediaSelector, | ||
38 | screenShareCss, | ||
39 | screenShareJs, | ||
40 | } from './screenshare'; | ||
41 | import { | ||
42 | switchDict, | ||
43 | getSpellcheckerLocaleByFuzzyIdentifier, | ||
44 | } from './spellchecker'; | ||
31 | 45 | ||
32 | import { DEFAULT_APP_SETTINGS, isDevMode } from '../environment'; | 46 | import { DEFAULT_APP_SETTINGS } from '../environment'; |
33 | 47 | ||
34 | const debug = require('debug')('Ferdi:Plugin'); | 48 | const debug = require('debug')('Ferdi:Plugin'); |
35 | 49 | ||
50 | const badgeHandler = new BadgeHandler(); | ||
51 | |||
52 | const notificationsHandler = new NotificationsHandler(); | ||
53 | |||
54 | // Patching window.open | ||
55 | const originalWindowOpen = window.open; | ||
56 | |||
57 | window.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. | ||
101 | contextBridge.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 | |||
110 | ipcRenderer.sendToHost( | ||
111 | 'inject-js-unsafe', | ||
112 | 'window.open = window.ferdi.open;', | ||
113 | notificationsClassDefinition, | ||
114 | screenShareJs, | ||
115 | ); | ||
116 | |||
36 | class RecipeController { | 117 | class 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 */ |
328 | new RecipeController(); | 443 | new RecipeController(); |
329 | /* eslint-enable no-new */ | 444 | /* 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 | } | ||