/* * 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 { UnreadCount } from '@sophie/service-shared'; import { type Certificate, type CertificateSnapshotIn, defineServiceModel, ServiceAction, type ServiceStateSnapshotIn, } from '@sophie/shared'; import { type Instance, getSnapshot, cast, flow } from 'mobx-state-tree'; import type { ServiceView } from '../infrastructure/electron/types'; import { getLogger } from '../utils/log'; import { getEnv } from './MainEnv'; import ServiceSettings from './ServiceSettings'; import type ServiceConfig from './config/ServiceConfig'; const log = getLogger('Service'); const Service = defineServiceModel(ServiceSettings) .volatile( (): { serviceView: ServiceView | undefined; } => ({ serviceView: undefined, }), ) .views((self) => ({ get config(): ServiceConfig { const { id, settings } = self; return { ...getSnapshot(settings), id }; }, get urlToLoad(): string { return self.currentUrl ?? self.settings.url; }, get shouldBeLoaded(): boolean { return !self.crashed; }, })) .views((self) => ({ get shouldBeVisible(): boolean { return self.shouldBeLoaded && !self.hasError; }, })) .actions((self) => ({ setServiceView(serviceView: ServiceView | undefined): void { self.serviceView = serviceView; }, setLocation({ url, canGoBack, canGoForward, }: { url: string; canGoBack: boolean; canGoForward: boolean; }): void { self.currentUrl = url; self.canGoBack = canGoBack; self.canGoForward = canGoForward; }, setTitle(title: string): void { self.title = title; }, setUnreadCount({ direct, indirect }: UnreadCount): void { if (direct !== undefined) { self.directMessageCount = direct; } if (indirect !== undefined) { self.indirectMessageCount = indirect; } }, goBack(): void { self.serviceView?.goBack(); }, goForward(): void { self.serviceView?.goForward(); }, stop(): void { self.serviceView?.stop(); }, openCurrentURLInExternalBrowser(): void { if (self.currentUrl === undefined) { log.error('Cannot open empty URL in external browser'); return; } getEnv(self).openURLInExternalBrowser(self.currentUrl); }, addBlockedPopup(url: string): void { const index = self.popups.indexOf(url); if (index >= 0) { // Move existing popup to the end of the array, // because later popups have precedence over earlier ones. self.popups.splice(index, 1); } self.popups.push(url); }, dismissPopup(url: string): boolean { const index = self.popups.indexOf(url); if (index < 0) { log.warn('Service', self.id, 'has no pending popup', url); return false; } self.popups.splice(index, 1); return true; }, dismissAllPopups(): void { self.popups.splice(0); }, toggleDeveloperTools(): void { self.serviceView?.toggleDeveloperTools(); }, downloadCertificate: flow(function* downloadCertificate( fingerprint: string, ) { const { state } = self; if (state.type !== 'certificateError') { log.warn( 'Tried to save certificate', fingerprint, 'when there is no certificate error', ); return; } let { certificate } = state; while ( certificate !== undefined && certificate.fingerprint !== fingerprint ) { certificate = certificate.issuerCert as Certificate; } if (certificate === undefined) { log.warn( 'Tried to save certificate', fingerprint, 'which is not part of the current certificate chain', ); return; } yield getEnv(self).saveTextFile('certificate.pem', certificate.data); }), })) .actions((self) => { function setState(state: ServiceStateSnapshotIn): void { self.state = cast(state); } return { startLoading(): void { setState({ type: 'loading' }); }, finishLoading(): void { if (self.loading) { // Do not overwrite any error state state if the service haven't been reloaded yet. setState({ type: 'loaded' }); } }, setFailed(errorCode: number, errorDesc: string): void { if (!self.hasError) { setState({ type: 'failed', errorCode, errorDesc, }); } }, setCertificateError( errorCode: string, certificate: CertificateSnapshotIn, ): void { if (!self.crashed && self.state.type !== 'certificateError') { setState({ type: 'certificateError', errorCode, certificate, }); } }, setCrashed(reason: string, exitCode: number): void { if (!self.crashed) { setState({ type: 'crashed', reason, exitCode, }); } }, reload(ignoreCache = false): void { if (self.serviceView === undefined) { setState({ type: 'initializing' }); } else { self.serviceView?.reload(ignoreCache); } }, go(url: string): void { if (self.serviceView === undefined) { self.currentUrl = url; setState({ type: 'initializing' }); } else { self.serviceView?.loadURL(url); } }, }; }) .actions((self) => ({ goHome(): void { self.go(self.settings.url); }, temporarilyTrustCurrentCertificate(fingerprint: string): void { if (self.state.type !== 'certificateError') { log.error('Tried to trust certificate without any certificate error'); return; } if (self.state.certificate.fingerprint !== fingerprint) { log.error( 'Tried to trust certificate', fingerprint, 'but the currently pending fingerprint is', self.state.certificate.fingerprint, ); return; } self.settings.profile.temporarilyTrustCertificate(self.state.certificate); self.state.trust = 'accepted'; self.reload(); }, followPopup(url: string): void { if (self.dismissPopup(url)) { self.go(url); } }, openPopupInExternalBrowser(url: string): void { if (self.dismissPopup(url)) { getEnv(self).openURLInExternalBrowser(url); } }, openAllPopupsInExternalBrowser(): void { const env = getEnv(self); self.popups.forEach((popup) => env.openURLInExternalBrowser(popup)); self.dismissAllPopups(); }, })) .actions((self) => ({ dispatch(action: ServiceAction): void { switch (action.action) { case 'back': self.goBack(); break; case 'forward': self.goForward(); break; case 'reload': self.reload(action.ignoreCache); break; case 'stop': self.stop(); break; case 'go-home': self.goHome(); break; case 'go': self.go(action.url); break; case 'temporarily-trust-current-certificate': self.temporarilyTrustCurrentCertificate(action.fingerprint); break; case 'open-current-url-in-external-browser': self.openCurrentURLInExternalBrowser(); break; case 'follow-popup': self.followPopup(action.url); break; case 'open-popup-in-external-browser': self.openPopupInExternalBrowser(action.url); break; case 'open-all-popups-in-external-browser': self.openAllPopupsInExternalBrowser(); break; case 'dismiss-popup': self.dismissPopup(action.url); break; case 'dismiss-all-popups': self.dismissAllPopups(); break; case 'download-certificate': self.downloadCertificate(action.fingerprint).catch((error) => { log.error('Error while saving certificate', error); }); break; default: log.error('Unknown action to dispatch', action); break; } }, })); /* eslint-disable-next-line @typescript-eslint/no-redeclare -- Intentionally naming the type the same as the store definition. */ interface Service extends Instance {} export default Service;