aboutsummaryrefslogtreecommitdiffstats
path: root/src/index.ts
diff options
context:
space:
mode:
authorLibravatar Markus Hatvan <markus_hatvan@aon.at>2021-10-10 10:42:20 +0200
committerLibravatar GitHub <noreply@github.com>2021-10-10 10:42:20 +0200
commita673c2b577a31437c91a0b498e69b0753d62aa58 (patch)
tree50d52df3ff9d78828ca06a24ef438e31b062ca56 /src/index.ts
parentremove unused 'conventional-changelog' npm package. Change the url for the ch... (diff)
downloadferdium-app-a673c2b577a31437c91a0b498e69b0753d62aa58.tar.gz
ferdium-app-a673c2b577a31437c91a0b498e69b0753d62aa58.tar.zst
ferdium-app-a673c2b577a31437c91a0b498e69b0753d62aa58.zip
chore: convert index file to TS (#2049)
Diffstat (limited to 'src/index.ts')
-rw-r--r--src/index.ts693
1 files changed, 693 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 000000000..7beaa86f6
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,693 @@
1/* eslint-disable import/first */
2
3import {
4 app,
5 BrowserWindow,
6 globalShortcut,
7 ipcMain,
8 session,
9 dialog,
10} from 'electron';
11
12import { emptyDirSync, ensureFileSync } from 'fs-extra';
13import { join } from 'path';
14import windowStateKeeper from 'electron-window-state';
15import ms from 'ms';
16import { initializeRemote } from './electron-util';
17import { enforceMacOSAppLocation } from './enforce-macos-app-location';
18
19initializeRemote();
20
21import { DEFAULT_APP_SETTINGS, DEFAULT_WINDOW_OPTIONS } from './config';
22
23import { isMac, isWindows, isLinux, altKey } from './environment';
24import {
25 isDevMode,
26 aboutAppDetails,
27 userDataRecipesPath,
28 userDataPath,
29} from './environment-remote';
30import { ifUndefinedBoolean } from './jsUtils';
31
32import { mainIpcHandler as basicAuthHandler } from './features/basicAuth';
33import ipcApi from './electron/ipc-api';
34import Tray from './lib/Tray';
35import DBus from './lib/DBus';
36import Settings from './electron/Settings';
37import handleDeepLink from './electron/deepLinking';
38import { isPositionValid } from './electron/windowUtils';
39// @ts-expect-error Cannot find module './package.json' or its corresponding type declarations.
40import { appId } from './package.json';
41import './electron/exception';
42
43import { asarPath } from './helpers/asar-helpers';
44import { openExternalUrl } from './helpers/url-helpers';
45import userAgent from './helpers/userAgent-helpers';
46
47const debug = require('debug')('Ferdi:App');
48
49// Globally set useragent to fix user agent override in service workers
50debug('Set userAgent to ', userAgent());
51app.userAgentFallback = userAgent();
52
53// Keep a global reference of the window object, if you don't, the window will
54// be closed automatically when the JavaScript object is garbage collected.
55let mainWindow: BrowserWindow | undefined;
56let willQuitApp = false;
57
58// Register methods to be called once the window has been loaded.
59let onDidLoadFns: any[] | null = [];
60
61function onDidLoad(fn: {
62 (window: BrowserWindow): void;
63 (window: BrowserWindow): void;
64 (window: BrowserWindow): void;
65 (arg0: BrowserWindow): void;
66}) {
67 if (onDidLoadFns) {
68 onDidLoadFns.push(fn);
69 } else if (mainWindow) {
70 fn(mainWindow);
71 }
72}
73
74// Ensure that the recipe directory exists
75emptyDirSync(userDataRecipesPath('temp'));
76ensureFileSync(userDataPath('window-state.json'));
77
78// Set App ID for Windows
79if (isWindows) {
80 app.setAppUserModelId(appId);
81}
82
83// Initialize Settings
84const settings = new Settings('app', DEFAULT_APP_SETTINGS);
85const proxySettings = new Settings('proxy');
86
87const retrieveSettingValue = (key: string, defaultValue: boolean) =>
88 ifUndefinedBoolean(settings.get(key), defaultValue);
89
90if (retrieveSettingValue('sentry', DEFAULT_APP_SETTINGS.sentry)) {
91 // eslint-disable-next-line global-require
92 require('./sentry');
93}
94
95const liftSingleInstanceLock = retrieveSettingValue(
96 'liftSingleInstanceLock',
97 false,
98);
99
100// Force single window
101const gotTheLock = liftSingleInstanceLock
102 ? true
103 : app.requestSingleInstanceLock();
104if (!gotTheLock) {
105 app.quit();
106} else {
107 app.on('second-instance', (_event, argv) => {
108 // Someone tried to run a second instance, we should focus our window.
109 if (mainWindow) {
110 if (!mainWindow.isVisible()) {
111 mainWindow.show();
112 }
113 if (mainWindow.isMinimized()) {
114 mainWindow.restore();
115 }
116 mainWindow.focus();
117
118 if (isWindows) {
119 onDidLoad((window: BrowserWindow) => {
120 // Keep only command line / deep linked arguments
121 const url = argv.slice(1);
122 if (url) {
123 handleDeepLink(window, url.toString());
124 }
125
126 if (argv.includes('--reset-window')) {
127 // Needs to be delayed to not interfere with mainWindow.restore();
128 setTimeout(() => {
129 debug('Resetting windows via Task');
130 window.setPosition(
131 DEFAULT_WINDOW_OPTIONS.x + 100,
132 DEFAULT_WINDOW_OPTIONS.y + 100,
133 );
134 window.setSize(
135 DEFAULT_WINDOW_OPTIONS.width,
136 DEFAULT_WINDOW_OPTIONS.height,
137 );
138 }, 1);
139 } else if (argv.includes('--quit')) {
140 // Needs to be delayed to not interfere with mainWindow.restore();
141 setTimeout(() => {
142 debug('Quitting Ferdi via Task');
143 app.quit();
144 }, 1);
145 }
146 });
147 }
148 }
149 });
150}
151
152// Fix Unity indicator issue
153// https://github.com/electron/electron/issues/9046
154if (
155 isLinux &&
156 process.env.XDG_CURRENT_DESKTOP &&
157 ['Pantheon', 'Unity:Unity7'].includes(process.env.XDG_CURRENT_DESKTOP)
158) {
159 process.env.XDG_CURRENT_DESKTOP = 'Unity';
160}
161
162// Disable GPU acceleration
163if (!retrieveSettingValue('enableGPUAcceleration', false)) {
164 debug('Disable GPU Acceleration');
165 app.disableHardwareAcceleration();
166}
167
168app.setAboutPanelOptions({
169 applicationVersion: aboutAppDetails(),
170 version: '',
171});
172
173const createWindow = () => {
174 // Remember window size
175 const mainWindowState = windowStateKeeper({
176 defaultWidth: DEFAULT_WINDOW_OPTIONS.width,
177 defaultHeight: DEFAULT_WINDOW_OPTIONS.height,
178 maximize: true, // Automatically maximizes the window, if it was last closed maximized
179 fullScreen: true, // Automatically restores the window to full screen, if it was last closed full screen
180 });
181
182 let posX = mainWindowState.x || DEFAULT_WINDOW_OPTIONS.x;
183 let posY = mainWindowState.y || DEFAULT_WINDOW_OPTIONS.y;
184
185 if (!isPositionValid({ x: posX, y: posY })) {
186 debug('Window is out of screen bounds, resetting window');
187 posX = DEFAULT_WINDOW_OPTIONS.x;
188 posY = DEFAULT_WINDOW_OPTIONS.y;
189 }
190
191 // Create the browser window.
192 const backgroundColor = retrieveSettingValue('darkMode', false)
193 ? '#1E1E1E'
194 : settings.get('accentColor');
195
196 mainWindow = new BrowserWindow({
197 x: posX,
198 y: posY,
199 width: mainWindowState.width,
200 height: mainWindowState.height,
201 minWidth: 600,
202 minHeight: 500,
203 show: false,
204 titleBarStyle: isMac ? 'hidden' : 'default',
205 frame: isLinux,
206 backgroundColor,
207 webPreferences: {
208 spellcheck: retrieveSettingValue(
209 'enableSpellchecking',
210 DEFAULT_APP_SETTINGS.enableSpellchecking,
211 ),
212 nodeIntegration: true,
213 contextIsolation: false,
214 webviewTag: true,
215 preload: join(__dirname, 'sentry.js'),
216 // @ts-expect-error Object literal may only specify known properties, and 'enableRemoteModule' does not exist in type 'WebPreferences'.
217 enableRemoteModule: true,
218 },
219 });
220
221 app.on('web-contents-created', (_e, contents) => {
222 if (contents.getType() === 'webview') {
223 contents.on('new-window', event => {
224 event.preventDefault();
225 });
226 }
227 });
228
229 mainWindow.webContents.on('did-finish-load', () => {
230 const fns = onDidLoadFns;
231 onDidLoadFns = null;
232
233 if (!fns) return;
234
235 for (const fn of fns) {
236 fn(mainWindow);
237 }
238 });
239
240 // Initialize System Tray
241 const trayIcon: Tray = new Tray();
242
243 // Initialize DBus interface
244 const dbus = new DBus(trayIcon);
245
246 // Initialize ipcApi
247 ipcApi({
248 mainWindow,
249 settings: {
250 app: settings,
251 proxy: proxySettings,
252 },
253 trayIcon,
254 });
255
256 // Connect to the DBus after ipcApi took care of the System Tray
257 dbus.start();
258
259 // Manage Window State
260 mainWindowState.manage(mainWindow);
261
262 // and load the index.html of the app.
263 mainWindow.loadURL(`file://${__dirname}/index.html`);
264
265 // Open the DevTools.
266 if (isDevMode || process.argv.includes('--devtools')) {
267 mainWindow.webContents.openDevTools();
268 }
269
270 // Windows deep linking handling on app launch
271 if (isWindows) {
272 onDidLoad((window: BrowserWindow) => {
273 const url = process.argv.slice(1);
274 if (url) {
275 handleDeepLink(window, url.toString());
276 }
277 });
278 }
279
280 // Emitted when the window is closed.
281 mainWindow.on('close', e => {
282 debug('Window: close window');
283 // Dereference the window object, usually you would store windows
284 // in an array if your app supports multi windows, this is the time
285 // when you should delete the corresponding element.
286 if (
287 !willQuitApp &&
288 retrieveSettingValue(
289 'runInBackground',
290 DEFAULT_APP_SETTINGS.runInBackground,
291 )
292 ) {
293 e.preventDefault();
294 if (isWindows) {
295 debug('Window: minimize');
296 mainWindow?.minimize();
297
298 if (
299 retrieveSettingValue(
300 'closeToSystemTray',
301 DEFAULT_APP_SETTINGS.closeToSystemTray,
302 )
303 ) {
304 debug('Skip taskbar: true');
305 mainWindow?.setSkipTaskbar(true);
306 }
307 } else if (isMac && mainWindow?.isFullScreen()) {
308 debug('Window: leaveFullScreen and hide');
309 mainWindow.once('show', () => mainWindow?.setFullScreen(true));
310 mainWindow.once('leave-full-screen', () => mainWindow?.hide());
311 mainWindow.setFullScreen(false);
312 } else {
313 debug('Window: hide');
314 mainWindow?.hide();
315 }
316 } else {
317 dbus.stop();
318 app.quit();
319 }
320 });
321
322 // For Windows we need to store a flag to properly restore the window
323 // if the window was maximized before minimizing it so system tray
324 mainWindow.on('minimize', () => {
325 // @ts-expect-error Property 'wasMaximized' does not exist on type 'App'.
326 app.wasMaximized = app.isMaximized;
327
328 if (
329 retrieveSettingValue(
330 'minimizeToSystemTray',
331 DEFAULT_APP_SETTINGS.minimizeToSystemTray,
332 )
333 ) {
334 debug('Skip taskbar: true');
335 mainWindow?.setSkipTaskbar(true);
336 trayIcon.show();
337 }
338 });
339
340 mainWindow.on('maximize', () => {
341 debug('Window: maximize');
342 // @ts-expect-error Property 'isMaximized' does not exist on type 'App'.
343 app.isMaximized = true;
344 });
345
346 mainWindow.on('unmaximize', () => {
347 debug('Window: unmaximize');
348 // @ts-expect-error Property 'isMaximized' does not exist on type 'App'.
349 app.isMaximized = false;
350 });
351
352 mainWindow.on('restore', () => {
353 debug('Window: restore');
354 mainWindow?.setSkipTaskbar(false);
355
356 // @ts-expect-error Property 'wasMaximized' does not exist on type 'App'.
357 if (app.wasMaximized) {
358 debug('Window: was maximized before, maximize window');
359 mainWindow?.maximize();
360 }
361
362 if (
363 !retrieveSettingValue(
364 'enableSystemTray',
365 DEFAULT_APP_SETTINGS.enableSystemTray,
366 )
367 ) {
368 debug('Tray: hiding tray icon');
369 trayIcon.hide();
370 }
371 });
372
373 if (isMac) {
374 // eslint-disable-next-line global-require
375 const { askFormacOSPermissions } = require('./electron/macOSPermissions');
376 setTimeout(() => askFormacOSPermissions(mainWindow), ms('30s'));
377 }
378
379 mainWindow.on('show', () => {
380 debug('Skip taskbar: true');
381 mainWindow?.setSkipTaskbar(false);
382 });
383
384 // @ts-expect-error Property 'isMaximized' does not exist on type 'App'.
385 app.isMaximized = mainWindow.isMaximized();
386
387 mainWindow.webContents.on('new-window', (e, url) => {
388 e.preventDefault();
389 openExternalUrl(url);
390 });
391
392 if (
393 retrieveSettingValue('startMinimized', DEFAULT_APP_SETTINGS.startMinimized)
394 ) {
395 mainWindow.hide();
396 } else {
397 mainWindow.show();
398 }
399
400 app.whenReady().then(() => {
401 if (
402 retrieveSettingValue(
403 'enableGlobalHideShortcut',
404 DEFAULT_APP_SETTINGS.enableGlobalHideShortcut,
405 )
406 ) {
407 // Toggle the window on 'Alt+X'
408 globalShortcut.register(`${altKey()}+X`, () => {
409 trayIcon.trayMenuTemplate[0].click();
410 });
411 }
412 });
413};
414
415// Allow passing command line parameters/switches to electron
416// https://electronjs.org/docs/api/chrome-command-line-switches
417// used for Kerberos support
418// Usage e.g. MACOS
419// $ Ferdi.app/Contents/MacOS/Ferdi --auth-server-whitelist *.mydomain.com --auth-negotiate-delegate-whitelist *.mydomain.com
420const argv = require('minimist')(process.argv.slice(1));
421
422if (argv['auth-server-whitelist']) {
423 app.commandLine.appendSwitch(
424 'auth-server-whitelist',
425 argv['auth-server-whitelist'],
426 );
427}
428if (argv['auth-negotiate-delegate-whitelist']) {
429 app.commandLine.appendSwitch(
430 'auth-negotiate-delegate-whitelist',
431 argv['auth-negotiate-delegate-whitelist'],
432 );
433}
434
435// Disable Chromium's poor MPRIS implementation
436// and apply workaround for https://github.com/electron/electron/pull/26432
437app.commandLine.appendSwitch(
438 'disable-features',
439 'HardwareMediaKeyHandling,MediaSessionService,CrossOriginOpenerPolicy',
440);
441
442// This method will be called when Electron has finished
443// initialization and is ready to create browser windows.
444// Some APIs can only be used after this event occurs.
445app.on('ready', () => {
446 // force app to live in /Applications
447 enforceMacOSAppLocation();
448
449 // Register App URL
450 const protocolClient = isDevMode ? 'ferdi-dev' : 'ferdi';
451 if (!app.isDefaultProtocolClient(protocolClient)) {
452 app.setAsDefaultProtocolClient(protocolClient);
453 }
454
455 if (isWindows) {
456 const extraArgs = isDevMode ? `${__dirname} ` : '';
457 const iconPath = asarPath(
458 join(
459 isDevMode ? `${__dirname}../src/` : __dirname,
460 'assets/images/taskbar/win32/display.ico',
461 ),
462 );
463 app.setUserTasks([
464 {
465 program: process.execPath,
466 arguments: `${extraArgs}--reset-window`,
467 iconPath,
468 iconIndex: 0,
469 title: 'Move Ferdi to Current Display',
470 description: 'Restore the position and size of Ferdi',
471 },
472 {
473 program: process.execPath,
474 arguments: `${extraArgs}--quit`,
475 iconPath,
476 iconIndex: 0,
477 title: 'Quit Ferdi',
478 description: '',
479 },
480 ]);
481 }
482
483 // eslint-disable-next-line global-require
484 require('electron-react-titlebar/main').initialize();
485
486 createWindow();
487});
488
489// This is the worst possible implementation as the webview.webContents based callback doesn't work 🖕
490// TODO: rewrite to handle multiple login calls
491const noop = () => null;
492let authCallback = noop;
493
494app.on('login', (event, _webContents, _request, authInfo, callback) => {
495 // @ts-expect-error Type '(username?: string | undefined, password?: string | undefined) => void' is not assignable to type '() => null'.
496 authCallback = callback;
497 debug('browser login event', authInfo);
498 event.preventDefault();
499
500 if (!authInfo.isProxy && authInfo.scheme === 'basic') {
501 debug('basic auth handler', authInfo);
502 basicAuthHandler(mainWindow, authInfo);
503 }
504});
505
506// TODO: evaluate if we need to store the authCallback for every service
507ipcMain.on('feature-basic-auth-credentials', (_e, { user, password }) => {
508 debug('Received basic auth credentials', user, '********');
509
510 // @ts-expect-error Expected 0 arguments, but got 2.
511 authCallback(user, password);
512 authCallback = noop;
513});
514
515ipcMain.on('open-browser-window', (_e, { url, serviceId }) => {
516 const serviceSession = session.fromPartition(`persist:service-${serviceId}`);
517 const child = new BrowserWindow({
518 parent: mainWindow,
519 webPreferences: {
520 session: serviceSession,
521 // TODO: Aren't these needed here?
522 // contextIsolation: false,
523 // enableRemoteModule: true,
524 },
525 });
526 child.show();
527 child.loadURL(url);
528 debug('Received open-browser-window', url);
529});
530
531ipcMain.on(
532 'modifyRequestHeaders',
533 (_e, { modifiedRequestHeaders, serviceId }) => {
534 debug(
535 `Received modifyRequestHeaders ${modifiedRequestHeaders} for serviceId ${serviceId}`,
536 );
537 for (const headerFilterSet of modifiedRequestHeaders) {
538 const { headers, requestFilters } = headerFilterSet;
539 session
540 .fromPartition(`persist:service-${serviceId}`)
541 .webRequest.onBeforeSendHeaders(requestFilters, (details, callback) => {
542 for (const key in headers) {
543 if (Object.prototype.hasOwnProperty.call(headers, key)) {
544 const value = headers[key];
545 details.requestHeaders[key] = value;
546 }
547 }
548 callback({ requestHeaders: details.requestHeaders });
549 });
550 }
551 },
552);
553
554ipcMain.on('knownCertificateHosts', (_e, { knownHosts, serviceId }) => {
555 debug(
556 `Received knownCertificateHosts ${knownHosts} for serviceId ${serviceId}`,
557 );
558 session
559 .fromPartition(`persist:service-${serviceId}`)
560 .setCertificateVerifyProc((request, callback) => {
561 // To know more about these callbacks: https://www.electronjs.org/docs/api/session#sessetcertificateverifyprocproc
562 const { hostname } = request;
563 if (
564 knownHosts.find((item: string | string[]) => item.includes(hostname))
565 .length > 0
566 ) {
567 callback(0);
568 } else {
569 callback(-2);
570 }
571 });
572});
573
574ipcMain.on('feature-basic-auth-cancel', () => {
575 debug('Cancel basic auth');
576
577 // @ts-expect-error Expected 0 arguments, but got 2.
578 authCallback(null);
579 authCallback = noop;
580});
581
582// Handle synchronous messages from service webviews.
583
584ipcMain.on('find-in-page', (e, text, options) => {
585 const { sender: webContents } = e;
586 if (webContents !== mainWindow?.webContents && typeof text === 'string') {
587 const sanitizedOptions = {};
588 for (const option of ['forward', 'findNext', 'matchCase']) {
589 if (option in options) {
590 sanitizedOptions[option] = !!options[option];
591 }
592 }
593 const requestId = webContents.findInPage(text, sanitizedOptions);
594 debug('Find in page', text, options, requestId);
595 e.returnValue = requestId;
596 } else {
597 e.returnValue = null;
598 }
599});
600
601ipcMain.on('stop-find-in-page', (e, action) => {
602 const { sender: webContents } = e;
603 if (webContents !== mainWindow?.webContents) {
604 const validActions = [
605 'clearSelection',
606 'keepSelection',
607 'activateSelection',
608 ];
609 if (validActions.includes(action)) {
610 webContents.stopFindInPage(action);
611 }
612 }
613 e.returnValue = null;
614});
615
616ipcMain.on('set-spellchecker-locales', (_e, { locale, serviceId }) => {
617 if (serviceId === undefined) {
618 return;
619 }
620
621 const serviceSession = session.fromPartition(`persist:service-${serviceId}`);
622 const [defaultLocale] = serviceSession.getSpellCheckerLanguages();
623 debug(`Spellchecker default locale is: ${defaultLocale}`);
624
625 const locales = [locale, defaultLocale, DEFAULT_APP_SETTINGS.fallbackLocale];
626 debug(`Setting spellchecker locales to: ${locales}`);
627 serviceSession.setSpellCheckerLanguages(locales);
628});
629
630// Quit when all windows are closed.
631app.on('window-all-closed', () => {
632 // On OS X it is common for applications and their menu bar
633 // to stay active until the user quits explicitly with Cmd + Q
634 if (
635 retrieveSettingValue(
636 'runInBackground',
637 DEFAULT_APP_SETTINGS.runInBackground,
638 )
639 ) {
640 debug('Window: all windows closed, quit app');
641 app.quit();
642 } else {
643 debug("Window: don't quit app");
644 }
645});
646
647app.on('before-quit', event => {
648 const yesButtonIndex = 0;
649 let selection = yesButtonIndex;
650 if (
651 retrieveSettingValue('confirmOnQuit', DEFAULT_APP_SETTINGS.confirmOnQuit)
652 ) {
653 selection = dialog.showMessageBoxSync(mainWindow!, {
654 type: 'question',
655 message: 'Quit',
656 detail: 'Do you really want to quit Ferdi?',
657 buttons: ['Yes', 'No'],
658 });
659 }
660 if (selection === yesButtonIndex) {
661 willQuitApp = true;
662 } else {
663 event.preventDefault();
664 }
665});
666
667app.on('activate', () => {
668 // On OS X it's common to re-create a window in the app when the
669 // dock icon is clicked and there are no other windows open.
670 if (mainWindow === null) {
671 createWindow();
672 } else {
673 mainWindow?.show();
674 }
675});
676
677app.on('web-contents-created', (_createdEvent, contents) => {
678 contents.on('new-window', (event, _url, _frameNme, disposition) => {
679 if (disposition === 'foreground-tab') event.preventDefault();
680 });
681});
682
683app.on('will-finish-launching', () => {
684 // Protocol handler for macOS
685 app.on('open-url', (event, url) => {
686 event.preventDefault();
687
688 onDidLoad((window: BrowserWindow) => {
689 debug('open-url event', url);
690 handleDeepLink(window, url);
691 });
692 });
693});