/* * Copyright (C) 2021-2022 Kristóf Marussy * * This file is part of Sophie. * * Sophie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * SPDX-License-Identifier: AGPL-3.0-only */ import { app, BrowserView, BrowserWindow, IpcMainEvent, } from 'electron'; import { readFile, readFileSync } from 'fs'; import { autorun } from 'mobx'; import { getSnapshot, onPatch } from 'mobx-state-tree'; import { join } from 'path'; import { ServiceToMainIpcMessage, unreadCount, } from '@sophie/service-shared'; import { browserViewBounds, MainToRendererIpcMessage, paletteMode, RendererToMainIpcMessage, } from '@sophie/shared'; import { URL } from 'url'; import { installDevToolsExtensions, openDevToolsWhenReady, } from './devTools'; import { createRootStore } from './stores/RootStore'; const isDevelopment = import.meta.env.MODE === 'development'; // Use alternative directory when debugging to avoid clobbering the main installation. if (isDevelopment) { app.setPath('userData', `${app.getPath('userData')}-dev`); } // Only allow a single instance at a time. const isSingleInstance = app.requestSingleInstanceLock(); if (!isSingleInstance) { app.quit(); process.exit(0); } // Alwayse enable sandboxing. app.enableSandbox(); // Disable chromium's MPRIS integration, which is usually more annoying // (triggered by random sounds played by websites) than useful. app.commandLine.appendSwitch( 'disable-features', 'HardwareMediaKeyHandling,MediaSessionService', ); // Remove sophie and electron from the user-agent string to avoid detection. const originalUserAgent = app.userAgentFallback; const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, ''); const platformInUa = userAgent.match(/\((Win|Mac|X11; L)/); let platform = 'Unknown'; if (platformInUa !== null) { switch (platformInUa[1]) { case 'Win': platform = 'Windows'; break; case 'Mac': platform = 'macOS'; break; case 'X11; L': platform = 'Linux'; break; } } const chromiumVersion = process.versions.chrome.split('.')[0]; // Removing the electron version breaks redux devtools, so we only do this in production. if (!isDevelopment) { app.userAgentFallback = userAgent; } let serviceInjectRelativePath = '../../service-inject/dist/index.cjs'; let serviceInjectPath = join(__dirname, serviceInjectRelativePath); let serviceInject: string = readFileSync(serviceInjectPath, 'utf8'); if (isDevelopment) { installDevToolsExtensions(app); } let mainWindow: BrowserWindow | null = null; const store = createRootStore(); function createWindow(): Promise { mainWindow = new BrowserWindow({ show: false, webPreferences: { sandbox: true, preload: join(__dirname, '../../preload/dist/index.cjs'), }, }); if (isDevelopment) { // openDevToolsWhenReady(mainWindow); } mainWindow.on('ready-to-show', () => { mainWindow?.show(); }); const { webContents } = mainWindow; webContents.userAgent = originalUserAgent; const browserView = new BrowserView({ webPreferences: { sandbox: true, nodeIntegrationInSubFrames: true, preload: join(__dirname, '../../service-preload/dist/index.cjs'), partition: 'persist:service', }, }); browserView.webContents.userAgent = userAgent; autorun(() => { browserView.setBounds(store.shared.browserViewBounds); }); mainWindow.setBrowserView(browserView); browserView.webContents.on( 'did-frame-navigate', (_event, _url, _statusCode, _statusText, isMainFrame, _processId, routingId) => { const { webContents: { mainFrame } } = browserView; const frame = isMainFrame ? mainFrame : mainFrame.framesInSubtree.find((f) => f.routingId === routingId); frame?.executeJavaScript(serviceInject).catch((err) => console.log(err)); } ); webContents.on('ipc-message', (_event, channel, ...args) => { try { switch (channel) { case RendererToMainIpcMessage.SharedStoreSnapshotRequest: webContents.send(MainToRendererIpcMessage.SharedStoreSnapshot, getSnapshot(store.shared)); break; case RendererToMainIpcMessage.SetBrowserViewBounds: store.setBrowserViewBounds(browserViewBounds.parse(args[0])); break; case RendererToMainIpcMessage.SetPaletteMode: store.setPaletteMode(paletteMode.parse(args[0])) break; case RendererToMainIpcMessage.ReloadAllServices: readFile(serviceInjectPath, 'utf8', (err, data) => { if (err === null) { serviceInject = data; } else { console.error('Error while reloading', serviceInjectPath, err); } browserView.webContents.reload(); }); break; default: console.error('Unknown IPC message:', channel, args); break; } } catch (err) { console.error('Error while processing IPC message:', channel, args, err); } }); onPatch(store.shared, (patch) => { webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); }); browserView.webContents.on('ipc-message', (_event, channel, ...args) => { try { switch (channel) { case ServiceToMainIpcMessage.ApiExposedInMainWorld: break; case ServiceToMainIpcMessage.SetUnreadCount: console.log('Unread count:', unreadCount.parse(args[0])); break; default: console.error('Unknown IPC message:', channel, args); break; } } catch (err) { console.error('Error while processing IPC message:', channel, args, err); } }); // Inject CSS to simulate `browserView.setBackgroundColor`. // This is injected before the page loads, so the styles from the website will overwrite it. browserView.webContents.on('did-navigate', () => { browserView.webContents.insertCSS( 'html { background-color: #fff; }', { cssOrigin: 'author', }, ); }); browserView.webContents.session.webRequest.onBeforeSendHeaders(({ url, requestHeaders }, callback) => { if (url.match(/accounts\.google/)) { requestHeaders['User-Agent'] = userAgent.replace(/ Chrome\/\S+/, ''); } else { requestHeaders['User-Agent'] = userAgent; } requestHeaders['User-Agent'] = userAgent; requestHeaders['Sec-CH-UA'] = `" Not A;Brand";v="99", "Chromium";v="${chromiumVersion}"`; requestHeaders['Sec-CH-UA-Mobile'] = '?0'; requestHeaders['Sec-CH-UA-Platform'] = platform; callback({ requestHeaders }); }); const pageUrl = (isDevelopment && import.meta.env.VITE_DEV_SERVER_URL !== undefined) ? import.meta.env.VITE_DEV_SERVER_URL : new URL('../renderer/dist/index.html', `file://${__dirname}`).toString(); return Promise.all([ mainWindow.loadURL(pageUrl), browserView.webContents.loadURL('https://gmail.com').then(() => browserView.webContents.openDevTools()), ]); } app.on('second-instance', () => { if (mainWindow !== null) { if (!mainWindow.isVisible()) { mainWindow.show(); } if (mainWindow.isMinimized()) { mainWindow.restore(); } mainWindow.focus(); } }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.whenReady().then(createWindow).catch((err) => { console.error('Failed to create window', err); process.exit(1); });