aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-02-08 21:40:40 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-02-14 12:13:22 +0100
commitcc214bb1afd37068c2bbc93f33990ca93f9a900f (patch)
tree7707415d5c4e42b2542d3f482060d4935c892fe1
parentfeat: Unread message badges (diff)
downloadsophie-cc214bb1afd37068c2bbc93f33990ca93f9a900f.tar.gz
sophie-cc214bb1afd37068c2bbc93f33990ca93f9a900f.tar.zst
sophie-cc214bb1afd37068c2bbc93f33990ca93f9a900f.zip
feat: Load and switch services
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/index.ts260
-rw-r--r--packages/main/src/infrastructure/config/ReadConfigResult.ts23
-rw-r--r--packages/main/src/infrastructure/electron/RendererBridge.ts103
-rw-r--r--packages/main/src/infrastructure/electron/UserAgents.ts46
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts169
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronPartition.ts59
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts121
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronViewFactory.ts119
-rw-r--r--packages/main/src/infrastructure/electron/types.ts67
-rw-r--r--packages/main/src/initReactions.ts24
-rw-r--r--packages/main/src/reactions/loadServices.ts157
-rw-r--r--packages/main/src/stores/MainStore.ts45
-rw-r--r--packages/main/src/stores/Service.ts17
-rw-r--r--packages/service-preload/src/index.ts2
14 files changed, 933 insertions, 279 deletions
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index f5f6be4..40b15f0 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -19,34 +19,13 @@
19 * SPDX-License-Identifier: AGPL-3.0-only 19 * SPDX-License-Identifier: AGPL-3.0-only
20 */ 20 */
21 21
22import { readFileSync } from 'node:fs';
23import { readFile } from 'node:fs/promises';
24import { arch } from 'node:os'; 22import { arch } from 'node:os';
25 23
26import { 24import { app } from 'electron';
27 ServiceToMainIpcMessage,
28 UnreadCount,
29 WebSource,
30} from '@sophie/service-shared';
31import {
32 Action,
33 MainToRendererIpcMessage,
34 RendererToMainIpcMessage,
35} from '@sophie/shared';
36import { app, BrowserView, BrowserWindow, ipcMain } from 'electron';
37import { ensureDirSync } from 'fs-extra'; 25import { ensureDirSync } from 'fs-extra';
38import { autorun } from 'mobx';
39import { getSnapshot, onAction, onPatch } from 'mobx-state-tree';
40import osName from 'os-name'; 26import osName from 'os-name';
41 27
42import { 28import { enableStacktraceSourceMaps } from './infrastructure/electron/impl/devTools';
43 enableStacktraceSourceMaps,
44 installDevToolsExtensions,
45 openDevToolsWhenReady,
46} from './infrastructure/electron/impl/devTools';
47import hardenSession from './infrastructure/electron/impl/hardenSession';
48import lockWebContentsToFile from './infrastructure/electron/impl/lockWebContentsToFile';
49import getDistResources from './infrastructure/resources/impl/getDistResources';
50import initReactions from './initReactions'; 29import initReactions from './initReactions';
51import { createMainStore } from './stores/MainStore'; 30import { createMainStore } from './stores/MainStore';
52import { getLogger } from './utils/log'; 31import { getLogger } from './utils/log';
@@ -81,15 +60,6 @@ app.commandLine.appendSwitch(
81 'HardwareMediaKeyHandling,MediaSessionService', 60 'HardwareMediaKeyHandling,MediaSessionService',
82); 61);
83 62
84// Remove sophie and electron from the user-agent string to avoid detection.
85const originalUserAgent = app.userAgentFallback;
86const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, '');
87const chromelessUserAgent = userAgent.replace(/ Chrome\/\S+/, '');
88// Removing the electron version breaks redux devtools, so we only do this in production.
89if (!isDevelopment) {
90 app.userAgentFallback = userAgent;
91}
92
93app.setAboutPanelOptions({ 63app.setAboutPanelOptions({
94 applicationVersion: [ 64 applicationVersion: [
95 `Version: ${app.getVersion()}`, 65 `Version: ${app.getVersion()}`,
@@ -107,215 +77,10 @@ app.setAboutPanelOptions({
107 version: '', 77 version: '',
108}); 78});
109 79
110const resources = getDistResources(isDevelopment);
111
112const serviceInjectPath = resources.getPath('service-inject', 'index.js');
113const serviceInject: WebSource = {
114 code: readFileSync(serviceInjectPath, 'utf8'),
115 url: resources.getFileURL('service-inject', 'index.js'),
116};
117
118let mainWindow: BrowserWindow | undefined;
119
120const store = createMainStore(); 80const store = createMainStore();
121 81
122initReactions(store)
123 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await.
124 .then((disposeCompositionRoot) => {
125 app.on('will-quit', disposeCompositionRoot);
126 })
127 .catch((error) => {
128 log.log('Failed to initialize application', error);
129 });
130
131async function createWindow(): Promise<unknown> {
132 mainWindow = new BrowserWindow({
133 show: false,
134 autoHideMenuBar: true,
135 webPreferences: {
136 sandbox: true,
137 devTools: isDevelopment,
138 preload: resources.getPath('preload', 'index.cjs'),
139 },
140 });
141
142 const { webContents } = mainWindow;
143
144 webContents.userAgent = originalUserAgent;
145
146 hardenSession(resources, isDevelopment, webContents.session);
147
148 if (isDevelopment) {
149 openDevToolsWhenReady(mainWindow);
150 }
151
152 mainWindow.on('ready-to-show', () => {
153 mainWindow?.show();
154 });
155
156 const browserView = new BrowserView({
157 webPreferences: {
158 sandbox: true,
159 nodeIntegrationInSubFrames: true,
160 preload: resources.getPath('service-preload', 'index.cjs'),
161 partition: 'persist:service',
162 },
163 });
164
165 browserView.webContents.userAgent = userAgent;
166
167 autorun(() => {
168 browserView.setBounds(store.browserViewBounds);
169 });
170 mainWindow.setBrowserView(browserView);
171
172 ipcMain.handle(RendererToMainIpcMessage.GetSharedStoreSnapshot, (event) => {
173 if (event.sender.id !== webContents.id) {
174 log.warn(
175 'Unexpected',
176 RendererToMainIpcMessage.GetSharedStoreSnapshot,
177 'from webContents',
178 event.sender.id,
179 );
180 throw new Error('Invalid IPC call');
181 }
182 return getSnapshot(store.shared);
183 });
184
185 async function reloadServiceInject() {
186 try {
187 serviceInject.code = await readFile(serviceInjectPath, 'utf8');
188 } catch (error) {
189 log.error('Error while reloading', serviceInjectPath, error);
190 }
191 browserView.webContents.reload();
192 }
193
194 ipcMain.on(RendererToMainIpcMessage.DispatchAction, (event, rawAction) => {
195 if (event.sender.id !== webContents.id) {
196 log.warn(
197 'Unexpected',
198 RendererToMainIpcMessage.DispatchAction,
199 'from webContents',
200 event.sender.id,
201 );
202 return;
203 }
204 try {
205 const actionToDispatch = Action.parse(rawAction);
206 switch (actionToDispatch.action) {
207 case 'set-selected-service-id':
208 store.settings.setSelectedServiceId(actionToDispatch.serviceId);
209 break;
210 case 'set-browser-view-bounds':
211 store.setBrowserViewBounds(actionToDispatch.browserViewBounds);
212 break;
213 case 'set-theme-source':
214 store.settings.setThemeSource(actionToDispatch.themeSource);
215 break;
216 case 'reload-all-services':
217 reloadServiceInject().catch((error) => {
218 log.error('Failed to reload browserView', error);
219 });
220 break;
221 default:
222 log.error('Unexpected action from UI renderer:', actionToDispatch);
223 break;
224 }
225 } catch (error) {
226 log.error('Error while dispatching renderer action', rawAction, error);
227 }
228 });
229
230 const batchedPatches: unknown[] = [];
231 onPatch(store.shared, (patch) => {
232 batchedPatches.push(patch);
233 });
234 onAction(
235 store,
236 () => {
237 if (batchedPatches.length > 0) {
238 webContents.send(
239 MainToRendererIpcMessage.SharedStorePatch,
240 batchedPatches,
241 );
242 batchedPatches.splice(0);
243 }
244 },
245 true,
246 );
247
248 ipcMain.handle(ServiceToMainIpcMessage.ApiExposedInMainWorld, (event) => {
249 if (event.sender.id !== browserView.webContents.id) {
250 log.warn(
251 'Unexpected',
252 ServiceToMainIpcMessage.ApiExposedInMainWorld,
253 'from webContents',
254 event.sender.id,
255 );
256 throw new Error('Invalid IPC call');
257 }
258 return serviceInject;
259 });
260
261 browserView.webContents.on('ipc-message', (_event, channel, ...args) => {
262 try {
263 switch (channel) {
264 case ServiceToMainIpcMessage.ApiExposedInMainWorld:
265 // Asynchronous message with reply must be handled in `ipcMain.handle`,
266 // otherwise electron emits a no handler registered warning.
267 break;
268 case ServiceToMainIpcMessage.SetUnreadCount:
269 log.log('Unread count:', UnreadCount.parse(args[0]));
270 break;
271 default:
272 log.error('Unknown IPC message:', channel, args);
273 break;
274 }
275 } catch (error) {
276 log.error('Error while processing IPC message:', channel, args, error);
277 }
278 });
279
280 browserView.webContents.session.setPermissionRequestHandler(
281 (_webContents, _permission, callback) => {
282 callback(false);
283 },
284 );
285
286 browserView.webContents.session.webRequest.onBeforeSendHeaders(
287 ({ url, requestHeaders }, callback) => {
288 const requestUserAgent = /^[^:]+:\/\/accounts\.google\.[^./]+\//.test(url)
289 ? chromelessUserAgent
290 : userAgent;
291 callback({
292 requestHeaders: {
293 ...requestHeaders,
294 'User-Agent': requestUserAgent,
295 },
296 });
297 },
298 );
299
300 browserView.webContents
301 .loadURL('https://gitlab.com/say-hi-to-sophie/sophie')
302 .catch((error) => {
303 log.error('Failed to load browser', error);
304 });
305
306 return lockWebContentsToFile(resources, 'index.html', webContents);
307}
308
309app.on('second-instance', () => { 82app.on('second-instance', () => {
310 if (mainWindow !== undefined) { 83 store.mainWindow?.bringToForeground();
311 if (!mainWindow.isVisible()) {
312 mainWindow.show();
313 }
314 if (mainWindow.isMinimized()) {
315 mainWindow.restore();
316 }
317 mainWindow.focus();
318 }
319}); 84});
320 85
321app.on('window-all-closed', () => { 86app.on('window-all-closed', () => {
@@ -324,20 +89,11 @@ app.on('window-all-closed', () => {
324 } 89 }
325}); 90});
326 91
327app 92initReactions(store, isDevelopment)
328 .whenReady() 93 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await.
329 .then(async () => { 94 .then((disposeCompositionRoot) => {
330 if (isDevelopment) { 95 app.on('will-quit', disposeCompositionRoot);
331 try {
332 await installDevToolsExtensions();
333 } catch (error) {
334 log.error('Failed to install devtools extensions', error);
335 }
336 }
337
338 return createWindow();
339 }) 96 })
340 .catch((error) => { 97 .catch((error) => {
341 log.error('Failed to create window', error); 98 log.log('Failed to initialize application', error);
342 process.exit(1);
343 }); 99 });
diff --git a/packages/main/src/infrastructure/config/ReadConfigResult.ts b/packages/main/src/infrastructure/config/ReadConfigResult.ts
deleted file mode 100644
index 3b3ee55..0000000
--- a/packages/main/src/infrastructure/config/ReadConfigResult.ts
+++ /dev/null
@@ -1,23 +0,0 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21type ReadConfigResult = { found: true; data: unknown } | { found: false };
22
23export default ReadConfigResult;
diff --git a/packages/main/src/infrastructure/electron/RendererBridge.ts b/packages/main/src/infrastructure/electron/RendererBridge.ts
new file mode 100644
index 0000000..8633b9d
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/RendererBridge.ts
@@ -0,0 +1,103 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import type { SharedStoreSnapshotOut } from '@sophie/shared';
22import {
23 addMiddleware,
24 getSnapshot,
25 IJsonPatch,
26 IMiddlewareEvent,
27 onPatch,
28} from 'mobx-state-tree';
29
30import type MainStore from '../../stores/MainStore';
31import Disposer from '../../utils/Disposer';
32import { getLogger } from '../../utils/log';
33
34const log = getLogger('RendererBridge');
35
36export type PatchListener = (patch: IJsonPatch[]) => void;
37
38export default class RendererBridge {
39 snapshot: SharedStoreSnapshotOut;
40
41 private readonly disposeOnPatch: Disposer;
42
43 private readonly disposeMiddleware: Disposer;
44
45 constructor(store: MainStore, listener: PatchListener) {
46 this.snapshot = getSnapshot(store.shared);
47
48 // The call for the currently pending action, if any.
49 let topLevelCall: IMiddlewareEvent | undefined;
50 // An array of accumulated patches if we're in an action, `undefined` otherwise.
51 let patches: IJsonPatch[] | undefined;
52
53 this.disposeOnPatch = onPatch(store.shared, (patch) => {
54 if (patches === undefined) {
55 // Update unprotected stores (outside an action) right away.
56 listener([patch]);
57 } else {
58 patches.push(patch);
59 }
60 });
61
62 this.disposeMiddleware = addMiddleware(store, (call, next, abort) => {
63 if (call.parentActionEvent !== undefined) {
64 // We're already in an action, there's no need to enter one.
65 next(call);
66 return;
67 }
68 if (patches !== undefined) {
69 log.error(
70 'Unexpected call',
71 call,
72 'during dispatching another call',
73 topLevelCall,
74 'with accumulated patches',
75 patches,
76 );
77 abort(undefined);
78 return;
79 }
80 // Make shure that the saved snapshot is consistent with the patches we're going to send.
81 this.snapshot = getSnapshot(store.shared);
82 topLevelCall = call;
83 patches = [];
84 try {
85 next(call);
86 } finally {
87 try {
88 if (patches.length > 0) {
89 listener(patches);
90 }
91 } finally {
92 topLevelCall = undefined;
93 patches = undefined;
94 }
95 }
96 });
97 }
98
99 dispose(): void {
100 this.disposeMiddleware();
101 this.disposeOnPatch();
102 }
103}
diff --git a/packages/main/src/infrastructure/electron/UserAgents.ts b/packages/main/src/infrastructure/electron/UserAgents.ts
new file mode 100644
index 0000000..af7f049
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/UserAgents.ts
@@ -0,0 +1,46 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21const CHROMELESS_USER_AGENT_REGEX = /^[^:]+:\/\/accounts\.google\.[^./]+\//;
22
23export default class UserAgents {
24 private readonly default: string;
25
26 private readonly chromeless: string;
27
28 constructor(readonly mainWindowUserAgent: string) {
29 this.default = mainWindowUserAgent.replaceAll(
30 /\s(sophie|Electron)\/\S+/g,
31 '',
32 );
33 this.chromeless = this.default.replace(/ Chrome\/\S+/, '');
34 }
35
36 serviceUserAgent(url?: string | undefined): string {
37 return url !== undefined && CHROMELESS_USER_AGENT_REGEX.test(url)
38 ? this.chromeless
39 : this.default;
40 }
41
42 fallbackUserAgent(devMode: boolean): string {
43 // Removing the electron version breaks redux devtools, so we only do this in production.
44 return devMode ? this.mainWindowUserAgent : this.default;
45 }
46}
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
new file mode 100644
index 0000000..cff7957
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
@@ -0,0 +1,169 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import {
22 Action,
23 MainToRendererIpcMessage,
24 RendererToMainIpcMessage,
25} from '@sophie/shared';
26import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
27import type { IJsonPatch } from 'mobx-state-tree';
28
29import type MainStore from '../../../stores/MainStore';
30import { getLogger } from '../../../utils/log';
31import RendererBridge from '../RendererBridge';
32import type { MainWindow, ServiceView } from '../types';
33
34import ElectronServiceView from './ElectronServiceView';
35import type ElectronViewFactory from './ElectronViewFactory';
36import { openDevToolsWhenReady } from './devTools';
37import lockWebContentsToFile from './lockWebContentsToFile';
38
39const log = getLogger('ElectronMainWindow');
40
41export default class ElectronMainWindow implements MainWindow {
42 private readonly browserWindow: BrowserWindow;
43
44 private readonly bridge: RendererBridge;
45
46 private readonly dispatchActionHandler = (
47 event: IpcMainEvent,
48 rawAction: unknown,
49 ): void => {
50 const { id } = event.sender;
51 if (id !== this.browserWindow.webContents.id) {
52 log.warn(
53 'Unexpected',
54 RendererToMainIpcMessage.DispatchAction,
55 'from webContents',
56 id,
57 );
58 return;
59 }
60 try {
61 const action = Action.parse(rawAction);
62 this.store.dispatch(action);
63 } catch (error) {
64 log.error('Error while dispatching renderer action', rawAction, error);
65 }
66 };
67
68 constructor(
69 private readonly store: MainStore,
70 private readonly parent: ElectronViewFactory,
71 ) {
72 this.browserWindow = new BrowserWindow({
73 show: false,
74 autoHideMenuBar: true,
75 darkTheme: store.shared.shouldUseDarkColors,
76 webPreferences: {
77 sandbox: true,
78 devTools: parent.devMode,
79 preload: parent.resources.getPath('preload', 'index.cjs'),
80 },
81 });
82
83 const { webContents } = this.browserWindow;
84
85 ipcMain.handle(RendererToMainIpcMessage.GetSharedStoreSnapshot, (event) => {
86 const { id } = event.sender;
87 if (id !== webContents.id) {
88 log.warn(
89 'Unexpected',
90 RendererToMainIpcMessage.GetSharedStoreSnapshot,
91 'from webContents',
92 id,
93 );
94 throw new Error('Invalid IPC call');
95 }
96 return this.bridge.snapshot;
97 });
98
99 this.bridge = new RendererBridge(store, (patch) => {
100 webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch);
101 });
102
103 ipcMain.on(
104 RendererToMainIpcMessage.DispatchAction,
105 this.dispatchActionHandler,
106 );
107
108 webContents.userAgent = parent.userAgents.mainWindowUserAgent;
109
110 this.browserWindow.on('ready-to-show', () => this.browserWindow.show());
111
112 this.browserWindow.on('close', () => this.dispose());
113
114 if (parent.devMode) {
115 openDevToolsWhenReady(this.browserWindow);
116 }
117 }
118
119 bringToForeground(): void {
120 if (!this.browserWindow.isVisible()) {
121 this.browserWindow.show();
122 }
123 if (this.browserWindow.isMinimized()) {
124 this.browserWindow.restore();
125 }
126 this.browserWindow.focus();
127 }
128
129 setServiceView(serviceView: ServiceView | undefined) {
130 if (serviceView === undefined) {
131 // eslint-disable-next-line unicorn/no-null -- Electron API requires passing `null`.
132 this.browserWindow.setBrowserView(null);
133 return;
134 }
135 if (serviceView instanceof ElectronServiceView) {
136 this.browserWindow.setBrowserView(serviceView.browserView);
137 serviceView.browserView.setBackgroundColor('#fff');
138 return;
139 }
140 throw new TypeError(
141 'Unexpected ServiceView with no underlying BrowserView',
142 );
143 }
144
145 dispose() {
146 this.bridge.dispose();
147 this.browserWindow.destroy();
148 ipcMain.removeHandler(RendererToMainIpcMessage.GetSharedStoreSnapshot);
149 ipcMain.removeListener(
150 RendererToMainIpcMessage.DispatchAction,
151 this.dispatchActionHandler,
152 );
153 }
154
155 loadInterface(): Promise<void> {
156 return lockWebContentsToFile(
157 this.parent.resources,
158 'index.html',
159 this.browserWindow.webContents,
160 );
161 }
162
163 sendSharedStorePatch(patch: IJsonPatch[]): void {
164 this.browserWindow.webContents.send(
165 MainToRendererIpcMessage.SharedStorePatch,
166 patch,
167 );
168 }
169}
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronPartition.ts b/packages/main/src/infrastructure/electron/impl/ElectronPartition.ts
new file mode 100644
index 0000000..e60ce21
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/impl/ElectronPartition.ts
@@ -0,0 +1,59 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { Session, session } from 'electron';
22
23import type Profile from '../../../stores/Profile';
24import type { Partition } from '../types';
25
26import type ElectronViewFactory from './ElectronViewFactory';
27
28export default class ElectronPartition implements Partition {
29 readonly id: string;
30
31 readonly session: Session;
32
33 constructor(profile: Profile, parent: ElectronViewFactory) {
34 this.id = profile.id;
35 this.session = session.fromPartition(`persist:${profile.id}`);
36 this.session.setPermissionRequestHandler(
37 (_webContents, permission, callback) => {
38 // TODO Handle screen sharing.
39 callback(permission === 'notifications');
40 },
41 );
42 this.session.setUserAgent(parent.userAgents.serviceUserAgent());
43 this.session.webRequest.onBeforeSendHeaders(
44 ({ url, requestHeaders }, callback) => {
45 callback({
46 requestHeaders: {
47 ...requestHeaders,
48 'User-Agent': parent.userAgents.serviceUserAgent(url),
49 },
50 });
51 },
52 );
53 }
54
55 // eslint-disable-next-line class-methods-use-this -- Implementing interface method.
56 dispose(): void {
57 // No reactions to dispose yet.
58 }
59}
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
new file mode 100644
index 0000000..c4f7e4d
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
@@ -0,0 +1,121 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import type { BrowserViewBounds } from '@sophie/shared';
22import { BrowserView } from 'electron';
23
24import type Service from '../../../stores/Service';
25import type Resources from '../../resources/Resources';
26import type { ServiceView } from '../types';
27
28import ElectronPartition from './ElectronPartition';
29import type ElectronViewFactory from './ElectronViewFactory';
30
31export default class ElectronServiceView implements ServiceView {
32 readonly id: string;
33
34 readonly partitionId: string;
35
36 readonly browserView: BrowserView;
37
38 constructor(
39 service: Service,
40 resources: Resources,
41 partition: ElectronPartition,
42 private readonly parent: ElectronViewFactory,
43 ) {
44 this.id = service.id;
45 this.partitionId = partition.id;
46 this.browserView = new BrowserView({
47 webPreferences: {
48 sandbox: true,
49 nodeIntegrationInSubFrames: true,
50 preload: resources.getPath('service-preload', 'index.cjs'),
51 session: partition.session,
52 },
53 });
54
55 const { webContents } = this.browserView;
56
57 function setLocation(url: string) {
58 service.setLocation({
59 url,
60 canGoBack: webContents.canGoBack(),
61 canGoForward: webContents.canGoForward(),
62 });
63 }
64
65 webContents.on('did-navigate', (_event, url) => {
66 setLocation(url);
67 });
68
69 webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
70 if (isMainFrame) {
71 setLocation(url);
72 }
73 });
74
75 webContents.on('page-title-updated', (_event, title) => {
76 service.setTitle(title);
77 });
78
79 webContents.on('did-start-loading', () => {
80 service.startedLoading();
81 });
82
83 webContents.on('did-finish-load', () => {
84 service.finishedLoading();
85 });
86
87 webContents.on('render-process-gone', () => {
88 service.crashed();
89 });
90 }
91
92 get webContentsId(): number {
93 return this.browserView.webContents.id;
94 }
95
96 loadURL(url: string): Promise<void> {
97 return this.browserView.webContents.loadURL(url);
98 }
99
100 goBack(): void {
101 this.browserView.webContents.goBack();
102 }
103
104 goForward(): void {
105 this.browserView.webContents.goForward();
106 }
107
108 setBounds(bounds: BrowserViewBounds): void {
109 this.browserView.setBounds(bounds);
110 }
111
112 dispose(): void {
113 this.parent.unregisterServiceView(this.webContentsId);
114 setImmediate(() => {
115 // Undocumented electron API, see e.g., https://github.com/electron/electron/issues/29626
116 (
117 this.browserView.webContents as unknown as { destroy(): void }
118 ).destroy();
119 });
120 }
121}
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronViewFactory.ts b/packages/main/src/infrastructure/electron/impl/ElectronViewFactory.ts
new file mode 100644
index 0000000..f8b4a36
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/impl/ElectronViewFactory.ts
@@ -0,0 +1,119 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { readFile } from 'node:fs/promises';
22
23import { ServiceToMainIpcMessage } from '@sophie/service-shared';
24import { ipcMain, WebSource } from 'electron';
25
26import type MainStore from '../../../stores/MainStore';
27import type Profile from '../../../stores/Profile';
28import type Service from '../../../stores/Service';
29import { getLogger } from '../../../utils/log';
30import type Resources from '../../resources/Resources';
31import type UserAgents from '../UserAgents';
32import type { MainWindow, Partition, ServiceView, ViewFactory } from '../types';
33
34import ElectronMainWindow from './ElectronMainWindow';
35import ElectronPartition from './ElectronPartition';
36import ElectronServiceView from './ElectronServiceView';
37
38const log = getLogger('ElectronViewFactory');
39
40export default class ElectronViewFactory implements ViewFactory {
41 private readonly webContentsIdToServiceView = new Map<
42 number,
43 ElectronServiceView
44 >();
45
46 private serviceInjectSource: WebSource | undefined;
47
48 constructor(
49 readonly userAgents: UserAgents,
50 readonly resources: Resources,
51 readonly devMode: boolean,
52 ) {
53 ipcMain.handle(ServiceToMainIpcMessage.ApiExposedInMainWorld, (event) => {
54 if (!this.webContentsIdToServiceView.has(event.sender.id)) {
55 log.error(
56 'Unexpected',
57 ServiceToMainIpcMessage.ApiExposedInMainWorld,
58 'IPC message from webContents',
59 event.sender.id,
60 );
61 throw new Error('Invalid IPC call');
62 }
63 if (this.serviceInjectSource === undefined) {
64 log.error('Service inject source was not loaded');
65 }
66 return this.serviceInjectSource;
67 });
68 }
69
70 async createMainWindow(store: MainStore): Promise<MainWindow> {
71 const mainWindow = new ElectronMainWindow(store, this);
72 await mainWindow.loadInterface();
73 return mainWindow;
74 }
75
76 createPartition(profile: Profile): Partition {
77 return new ElectronPartition(profile, this);
78 }
79
80 createServiceView(service: Service, partition: Partition): ServiceView {
81 if (partition instanceof ElectronPartition) {
82 const serviceView = new ElectronServiceView(
83 service,
84 this.resources,
85 partition,
86 this,
87 );
88 this.webContentsIdToServiceView.set(
89 serviceView.webContentsId,
90 serviceView,
91 );
92 return serviceView;
93 }
94 throw new TypeError('Unexpected ProfileSession is not a WrappedSession');
95 }
96
97 async loadServiceInject(): Promise<void> {
98 const injectPackage = 'service-inject';
99 const injectFile = 'index.js';
100 const injectPath = this.resources.getPath(injectPackage, injectFile);
101 this.serviceInjectSource = {
102 code: await readFile(injectPath, 'utf-8'),
103 url: this.resources.getFileURL(injectPackage, injectFile),
104 };
105 }
106
107 dispose(): void {
108 if (this.webContentsIdToServiceView.size > 0) {
109 throw new Error(
110 'Must dispose all ServiceView instances before disposing ViewFactory',
111 );
112 }
113 ipcMain.removeHandler(ServiceToMainIpcMessage.ApiExposedInMainWorld);
114 }
115
116 unregisterServiceView(id: number): void {
117 this.webContentsIdToServiceView.delete(id);
118 }
119}
diff --git a/packages/main/src/infrastructure/electron/types.ts b/packages/main/src/infrastructure/electron/types.ts
new file mode 100644
index 0000000..9f03214
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/types.ts
@@ -0,0 +1,67 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import type { BrowserViewBounds } from '@sophie/shared';
22
23import type MainStore from '../../stores/MainStore';
24import type Profile from '../../stores/Profile';
25import type Service from '../../stores/Service';
26
27export interface ViewFactory {
28 createMainWindow(store: MainStore): Promise<MainWindow>;
29
30 createPartition(profile: Profile): Partition;
31
32 createServiceView(service: Service, partition: Partition): ServiceView;
33
34 loadServiceInject(): Promise<void>;
35
36 dispose(): void;
37}
38
39export interface MainWindow {
40 bringToForeground(): void;
41
42 setServiceView(serviceView: ServiceView | undefined): void;
43
44 dispose(): void;
45}
46
47export interface Partition {
48 readonly id: string;
49
50 dispose(): void;
51}
52
53export interface ServiceView {
54 readonly id: string;
55
56 readonly partitionId: string;
57
58 loadURL(url: string): Promise<void>;
59
60 goBack(): void;
61
62 goForward(): void;
63
64 setBounds(bounds: BrowserViewBounds): void;
65
66 dispose(): void;
67}
diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts
index a87b323..b6a8502 100644
--- a/packages/main/src/initReactions.ts
+++ b/packages/main/src/initReactions.ts
@@ -18,9 +18,15 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { app } from 'electron'; 21import { app, session } from 'electron';
22 22
23import ConfigFile from './infrastructure/config/impl/ConfigFile'; 23import ConfigFile from './infrastructure/config/impl/ConfigFile';
24import UserAgents from './infrastructure/electron/UserAgents';
25import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory';
26import { installDevToolsExtensions } from './infrastructure/electron/impl/devTools';
27import hardenSession from './infrastructure/electron/impl/hardenSession';
28import getDistResources from './infrastructure/resources/impl/getDistResources';
29import loadServices from './reactions/loadServices';
24import synchronizeConfig from './reactions/synchronizeConfig'; 30import synchronizeConfig from './reactions/synchronizeConfig';
25import synchronizeNativeTheme from './reactions/synchronizeNativeTheme'; 31import synchronizeNativeTheme from './reactions/synchronizeNativeTheme';
26import type MainStore from './stores/MainStore'; 32import type MainStore from './stores/MainStore';
@@ -28,6 +34,7 @@ import type Disposer from './utils/Disposer';
28 34
29export default async function initReactions( 35export default async function initReactions(
30 store: MainStore, 36 store: MainStore,
37 devMode: boolean,
31): Promise<Disposer> { 38): Promise<Disposer> {
32 const configRepository = new ConfigFile(app.getPath('userData')); 39 const configRepository = new ConfigFile(app.getPath('userData'));
33 const disposeConfigController = await synchronizeConfig( 40 const disposeConfigController = await synchronizeConfig(
@@ -36,7 +43,20 @@ export default async function initReactions(
36 ); 43 );
37 await app.whenReady(); 44 await app.whenReady();
38 const disposeNativeThemeController = synchronizeNativeTheme(store.shared); 45 const disposeNativeThemeController = synchronizeNativeTheme(store.shared);
39 46 const resources = getDistResources(devMode);
47 hardenSession(resources, devMode, session.defaultSession);
48 if (devMode) {
49 await installDevToolsExtensions();
50 }
51 const userAgents = new UserAgents(app.userAgentFallback);
52 app.userAgentFallback = userAgents.fallbackUserAgent(devMode);
53 const viewFactory = new ElectronViewFactory(userAgents, resources, devMode);
54 const [mainWindow] = await Promise.all([
55 viewFactory.createMainWindow(store),
56 viewFactory.loadServiceInject(),
57 ]);
58 store.setMainWindow(mainWindow);
59 loadServices(store, viewFactory);
40 return () => { 60 return () => {
41 disposeNativeThemeController(); 61 disposeNativeThemeController();
42 disposeConfigController(); 62 disposeConfigController();
diff --git a/packages/main/src/reactions/loadServices.ts b/packages/main/src/reactions/loadServices.ts
new file mode 100644
index 0000000..533ef95
--- /dev/null
+++ b/packages/main/src/reactions/loadServices.ts
@@ -0,0 +1,157 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { autorun, reaction } from 'mobx';
22import { addDisposer } from 'mobx-state-tree';
23
24import type {
25 MainWindow,
26 Partition,
27 ServiceView,
28 ViewFactory,
29} from '../infrastructure/electron/types';
30import type MainStore from '../stores/MainStore';
31import type Service from '../stores/Service';
32import { getLogger } from '../utils/log';
33
34const log = getLogger('loadServices');
35
36export default function loadServices(
37 store: MainStore,
38 viewFactory: ViewFactory,
39): void {
40 const profilesToPartitions = new Map<string, Partition>();
41 const servicesToViews = new Map<string, ServiceView>();
42
43 type ReactionArgs = [
44 Map<string, Service>,
45 MainWindow | undefined,
46 Service | undefined,
47 unknown,
48 ];
49
50 const disposer = reaction(
51 (): ReactionArgs => [
52 new Map(store.shared.servicesById),
53 store.mainWindow,
54 store.visibleService,
55 [...store.shared.servicesById.values()].map((service) => [
56 service.settings.profile,
57 service.shouldBeLoaded,
58 ]),
59 ],
60 ([servicesById, mainWindow, visibleService]: ReactionArgs) => {
61 log.debug('Loading service partitions and views');
62
63 const partitionsToDispose = new Map(profilesToPartitions);
64 servicesById.forEach((service) => {
65 const {
66 settings: { profile },
67 } = service;
68 const { id: profileId } = profile;
69 partitionsToDispose.delete(profileId);
70 if (!profilesToPartitions.has(profileId)) {
71 log.debug('Creating partition for profile', profileId);
72 profilesToPartitions.set(
73 profileId,
74 viewFactory.createPartition(profile),
75 );
76 }
77 });
78
79 const viewsToDispose = new Map(servicesToViews);
80 servicesById.forEach((service, serviceId) => {
81 if (service.shouldBeLoaded) {
82 let view = servicesToViews.get(serviceId);
83 const {
84 settings: {
85 profile: { id: profileId },
86 },
87 } = service;
88 if (view === undefined || view.partitionId !== profileId) {
89 log.debug('Creating view for service', serviceId);
90 const partition = profilesToPartitions.get(profileId);
91 if (partition === undefined) {
92 throw new Error(`Missing Partition ${profileId}`);
93 }
94 view = viewFactory.createServiceView(service, partition);
95 view.setBounds(store.browserViewBounds);
96 servicesToViews.set(serviceId, view);
97 service.setServiceView(view);
98 const { urlToLoad } = service;
99 view.loadURL(urlToLoad).catch((error) => {
100 log.warn(
101 'Cannot URL',
102 urlToLoad,
103 'for service',
104 serviceId,
105 error,
106 );
107 });
108 } else {
109 viewsToDispose.delete(serviceId);
110 }
111 }
112 });
113
114 mainWindow?.setServiceView(visibleService?.serviceView);
115 log.debug('Visible service is', visibleService?.serviceView?.id);
116
117 viewsToDispose.forEach((view, serviceId) => {
118 const currentView = servicesToViews.get(serviceId);
119 if (currentView === view) {
120 servicesToViews.delete(serviceId);
121 const service = store.shared.servicesById.get(serviceId);
122 if (service !== undefined) {
123 service.setServiceView(undefined);
124 }
125 log.debug('Disposing view for service', serviceId);
126 } else {
127 log.debug('Changed partition for service', serviceId);
128 }
129 view.dispose();
130 });
131
132 partitionsToDispose.forEach((partition, profileId) => {
133 log.debug('Disposing partition for profile', profileId);
134 profilesToPartitions.delete(profileId);
135 partition.dispose();
136 });
137 },
138 {
139 fireImmediately: true,
140 },
141 );
142
143 const resizeDisposer = autorun(() => {
144 store.visibleService?.serviceView?.setBounds(store.browserViewBounds);
145 });
146
147 addDisposer(store, () => {
148 resizeDisposer();
149 disposer();
150 store.mainWindow?.setServiceView(undefined);
151 servicesToViews.forEach((serviceView, serviceId) => {
152 store.shared.servicesById.get(serviceId)?.setServiceView(undefined);
153 serviceView.dispose();
154 });
155 profilesToPartitions.forEach((partition) => partition.dispose());
156 });
157}
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index ff014c9..9ac56f4 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -18,14 +18,19 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { BrowserViewBounds } from '@sophie/shared'; 21import type { Action, BrowserViewBounds } from '@sophie/shared';
22import { applySnapshot, Instance, types } from 'mobx-state-tree'; 22import { applySnapshot, Instance, types } from 'mobx-state-tree';
23 23
24import type { MainWindow } from '../infrastructure/electron/types';
25import { getLogger } from '../utils/log';
26
24import GlobalSettings from './GlobalSettings'; 27import GlobalSettings from './GlobalSettings';
25import Profile from './Profile'; 28import Profile from './Profile';
26import Service from './Service'; 29import Service from './Service';
27import SharedStore from './SharedStore'; 30import SharedStore from './SharedStore';
28 31
32const log = getLogger('MainStore');
33
29const MainStore = types 34const MainStore = types
30 .model('MainStore', { 35 .model('MainStore', {
31 browserViewBounds: types.optional( 36 browserViewBounds: types.optional(
@@ -49,11 +54,49 @@ const MainStore = types
49 get services(): Service[] { 54 get services(): Service[] {
50 return self.shared.services; 55 return self.shared.services;
51 }, 56 },
57 get visibleService(): Service | undefined {
58 const { selectedService } = this.settings;
59 return selectedService !== undefined && selectedService.shouldBeLoaded
60 ? selectedService
61 : undefined;
62 },
52 })) 63 }))
64 .volatile(
65 (): {
66 mainWindow: MainWindow | undefined;
67 } => ({
68 mainWindow: undefined,
69 }),
70 )
53 .actions((self) => ({ 71 .actions((self) => ({
54 setBrowserViewBounds(bounds: BrowserViewBounds): void { 72 setBrowserViewBounds(bounds: BrowserViewBounds): void {
55 applySnapshot(self.browserViewBounds, bounds); 73 applySnapshot(self.browserViewBounds, bounds);
56 }, 74 },
75 dispatch(action: Action): void {
76 switch (action.action) {
77 case 'reload-all-services':
78 // TODO
79 break;
80 case 'set-browser-view-bounds':
81 this.setBrowserViewBounds(action.browserViewBounds);
82 break;
83 case 'set-selected-service-id':
84 self.settings.setSelectedServiceId(action.serviceId);
85 break;
86 case 'set-theme-source':
87 self.settings.setThemeSource(action.themeSource);
88 break;
89 default:
90 log.error('Unknown action to dispatch', action);
91 break;
92 }
93 },
94 setMainWindow(mainWindow: MainWindow | undefined): void {
95 self.mainWindow = mainWindow;
96 },
97 beforeDestroy(): void {
98 self.mainWindow?.dispose();
99 },
57 })); 100 }));
58 101
59/* 102/*
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index fea0bdf..5302dd4 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -22,6 +22,7 @@ import type { UnreadCount } from '@sophie/service-shared';
22import { Service as ServiceBase } from '@sophie/shared'; 22import { Service as ServiceBase } from '@sophie/shared';
23import { Instance, getSnapshot } from 'mobx-state-tree'; 23import { Instance, getSnapshot } from 'mobx-state-tree';
24 24
25import type { ServiceView } from '../infrastructure/electron/types';
25import overrideProps from '../utils/overrideProps'; 26import overrideProps from '../utils/overrideProps';
26 27
27import ServiceSettings from './ServiceSettings'; 28import ServiceSettings from './ServiceSettings';
@@ -35,7 +36,20 @@ const Service = overrideProps(ServiceBase, {
35 const { id, settings } = self; 36 const { id, settings } = self;
36 return { ...getSnapshot(settings), id }; 37 return { ...getSnapshot(settings), id };
37 }, 38 },
39 get urlToLoad(): string {
40 return self.currentUrl ?? self.settings.url;
41 },
42 get shouldBeLoaded(): boolean {
43 return self.state !== 'crashed';
44 },
38 })) 45 }))
46 .volatile(
47 (): {
48 serviceView: ServiceView | undefined;
49 } => ({
50 serviceView: undefined,
51 }),
52 )
39 .actions((self) => ({ 53 .actions((self) => ({
40 setLocation({ 54 setLocation({
41 url, 55 url,
@@ -73,6 +87,9 @@ const Service = overrideProps(ServiceBase, {
73 self.indirectMessageCount = indirect; 87 self.indirectMessageCount = indirect;
74 } 88 }
75 }, 89 },
90 setServiceView(serviceView: ServiceView | undefined): void {
91 self.serviceView = serviceView;
92 },
76 })); 93 }));
77 94
78/* 95/*
diff --git a/packages/service-preload/src/index.ts b/packages/service-preload/src/index.ts
index 5383f42..e546f2f 100644
--- a/packages/service-preload/src/index.ts
+++ b/packages/service-preload/src/index.ts
@@ -24,7 +24,7 @@ import { ipcRenderer, webFrame } from 'electron';
24if (webFrame.parent === null) { 24if (webFrame.parent === null) {
25 // Inject CSS to simulate `browserView.setBackgroundColor`. 25 // Inject CSS to simulate `browserView.setBackgroundColor`.
26 // This is injected before the page loads, so the styles from the website will overwrite it. 26 // This is injected before the page loads, so the styles from the website will overwrite it.
27 webFrame.insertCSS('html { background-color: #fff; }'); 27 webFrame.insertCSS(':root { background-color: #fff; }');
28} 28}
29 29
30/** 30/**