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 { setApplicationMenu } from './menu-view/application-menu';
17 import { startup } from './startup';
18 import { certificateVerifyProc } from './tls';
19 import type { PassElectronContext } from './types';
20 import { SourceType, updateElectronApp } from './update';
21 import { isMac, isProdEnv, isWindows } from './utils/platform';
23 const ctx: PassElectronContext = { window: null, quitting: false };
25 const DOMAIN = getSecondLevelDomain(new URL(config.API_URL).hostname);
27 const createSession = () => {
28 const partitionKey = ENV !== 'production' ? 'app-dev' : 'app';
29 const secureSession = session.fromPartition(`persist:${partitionKey}`, { cache: false });
31 const filter = { urls: [`${getAppUrlFromApiUrl(config.API_URL, APPS.PROTONPASS)}*`] };
33 secureSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
36 // Always use system DNS settings
37 app.configureHostResolver({
38 enableAdditionalDnsQueryTypes: false,
39 enableBuiltInResolver: true,
44 // Use certificate pinning
45 if (config.SSO_URL.endsWith('proton.me')) secureSession.setCertificateVerifyProc(certificateVerifyProc);
47 secureSession.webRequest.onHeadersReceived({ urls: [`https://*.${DOMAIN}/*`] }, (details, callback) => {
48 const { responseHeaders = {}, frame } = details;
49 const appRequest = frame?.url?.startsWith('file://') ?? false;
51 /** If the request is made from a `file://` url: migrate ALL `SameSite` directives
52 * to `None` and allow cross-origin requests for the API. If not then only upgrade
53 * EMPTY `SameSite` cookie directives to `None` to preserve `Session-ID` cookies */
55 migrateSameSiteCookies(responseHeaders);
56 responseHeaders['access-control-allow-origin'] = ['file://'];
57 responseHeaders['access-control-allow-credentials'] = ['true'];
58 responseHeaders['access-control-allow-headers'] = Object.keys(details.responseHeaders || {});
59 } else upgradeSameSiteCookies(responseHeaders);
61 callback({ cancel: false, responseHeaders });
65 const clientId = ((): string => {
66 const config = APPS_CONFIGURATION[APPS.PROTONPASS];
68 switch (process.platform) {
70 return config.windowsClientID || config.clientID;
72 return config.macosClientID || config.clientID;
74 return config.linuxClientID || config.clientID;
76 return config.clientID;
80 secureSession.webRequest.onBeforeSendHeaders(({ requestHeaders }, callback) =>
84 ...getAppVersionHeaders(clientId, config.APP_VERSION),
89 // Intercept SSO login redirect to the Pass web app
90 secureSession.webRequest.onBeforeRequest(filter, async (details, callback) => {
91 if (!ctx.window) return;
93 const url = new URL(details.url);
94 if (url.pathname !== '/login') return callback({ cancel: false });
96 callback({ cancel: true });
97 const nextUrl = `${MAIN_WINDOW_WEBPACK_ENTRY}#/login${url.hash}`;
98 await ctx.window.loadURL(nextUrl);
101 return secureSession;
104 const createWindow = async (session: Session): Promise<BrowserWindow> => {
105 if (ctx.window) return ctx.window;
107 ctx.window = new BrowserWindow({
112 autoHideMenuBar: true,
116 contextIsolation: true,
117 nodeIntegration: false,
118 disableBlinkFeatures: 'Auxclick',
119 devTools: Boolean(process.env.PASS_DEBUG) || !isProdEnv(),
120 preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
122 ...(isMac ? { titleBarStyle: 'hidden', frame: false } : { titleBarStyle: 'default' }),
123 trafficLightPosition: {
131 setApplicationMenu(ctx.window);
133 ctx.window.on('close', (e) => {
140 ctx.window.on('closed', () => (ctx.window = null));
142 await ctx.window.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
149 const createTrayIcon = (session: Session) => {
150 const trayIconName = (() => {
151 switch (process.platform) {
153 return 'trayTemplate.png';
161 const trayIconPath = join(app.isPackaged ? process.resourcesPath : app.getAppPath(), 'assets', trayIconName);
162 const trayIcon = nativeImage.createFromPath(trayIconPath);
163 const tray = new Tray(trayIcon);
164 tray.setToolTip('Proton Pass');
166 const onOpenPassHandler = async () => {
167 const window = await createWindow(session);
171 const contextMenu = Menu.buildFromTemplate([
172 { label: 'Open Proton Pass', click: onOpenPassHandler },
173 { type: 'separator' },
174 { label: 'Quit', role: 'quit', click: app.quit },
177 tray.setContextMenu(contextMenu);
179 if (process.platform === 'win32') tray.on('double-click', onOpenPassHandler);
182 const onActivate = (secureSession: Session) => () => {
183 if (ctx.window) return ctx.window.show();
184 if (BrowserWindow.getAllWindows().length === 0) return createWindow(secureSession);
187 if (!app.requestSingleInstanceLock()) app.quit();
191 // This method will be called when Electron has finished
192 // initialization and is ready to create browser windows.
193 // Some APIs can only be used after this event occurs.
194 app.addListener('ready', async () => {
195 const secureSession = createSession();
196 const handleActivate = onActivate(secureSession);
198 // Match title bar with the saved (or default) theme
199 nativeTheme.themeSource = getTheme();
202 createTrayIcon(secureSession);
204 // On OS X it's common to re-create a window in the app when the
205 // dock icon is clicked and there are no other windows open.
206 app.addListener('activate', handleActivate);
208 // On Windows, launching Pass while it's already running shold focus
209 // or create the main window of the existing process
210 app.addListener('second-instance', handleActivate);
212 // Prevent hiding windows when explicitly quitting
213 app.addListener('before-quit', () => (ctx.quitting = true));
215 await createWindow(secureSession);
218 session: secureSession,
220 type: SourceType.StaticStorage,
221 baseUrl: `https://proton.me/download/PassDesktop/${process.platform}/${ARCH}`,
226 app.addListener('web-contents-created', (_, contents) => {
227 contents.addListener('will-attach-webview', (evt) => evt.preventDefault());
229 const allowedHosts: string[] = [
230 new URL(config.API_URL).host,
231 new URL(config.SSO_URL).host,
232 getAppUrlFromApiUrl(config.API_URL, APPS.PROTONPASS).host,
235 contents.addListener('will-navigate', (evt, href) => {
236 if (href.startsWith(MAIN_WINDOW_WEBPACK_ENTRY)) return;
238 const url = new URL(href);
240 // Prevent opening URLs outside of account
241 if (!allowedHosts.includes(url.host) || !['/authorize', '/login'].includes(url.pathname)) {
242 evt.preventDefault();
243 logger.warn(`[will-navigate] preventDefault: ${url.toString()}`);
247 // Open Create account externally
248 if (url.searchParams.has('t') && url.searchParams.get('t') === ForkType.SIGNUP) {
249 evt.preventDefault();
250 logger.warn(`[will-navigate] openExternal: ${url.toString()}`);
251 return shell.openExternal(href).catch(noop);
255 contents.setWindowOpenHandler(({ url: href }) => {
256 const url = new URL(href);
258 // Shell out to the system browser if http(s)
259 if (['http:', 'https:'].includes(url.protocol)) shell.openExternal(href).catch(noop);
261 // Always deny opening external links in-app
262 return { action: 'deny' };
266 // Quit when all windows are closed, except on macOS. There, it's common
267 // for applications and their menu bar to stay active until the user quits
268 // explicitly with Cmd + Q.
269 app.addListener('window-all-closed', () => !isMac && app.quit());
270 app.addListener('will-finish-launching', () => isWindows && app.setAppUserModelId(WINDOWS_APP_ID));