/* * Copyright (C) 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 type { BrowserViewBounds } from '@sophie/shared'; import { BrowserView } from 'electron'; import type Service from '../../../stores/Service'; import { getLogger } from '../../../utils/log'; import type Resources from '../../resources/Resources'; import type { ServiceView } from '../types'; import ElectronPartition from './ElectronPartition'; import type ElectronViewFactory from './ElectronViewFactory'; const log = getLogger('ElectronServiceView'); export default class ElectronServiceView implements ServiceView { readonly id: string; readonly partitionId: string; readonly browserView: BrowserView; constructor( service: Service, resources: Resources, partition: ElectronPartition, private readonly parent: ElectronViewFactory, ) { this.id = service.id; this.partitionId = partition.id; this.browserView = new BrowserView({ webPreferences: { sandbox: true, nodeIntegrationInSubFrames: true, preload: resources.getPath('service-preload', 'index.cjs'), session: partition.session, }, }); const { webContents } = this.browserView; function setLocation(url: string) { service.setLocation({ url, canGoBack: webContents.canGoBack(), canGoForward: webContents.canGoForward(), }); } webContents.on('did-navigate', (_event, url) => { setLocation(url); }); webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => { if (isMainFrame) { setLocation(url); } }); webContents.on( 'did-fail-load', (_event, errorCode, errorDesc, url, isMainFrame) => { if (errorCode === -3) { // Do not signal error on ABORTED, since that corresponds to an action requested by the user. // Other events (`did-start-loading` or `did-stop-loading`) will cause service state changes // that are appropriate for the user action. log.debug('Loading', url, 'in service', this.id, 'aborted by user'); return; } if (isMainFrame) { setLocation(url); service.setFailed(errorCode, errorDesc); log.warn( 'Failed to load', url, 'in service', this.id, errorCode, errorDesc, ); } }, ); /** * We use the `'certificate-error'` event instead of `session.setCertificateVerifyProc` * because: * * 1. `'certificate-error'` is bound to the `webContents`, so we can display the certificate * in the place of the correct service. Note that chromium still manages certificate trust * per session, so we can't have different trusted certificates for each service of a * profile. * 2. The results of `'certificate-error'` are _not_ cached, so we can initially reject * the certificate but we can still accept it once the user trusts it temporarily. */ webContents.on( 'certificate-error', (event, url, error, certificate, callback, isMainFrame) => { if (service.isCertificateTemporarilyTrusted(certificate)) { event.preventDefault(); callback(true); return; } if (isMainFrame) { setLocation(url); service.setCertificateError(error, certificate); } callback(false); }, ); webContents.on('page-title-updated', (_event, title) => { service.setTitle(title); }); webContents.on('did-start-loading', () => { service.startLoading(); }); webContents.on('did-stop-loading', () => { service.finishLoading(); }); webContents.on('render-process-gone', (_event, details) => { const { reason, exitCode } = details; service.setCrashed(reason, exitCode); }); webContents.setWindowOpenHandler(({ url }) => { // TODO Add filtering (allowlist) by URL. // TODO Handle `new-window` disposition where the service wants an object returned by // `window.open`. // TODO Handle downloads with `save-to-disk` disposition. // TODO Handle POST bodies where the window must be allowed to open or the data is lost. service.addBlockedPopup(url); return { action: 'deny' }; }); } get webContentsId(): number { return this.browserView.webContents.id; } loadURL(url: string): void { this.browserView.webContents.loadURL(url).catch((error) => { log.warn('Error while loading', url, 'in service', this.id, error); }); } goBack(): void { this.browserView.webContents.goBack(); } goForward(): void { this.browserView.webContents.goForward(); } reload(ignoreCache: boolean): void { if (ignoreCache) { this.browserView.webContents.reloadIgnoringCache(); } else { this.browserView.webContents.reload(); } } stop(): void { this.browserView.webContents.stop(); } toggleDeveloperTools(): void { this.browserView.webContents.toggleDevTools(); } setBounds(bounds: BrowserViewBounds): void { this.browserView.setBounds(bounds); } dispose(): void { this.parent.unregisterServiceView(this.webContentsId); setImmediate(() => { // Undocumented electron API, see e.g., https://github.com/electron/electron/issues/29626 ( this.browserView.webContents as unknown as { destroy(): void } ).destroy(); }); } }