Update all non-major dependencies
[ProtonMail-WebClient.git] / applications / pass-desktop / src / main.ts
blobb9eda65105dbc5d326fa5ffb62896149c081ae06
1 import { BrowserWindow, Menu, type Session, Tray, app, nativeImage, nativeTheme, session, shell } from 'electron';
2 import logger from 'electron-log/main';
3 import { join } from 'path';
5 import { ForkType } from '@proton/shared/lib/authentication/fork/constants';
6 import { APPS, APPS_CONFIGURATION } from '@proton/shared/lib/constants';
7 import { getAppVersionHeaders } from '@proton/shared/lib/fetch/headers';
8 import { getAppUrlFromApiUrl, getSecondLevelDomain } from '@proton/shared/lib/helpers/url';
9 import noop from '@proton/utils/noop';
11 import * as config from './app/config';
12 import { WINDOWS_APP_ID } from './constants';
13 import { migrateSameSiteCookies, upgradeSameSiteCookies } from './lib/cookies';
14 import { ARCH } from './lib/env';
15 import { getTheme } from './lib/theming';
16 import { getWindowConfig, registerWindowManagementHandlers } from './lib/window-management';
17 import { setApplicationMenu } from './menu-view/application-menu';
18 import { startup } from './startup';
19 import { certificateVerifyProc } from './tls';
20 import type { PassElectronContext } from './types';
21 import { SourceType, updateElectronApp } from './update';
22 import { isMac, isProdEnv, isWindows } from './utils/platform';
24 const ctx: PassElectronContext = { window: null, quitting: false };
26 const DOMAIN = getSecondLevelDomain(new URL(config.API_URL).hostname);
28 const createSession = () => {
29     const partitionKey = ENV !== 'production' ? 'app-dev' : 'app';
30     const secureSession = session.fromPartition(`persist:${partitionKey}`, { cache: false });
32     const filter = { urls: [`${getAppUrlFromApiUrl(config.API_URL, APPS.PROTONPASS)}*`] };
34     secureSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
36     if (isProdEnv()) {
37         // Always use system DNS settings
38         app.configureHostResolver({
39             enableAdditionalDnsQueryTypes: false,
40             enableBuiltInResolver: true,
41             secureDnsMode: 'off',
42             secureDnsServers: [],
43         });
45         // Use certificate pinning
46         if (config.SSO_URL.endsWith('proton.me')) secureSession.setCertificateVerifyProc(certificateVerifyProc);
48         secureSession.webRequest.onHeadersReceived({ urls: [`https://*.${DOMAIN}/*`] }, (details, callback) => {
49             const { responseHeaders = {}, frame } = details;
50             const appRequest = frame?.url?.startsWith('file://') ?? false;
52             /** If the request is made from a `file://` url: migrate ALL `SameSite` directives
53              * to `None` and allow cross-origin requests for the API. If not then only upgrade
54              * EMPTY `SameSite` cookie directives to `None` to preserve `Session-ID` cookies */
55             if (appRequest) {
56                 migrateSameSiteCookies(responseHeaders);
57                 responseHeaders['access-control-allow-origin'] = ['file://'];
58                 responseHeaders['access-control-allow-credentials'] = ['true'];
59                 responseHeaders['access-control-allow-headers'] = Object.keys(details.responseHeaders || {});
60             } else upgradeSameSiteCookies(responseHeaders);
62             callback({ cancel: false, responseHeaders });
63         });
64     }
66     const clientId = ((): string => {
67         const config = APPS_CONFIGURATION[APPS.PROTONPASS];
69         switch (process.platform) {
70             case 'win32':
71                 return config.windowsClientID || config.clientID;
72             case 'darwin':
73                 return config.macosClientID || config.clientID;
74             case 'linux':
75                 return config.linuxClientID || config.clientID;
76             default:
77                 return config.clientID;
78         }
79     })();
81     secureSession.webRequest.onBeforeSendHeaders(({ requestHeaders }, callback) =>
82         callback({
83             requestHeaders: {
84                 ...requestHeaders,
85                 ...getAppVersionHeaders(clientId, config.APP_VERSION),
86             },
87         })
88     );
90     // Intercept SSO login redirect to the Pass web app
91     secureSession.webRequest.onBeforeRequest(filter, async (details, callback) => {
92         if (!ctx.window) return;
94         const url = new URL(details.url);
95         if (url.pathname !== '/login') return callback({ cancel: false });
97         callback({ cancel: true });
98         const nextUrl = `${MAIN_WINDOW_WEBPACK_ENTRY}#/login${url.hash}`;
99         await ctx.window.loadURL(nextUrl);
100     });
102     return secureSession;
105 const createWindow = async (session: Session): Promise<BrowserWindow> => {
106     if (ctx.window) return ctx.window;
108     const { x, y, minHeight, minWidth, height, width, maximized, zoomLevel } = getWindowConfig();
110     ctx.window = new BrowserWindow({
111         x,
112         y,
113         minHeight,
114         minWidth,
115         width,
116         height,
117         show: false,
118         opacity: 1,
119         autoHideMenuBar: true,
120         webPreferences: {
121             session: session,
122             sandbox: true,
123             contextIsolation: true,
124             nodeIntegration: false,
125             disableBlinkFeatures: 'Auxclick',
126             devTools: Boolean(process.env.PASS_DEBUG) || !isProdEnv(),
127             preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
128         },
129         ...(isMac ? { titleBarStyle: 'hidden', frame: false } : { titleBarStyle: 'default' }),
130         trafficLightPosition: {
131             x: 20,
132             y: 18,
133         },
134     });
136     if (zoomLevel) {
137         ctx.window.webContents.setZoomLevel(zoomLevel);
138     }
140     setApplicationMenu(ctx.window);
141     registerWindowManagementHandlers(ctx.window);
143     ctx.window.on('close', (e) => {
144         if (!ctx.quitting) {
145             e.preventDefault();
146             ctx.window?.hide();
147         }
148     });
150     ctx.window.on('closed', () => (ctx.window = null));
152     await ctx.window.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
154     ctx.window.show();
156     if (maximized) {
157         ctx.window.maximize();
158     }
160     return ctx.window;
163 const createTrayIcon = (session: Session) => {
164     const trayIconName = (() => {
165         switch (process.platform) {
166             case 'darwin':
167                 return 'trayTemplate.png';
168             case 'win32':
169                 return 'logo.ico';
170             default:
171                 return 'tray.png';
172         }
173     })();
175     const trayIconPath = join(app.isPackaged ? process.resourcesPath : app.getAppPath(), 'assets', trayIconName);
176     const trayIcon = nativeImage.createFromPath(trayIconPath);
177     const tray = new Tray(trayIcon);
178     tray.setToolTip('Proton Pass');
180     const onOpenPassHandler = async () => {
181         const window = await createWindow(session);
182         window.show();
183     };
185     const contextMenu = Menu.buildFromTemplate([
186         { label: 'Open Proton Pass', click: onOpenPassHandler },
187         { type: 'separator' },
188         { label: 'Quit', role: 'quit', click: app.quit },
189     ]);
191     tray.setContextMenu(contextMenu);
193     if (process.platform === 'win32') tray.on('double-click', onOpenPassHandler);
196 const onActivate = (secureSession: Session) => () => {
197     if (ctx.window) return ctx.window.show();
198     if (BrowserWindow.getAllWindows().length === 0) return createWindow(secureSession);
201 if (!app.requestSingleInstanceLock()) app.quit();
203 await startup(ctx);
205 // This method will be called when Electron has finished
206 // initialization and is ready to create browser windows.
207 // Some APIs can only be used after this event occurs.
208 app.addListener('ready', async () => {
209     const secureSession = createSession();
210     const handleActivate = onActivate(secureSession);
212     // Match title bar with the saved (or default) theme
213     nativeTheme.themeSource = getTheme();
215     // Create tray icon
216     createTrayIcon(secureSession);
218     // On OS X it's common to re-create a window in the app when the
219     // dock icon is clicked and there are no other windows open.
220     app.addListener('activate', handleActivate);
222     // On Windows, launching Pass while it's already running shold focus
223     // or create the main window of the existing process
224     app.addListener('second-instance', handleActivate);
226     // Prevent hiding windows when explicitly quitting
227     app.addListener('before-quit', () => (ctx.quitting = true));
229     await createWindow(secureSession);
231     updateElectronApp({
232         session: secureSession,
233         updateSource: {
234             type: SourceType.StaticStorage,
235             baseUrl: `https://proton.me/download/PassDesktop/${process.platform}/${ARCH}`,
236         },
237     });
240 app.addListener('web-contents-created', (_, contents) => {
241     contents.addListener('will-attach-webview', (evt) => evt.preventDefault());
243     const allowedHosts: string[] = [
244         new URL(config.API_URL).host,
245         new URL(config.SSO_URL).host,
246         getAppUrlFromApiUrl(config.API_URL, APPS.PROTONPASS).host,
247     ];
249     contents.addListener('will-navigate', (evt, href) => {
250         if (href.startsWith(MAIN_WINDOW_WEBPACK_ENTRY)) return;
252         const url = new URL(href);
254         // Prevent opening URLs outside of account
255         if (!allowedHosts.includes(url.host) || !['/authorize', '/login'].includes(url.pathname)) {
256             evt.preventDefault();
257             logger.warn(`[will-navigate] preventDefault: ${url.toString()}`);
258             return;
259         }
261         // Open Create account externally
262         if (url.searchParams.has('t') && url.searchParams.get('t') === ForkType.SIGNUP) {
263             evt.preventDefault();
264             logger.warn(`[will-navigate] openExternal: ${url.toString()}`);
265             return shell.openExternal(href).catch(noop);
266         }
267     });
269     contents.setWindowOpenHandler(({ url: href }) => {
270         const url = new URL(href);
272         // Shell out to the system browser if http(s)
273         if (['http:', 'https:', 'mailto:'].includes(url.protocol)) shell.openExternal(href).catch(noop);
275         // Always deny opening external links in-app
276         return { action: 'deny' };
277     });
280 // Quit when all windows are closed, except on macOS. There, it's common
281 // for applications and their menu bar to stay active until the user quits
282 // explicitly with Cmd + Q.
283 app.addListener('window-all-closed', () => !isMac && app.quit());
284 app.addListener('will-finish-launching', () => isWindows && app.setAppUserModelId(WINDOWS_APP_ID));