aboutsummaryrefslogtreecommitdiffstats
path: root/src/webview/recipe.ts
diff options
context:
space:
mode:
authorLibravatar Balaji Vijayakumar <kuttibalaji.v6@gmail.com>2022-11-02 19:18:55 +0530
committerLibravatar Vijay Aravamudhan <vraravam@users.noreply.github.com>2022-11-02 20:07:36 +0530
commitc04ae52680a5293a46d506517b12cf4fc3d6909c (patch)
tree2d1e4849f0f80fb7abdbc9c5a2997320dc4f5275 /src/webview/recipe.ts
parent6.2.1-nightly.36 [skip ci] (diff)
downloadferdium-app-c04ae52680a5293a46d506517b12cf4fc3d6909c.tar.gz
ferdium-app-c04ae52680a5293a46d506517b12cf4fc3d6909c.tar.zst
ferdium-app-c04ae52680a5293a46d506517b12cf4fc3d6909c.zip
refactor: migrate recipe.js to typescript
Diffstat (limited to 'src/webview/recipe.ts')
-rw-r--r--src/webview/recipe.ts475
1 files changed, 475 insertions, 0 deletions
diff --git a/src/webview/recipe.ts b/src/webview/recipe.ts
new file mode 100644
index 000000000..887d9c367
--- /dev/null
+++ b/src/webview/recipe.ts
@@ -0,0 +1,475 @@
1/* eslint-disable global-require */
2/* eslint-disable import/no-dynamic-require */
3/* eslint-disable import/first */
4import { contextBridge, ipcRenderer } from 'electron';
5import { join } from 'path';
6import { autorun, computed, makeObservable, observable } from 'mobx';
7import { pathExistsSync, readFileSync } from 'fs-extra';
8import { debounce } from 'lodash';
9import {
10 disable as disableDarkMode,
11 enable as enableDarkMode,
12} from 'darkreader';
13
14import { existsSync } from 'fs';
15import ignoreList from './darkmode/ignore';
16import customDarkModeCss from './darkmode/custom';
17
18import RecipeWebview from './lib/RecipeWebview';
19import Userscript from './lib/Userscript';
20
21import BadgeHandler from './badge';
22import DialogTitleHandler from './dialogTitle';
23import SessionHandler from './sessionHandler';
24import contextMenu from './contextMenu';
25import {
26 darkModeStyleExists,
27 injectDarkModeStyle,
28 isDarkModeStyleInjected,
29 removeDarkModeStyle,
30} from './darkmode';
31import FindInPage from './find';
32import {
33 notificationsClassDefinition,
34 NotificationsHandler,
35} from './notifications';
36import {
37 getDisplayMediaSelector,
38 screenShareCss,
39 screenShareJs,
40} from './screenshare';
41import {
42 getSpellcheckerLocaleByFuzzyIdentifier,
43 switchDict,
44} from './spellchecker';
45
46import { ifUndefinedString } from '../jsUtils';
47import { AppStore } from '../@types/stores.types';
48import Service from '../models/Service';
49
50// For some services darkreader tries to use the chrome extension message API
51// This will cause the service to fail loading
52// As the message API is not actually needed, we'll add this shim sendMessage
53// function in order for darkreader to continue working
54// @ts-ignore
55window.chrome.runtime.sendMessage = () => {};
56
57const debug = require('../preload-safe-debug')('Ferdium:Plugin');
58
59const badgeHandler = new BadgeHandler();
60
61const dialogTitleHandler = new DialogTitleHandler();
62
63const sessionHandler = new SessionHandler();
64
65const notificationsHandler = new NotificationsHandler();
66
67// Patching window.open
68const originalWindowOpen = window.open;
69
70window.open = (url, frameName, features): WindowProxy | null => {
71 debug('window.open', url, frameName, features);
72 if (!url) {
73 // The service hasn't yet supplied a URL (as used in Skype).
74 // Return a new dummy window object and wait for the service to change the properties
75 const newWindow = {
76 location: {
77 href: '',
78 },
79 };
80
81 const checkInterval = setInterval(() => {
82 // Has the service changed the URL yet?
83 if (newWindow.location.href !== '') {
84 if (features) {
85 originalWindowOpen(newWindow.location.href, frameName, features);
86 } else {
87 // Open the new URL
88 ipcRenderer.sendToHost('new-window', newWindow.location.href);
89 }
90 clearInterval(checkInterval);
91 }
92 }, 0);
93
94 setTimeout(() => {
95 // Stop checking for location changes after 1 second
96 clearInterval(checkInterval);
97 }, 1000);
98
99 return newWindow as Window;
100 }
101
102 // We need to differentiate if the link should be opened in a popup or in the systems default browser
103 if (!frameName && !features && typeof features !== 'string') {
104 ipcRenderer.sendToHost('new-window', url);
105 return null;
106 }
107
108 if (url) {
109 return originalWindowOpen(url, frameName, features);
110 }
111 return null;
112};
113
114// We can't override APIs here, so we first expose functions via 'window.ferdium',
115// then overwrite the corresponding field of the window object by injected JS.
116contextBridge.exposeInMainWorld('ferdium', {
117 open: window.open,
118 setBadge: (direct, indirect) => badgeHandler.setBadge(direct, indirect),
119 safeParseInt: text => badgeHandler.safeParseInt(text),
120 setDialogTitle: title => dialogTitleHandler.setDialogTitle(title),
121 displayNotification: (title, options) =>
122 notificationsHandler.displayNotification(title, options),
123 getDisplayMediaSelector,
124});
125
126ipcRenderer.sendToHost(
127 'inject-js-unsafe',
128 'window.open = window.ferdium.open;',
129 notificationsClassDefinition,
130 screenShareJs,
131);
132
133class RecipeController {
134 // @ts-ignore
135 @observable settings: {
136 overrideSpellcheckerLanguage: boolean;
137 app: AppStore;
138 service: Service;
139 } = {
140 overrideSpellcheckerLanguage: false,
141 };
142
143 ipcEvents = {
144 'initialize-recipe': 'loadRecipeModule',
145 'settings-update': 'updateAppSettings',
146 'service-settings-update': 'updateServiceSettings',
147 'get-service-id': 'serviceIdEcho',
148 'find-in-page': 'openFindInPage',
149 };
150
151 universalDarkModeInjected = false;
152
153 recipe: RecipeWebview | null = null;
154
155 userscript: Userscript | null = null;
156
157 hasUpdatedBeforeRecipeLoaded = false;
158
159 constructor() {
160 makeObservable(this);
161
162 this.initialize();
163 }
164
165 @computed get spellcheckerLanguage() {
166 return ifUndefinedString(
167 this.settings.service.spellcheckerLanguage,
168 this.settings.app.spellcheckerLanguage,
169 );
170 }
171
172 findInPage: FindInPage | null = null;
173
174 async initialize() {
175 for (const channel of Object.keys(this.ipcEvents)) {
176 ipcRenderer.on(channel, (...args) => {
177 debug('Received IPC event for channel', channel, 'with', ...args);
178 this[this.ipcEvents[channel]](...args);
179 });
180 }
181
182 debug('Send "hello" to host');
183 setTimeout(() => ipcRenderer.sendToHost('hello'), 100);
184
185 contextMenu(
186 () => this.settings.app.enableSpellchecking,
187 () => this.settings.app.spellcheckerLanguage,
188 () => this.spellcheckerLanguage,
189 () => this.settings.app.searchEngine,
190 () => this.settings.app.clipboardNotifications,
191 () => this.settings.app.enableTranslator,
192 () => this.settings.app.translatorEngine,
193 () => this.settings.app.translatorLanguage,
194 );
195
196 autorun(() => this.update());
197
198 document.addEventListener('DOMContentLoaded', () => {
199 this.findInPage = new FindInPage({
200 inputFocusColor: '#CE9FFC',
201 textColor: '#212121',
202 });
203 });
204
205 // Add ability to go forward or back with mouse buttons (inside the recipe)
206 window.addEventListener('mouseup', e => {
207 if (e.button === 3) {
208 e.preventDefault();
209 e.stopPropagation();
210 window.history.back();
211 } else if (e.button === 4) {
212 e.preventDefault();
213 e.stopPropagation();
214 window.history.forward();
215 }
216 });
217 }
218
219 loadRecipeModule(_event, config, recipe) {
220 debug('loadRecipeModule');
221 const modulePath = join(recipe.path, 'webview.js');
222 debug('module path', modulePath);
223 // Delete module from cache
224 delete require.cache[require.resolve(modulePath)];
225 try {
226 this.recipe = new RecipeWebview(
227 badgeHandler,
228 dialogTitleHandler,
229 notificationsHandler,
230 sessionHandler,
231 );
232 if (existsSync(modulePath)) {
233 require(modulePath)(this.recipe, { ...config, recipe });
234 debug('Initialize Recipe', config, recipe);
235 }
236
237 this.settings.service = Object.assign(config, { recipe });
238
239 // Make sure to update the WebView, otherwise the custom darkmode handler may not be used
240 this.update();
241 } catch (error) {
242 console.error('Recipe initialization failed', error);
243 }
244
245 this.loadUserFiles(recipe, config);
246 }
247
248 async loadUserFiles(recipe, config) {
249 const styles = document.createElement('style');
250 styles.innerHTML = screenShareCss;
251
252 const userCss = join(recipe.path, 'user.css');
253 if (pathExistsSync(userCss)) {
254 const data = readFileSync(userCss);
255 styles.innerHTML += data.toString();
256 }
257 document.querySelector('head')?.append(styles);
258
259 const userJs = join(recipe.path, 'user.js');
260 if (pathExistsSync(userJs)) {
261 const loadUserJs = () => {
262 const userJsModule = require(userJs);
263
264 if (typeof userJsModule === 'function') {
265 this.userscript = new Userscript(this.recipe, this, config);
266 userJsModule(config, this.userscript);
267 }
268 };
269
270 if (document.readyState !== 'loading') {
271 loadUserJs();
272 } else {
273 document.addEventListener('DOMContentLoaded', () => {
274 loadUserJs();
275 });
276 }
277 }
278 }
279
280 openFindInPage() {
281 this.findInPage?.openFindWindow();
282 }
283
284 update() {
285 debug('enableSpellchecking', this.settings.app.enableSpellchecking);
286 debug('isDarkModeEnabled', this.settings.service.isDarkModeEnabled);
287 debug(
288 'System spellcheckerLanguage',
289 this.settings.app.spellcheckerLanguage,
290 );
291 debug(
292 'Service spellcheckerLanguage',
293 this.settings.service.spellcheckerLanguage,
294 );
295 debug('darkReaderSettigs', this.settings.service.darkReaderSettings);
296 debug('searchEngine', this.settings.app.searchEngine);
297 debug('enableTranslator', this.settings.app.enableTranslator);
298 debug('translatorEngine', this.settings.app.translatorEngine);
299 debug('translatorLanguage', this.settings.app.translatorLanguage);
300
301 if (this.userscript && this.userscript.internal_setSettings) {
302 this.userscript.internal_setSettings(this.settings);
303 }
304
305 if (this.settings.app.enableSpellchecking) {
306 let { spellcheckerLanguage } = this;
307 debug(`Setting spellchecker language to ${spellcheckerLanguage}`);
308 if (spellcheckerLanguage.includes('automatic')) {
309 this.automaticLanguageDetection();
310 debug(
311 'Found `automatic` locale, falling back to user locale until detected',
312 this.settings.app.locale,
313 );
314 spellcheckerLanguage = this.settings.app.locale;
315 }
316 switchDict(spellcheckerLanguage, this.settings.service.id);
317 } else {
318 debug('Disable spellchecker');
319 }
320
321 if (!this.recipe) {
322 this.hasUpdatedBeforeRecipeLoaded = true;
323 }
324
325 debug(
326 'Darkmode enabled?',
327 this.settings.service.isDarkModeEnabled,
328 'Dark theme active?',
329 // @ts-ignore
330 this.settings.app.isDarkThemeActive,
331 );
332
333 const handlerConfig = {
334 removeDarkModeStyle,
335 disableDarkMode,
336 enableDarkMode,
337 injectDarkModeStyle: () =>
338 injectDarkModeStyle(this.settings.service.recipe.path),
339 isDarkModeStyleInjected,
340 };
341
342 if (this.settings.service.isDarkModeEnabled) {
343 debug('Enable dark mode');
344
345 // Check if recipe has a custom dark mode handler
346 if (this.recipe && this.recipe.darkModeHandler) {
347 debug('Using custom dark mode handler');
348
349 // Remove other dark mode styles if they were already loaded
350 if (this.hasUpdatedBeforeRecipeLoaded) {
351 this.hasUpdatedBeforeRecipeLoaded = false;
352 removeDarkModeStyle();
353 disableDarkMode();
354 }
355
356 this.recipe.darkModeHandler(true, handlerConfig);
357 } else if (darkModeStyleExists(this.settings.service.recipe.path)) {
358 debug('Injecting darkmode from recipe');
359 injectDarkModeStyle(this.settings.service.recipe.path);
360
361 // Make sure universal dark mode is disabled
362 disableDarkMode();
363 this.universalDarkModeInjected = false;
364 } else if (
365 this.settings.app.universalDarkMode &&
366 !ignoreList.includes(window.location.host)
367 ) {
368 debug('Injecting Dark Reader');
369
370 // Use Dark Reader instead
371 const { brightness, contrast, sepia } =
372 this.settings.service.darkReaderSettings;
373 enableDarkMode(
374 { brightness, contrast, sepia },
375 {
376 css: customDarkModeCss[window.location.host] || '',
377 invert: [],
378 ignoreImageAnalysis: [],
379 ignoreInlineStyle: [],
380 disableStyleSheetsProxy: false,
381 },
382 );
383 this.universalDarkModeInjected = true;
384 }
385 } else {
386 debug('Remove dark mode');
387 debug('DarkMode disabled - removing remaining styles');
388
389 if (this.recipe && this.recipe.darkModeHandler) {
390 // Remove other dark mode styles if they were already loaded
391 if (this.hasUpdatedBeforeRecipeLoaded) {
392 this.hasUpdatedBeforeRecipeLoaded = false;
393 removeDarkModeStyle();
394 disableDarkMode();
395 }
396
397 this.recipe.darkModeHandler(false, handlerConfig);
398 } else if (isDarkModeStyleInjected()) {
399 debug('Removing injected darkmode from recipe');
400 removeDarkModeStyle();
401 } else {
402 debug('Removing Dark Reader');
403
404 disableDarkMode();
405 this.universalDarkModeInjected = false;
406 }
407 }
408
409 // Remove dark reader if (universal) dark mode was just disabled
410 if (
411 this.universalDarkModeInjected &&
412 (!this.settings.app.darkMode ||
413 !this.settings.service.isDarkModeEnabled ||
414 !this.settings.app.universalDarkMode)
415 ) {
416 disableDarkMode();
417 this.universalDarkModeInjected = false;
418 }
419 }
420
421 updateAppSettings(_event, data) {
422 this.settings.app = Object.assign(this.settings.app, data);
423 }
424
425 updateServiceSettings(_event, data) {
426 this.settings.service = Object.assign(this.settings.service, data);
427 }
428
429 serviceIdEcho(event) {
430 debug('Received a service echo ping');
431 event.sender.send('service-id', this.settings.service.id);
432 }
433
434 async automaticLanguageDetection() {
435 window.addEventListener(
436 'keyup',
437 debounce(async e => {
438 const element = e.target;
439
440 if (!element) return;
441
442 let value = '';
443 if (element.isContentEditable) {
444 value = element.textContent;
445 } else if (element.value) {
446 value = element.value;
447 }
448
449 // Force a minimum length to get better detection results
450 if (value.length < 25) return;
451
452 debug('Detecting language for', value);
453 const locale = await ipcRenderer.invoke('detect-language', {
454 sample: value,
455 });
456 if (!locale) {
457 return;
458 }
459
460 const spellcheckerLocale =
461 getSpellcheckerLocaleByFuzzyIdentifier(locale);
462 debug(
463 'Language detected reliably, setting spellchecker language to',
464 spellcheckerLocale,
465 );
466 if (spellcheckerLocale) {
467 switchDict(spellcheckerLocale, this.settings.service.id);
468 }
469 }, 225),
470 );
471 }
472}
473
474/* eslint-disable no-new */
475new RecipeController();