/* * 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, ipcMain, } from 'electron'; import { ensureDirSync, readFile, readFileSync } from 'fs-extra'; import { autorun } from 'mobx'; import { getSnapshot, onPatch } from 'mobx-state-tree'; import { join } from 'path'; import { ServiceToMainIpcMessage, unreadCount, WebSource, } from '@sophie/service-shared'; import { action, MainToRendererIpcMessage, RendererToMainIpcMessage, } from '@sophie/shared'; import { URL } from 'url'; import { init } from './compositionRoot.js'; import { installDevToolsExtensions, openDevToolsWhenReady, } from './devTools.js'; import { createMainStore } from './stores/MainStore.js'; const isDevelopment = import.meta.env.MODE === 'development'; // Alwayse enable sandboxing. app.enableSandbox(); // Use alternative directory when debugging to avoid clobbering the main installation. if (isDevelopment) { const devUserDataPath = `${app.getPath('userData')}-dev`; app.setPath('userData', devUserDataPath); ensureDirSync(devUserDataPath); } // Only allow a single instance at a time. const isSingleInstance = app.requestSingleInstanceLock(); if (!isSingleInstance) { app.quit(); process.exit(0); } // 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', ); // It doesn't seem to cause a race condition to start installing the extensions this early. if (isDevelopment) { app.whenReady().then(installDevToolsExtensions).catch((err) => { console.error('Failed to install devtools extensions', err); process.exit(1); }); } // 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 chromelessUserAgent = userAgent.replace(/ Chrome\/\S+/, ''); // Removing the electron version breaks redux devtools, so we only do this in production. if (!isDevelopment) { app.userAgentFallback = userAgent; } function getResourcePath(relativePath: string): string { return join(__dirname, relativePath); } const baseUrl = `file://${__dirname}`; function getResourceUrl(relativePath: string): string { return new URL(relativePath, baseUrl).toString(); } let serviceInjectRelativePath = '../../service-inject/dist/index.js'; let serviceInjectPath = getResourcePath(serviceInjectRelativePath); let serviceInject: WebSource = { code: readFileSync(serviceInjectPath, 'utf8'), url: getResourceUrl(serviceInjectRelativePath), }; let mainWindow: BrowserWindow | null = null; const store = createMainStore(); init(store).then((disposeCompositionRoot) => { app.on('will-quit', disposeCompositionRoot); }).catch((err) => { console.log('Failed to initialize application', err); }); const rendererBaseUrl = getResourceUrl('../renderer/'); function shouldCancelMainWindowRequest(url: string, method: string): boolean { if (method !== 'GET') { return true; } let normalizedUrl: string; try { normalizedUrl = new URL(url).toString(); } catch (_err) { return true; } if (isDevelopment) { if (normalizedUrl.startsWith('devtools:') || normalizedUrl.startsWith('chrome-extension:')) { return false; } if (import.meta.env.VITE_DEV_SERVER_URL !== undefined) { const isHttp = normalizedUrl.startsWith(import.meta.env.VITE_DEV_SERVER_URL); const isWs = normalizedUrl.startsWith(import.meta.env.VITE_DEV_SERVER_URL.replace(/^http:/, 'ws:')); return !isHttp && !isWs; } } return !normalizedUrl.startsWith(getResourceUrl(rendererBaseUrl)); } async function createWindow(): Promise { mainWindow = new BrowserWindow({ show: false, autoHideMenuBar: true, webPreferences: { sandbox: true, devTools: isDevelopment, preload: getResourcePath('../../preload/dist/index.cjs'), }, }); const { webContents } = mainWindow; webContents.userAgent = originalUserAgent; webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false); }); webContents.session.webRequest.onBeforeRequest(({ url, method }, callback) => { callback({ cancel: shouldCancelMainWindowRequest(url, method), }) }); const pageUrl = (isDevelopment && import.meta.env.VITE_DEV_SERVER_URL !== undefined) ? import.meta.env.VITE_DEV_SERVER_URL : getResourceUrl('../renderer/dist/index.html'); webContents.on('will-navigate', (event, url) => { if (url !== pageUrl) { event.preventDefault(); } }); webContents.setWindowOpenHandler(() => ({ action: 'deny' })); if (isDevelopment) { openDevToolsWhenReady(mainWindow); } mainWindow.on('ready-to-show', () => { mainWindow?.show(); }); const browserView = new BrowserView({ webPreferences: { sandbox: true, nodeIntegrationInSubFrames: true, preload: getResourcePath('../../service-preload/dist/index.cjs'), partition: 'persist:service', }, }); browserView.webContents.userAgent = userAgent; autorun(() => { browserView.setBounds(store.browserViewBounds); }); mainWindow.setBrowserView(browserView); ipcMain.handle(RendererToMainIpcMessage.GetSharedStoreSnapshot, (event) => { if (event.sender.id !== webContents.id) { console.warn( 'Unexpected', RendererToMainIpcMessage.GetSharedStoreSnapshot, 'from webContents', event.sender.id, ); return null; } return getSnapshot(store.shared); }); ipcMain.on(RendererToMainIpcMessage.DispatchAction, (event, rawAction) => { if (event.sender.id !== webContents.id) { console.warn( 'Unexpected', RendererToMainIpcMessage.DispatchAction, 'from webContents', event.sender.id, ); return; } try { const actionToDispatch = action.parse(rawAction); switch (actionToDispatch.action) { case 'set-browser-view-bounds': store.setBrowserViewBounds(actionToDispatch.browserViewBounds); break; case 'set-theme-source': store.config.setThemeSource(actionToDispatch.themeSource) break; case 'reload-all-services': readFile(serviceInjectPath, 'utf8').then((data) => { serviceInject.code = data; }).catch((err) => { console.error('Error while reloading', serviceInjectPath, err); }).then(() => { browserView.webContents.reload(); }); break; } } catch (err) { console.error('Error while dispatching renderer action', rawAction, err); } }); onPatch(store.shared, (patch) => { webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); }); ipcMain.handle(ServiceToMainIpcMessage.ApiExposedInMainWorld, (event) => { return event.sender.id == browserView.webContents.id ? serviceInject : null; }); browserView.webContents.on('ipc-message', (_event, channel, ...args) => { try { switch (channel) { case ServiceToMainIpcMessage.ApiExposedInMainWorld: // Asynchronous message with reply must be handled in `ipcMain.handle`, // otherwise electron emits a no handler registered warning. 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); } }); browserView.webContents.session.setPermissionRequestHandler( (_webContents, _permission, callback) => { callback(false); } ); browserView.webContents.session.webRequest.onBeforeSendHeaders(({ url, requestHeaders }, callback) => { if (url.match(/^[^:]+:\/\/accounts\.google\.[^.\/]+\//)) { requestHeaders['User-Agent'] = chromelessUserAgent; } else { requestHeaders['User-Agent'] = userAgent; } callback({ requestHeaders }); }); browserView.webContents.loadURL('https://gitlab.com/say-hi-to-sophie/sophie').catch((err) => { console.error('Failed to load browser', err); }); return mainWindow.loadURL(pageUrl); } 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); });