diff options
Diffstat (limited to 'packages')
-rw-r--r-- | packages/main/src/index.ts | 260 | ||||
-rw-r--r-- | packages/main/src/infrastructure/config/ReadConfigResult.ts | 23 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/RendererBridge.ts | 103 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/UserAgents.ts | 46 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts | 169 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/ElectronPartition.ts | 59 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts | 121 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/ElectronViewFactory.ts | 119 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/types.ts | 67 | ||||
-rw-r--r-- | packages/main/src/initReactions.ts | 24 | ||||
-rw-r--r-- | packages/main/src/reactions/loadServices.ts | 157 | ||||
-rw-r--r-- | packages/main/src/stores/MainStore.ts | 45 | ||||
-rw-r--r-- | packages/main/src/stores/Service.ts | 17 | ||||
-rw-r--r-- | packages/service-preload/src/index.ts | 2 |
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 | ||
22 | import { readFileSync } from 'node:fs'; | ||
23 | import { readFile } from 'node:fs/promises'; | ||
24 | import { arch } from 'node:os'; | 22 | import { arch } from 'node:os'; |
25 | 23 | ||
26 | import { | 24 | import { app } from 'electron'; |
27 | ServiceToMainIpcMessage, | ||
28 | UnreadCount, | ||
29 | WebSource, | ||
30 | } from '@sophie/service-shared'; | ||
31 | import { | ||
32 | Action, | ||
33 | MainToRendererIpcMessage, | ||
34 | RendererToMainIpcMessage, | ||
35 | } from '@sophie/shared'; | ||
36 | import { app, BrowserView, BrowserWindow, ipcMain } from 'electron'; | ||
37 | import { ensureDirSync } from 'fs-extra'; | 25 | import { ensureDirSync } from 'fs-extra'; |
38 | import { autorun } from 'mobx'; | ||
39 | import { getSnapshot, onAction, onPatch } from 'mobx-state-tree'; | ||
40 | import osName from 'os-name'; | 26 | import osName from 'os-name'; |
41 | 27 | ||
42 | import { | 28 | import { enableStacktraceSourceMaps } from './infrastructure/electron/impl/devTools'; |
43 | enableStacktraceSourceMaps, | ||
44 | installDevToolsExtensions, | ||
45 | openDevToolsWhenReady, | ||
46 | } from './infrastructure/electron/impl/devTools'; | ||
47 | import hardenSession from './infrastructure/electron/impl/hardenSession'; | ||
48 | import lockWebContentsToFile from './infrastructure/electron/impl/lockWebContentsToFile'; | ||
49 | import getDistResources from './infrastructure/resources/impl/getDistResources'; | ||
50 | import initReactions from './initReactions'; | 29 | import initReactions from './initReactions'; |
51 | import { createMainStore } from './stores/MainStore'; | 30 | import { createMainStore } from './stores/MainStore'; |
52 | import { getLogger } from './utils/log'; | 31 | import { 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. | ||
85 | const originalUserAgent = app.userAgentFallback; | ||
86 | const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, ''); | ||
87 | const chromelessUserAgent = userAgent.replace(/ Chrome\/\S+/, ''); | ||
88 | // Removing the electron version breaks redux devtools, so we only do this in production. | ||
89 | if (!isDevelopment) { | ||
90 | app.userAgentFallback = userAgent; | ||
91 | } | ||
92 | |||
93 | app.setAboutPanelOptions({ | 63 | app.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 | ||
110 | const resources = getDistResources(isDevelopment); | ||
111 | |||
112 | const serviceInjectPath = resources.getPath('service-inject', 'index.js'); | ||
113 | const serviceInject: WebSource = { | ||
114 | code: readFileSync(serviceInjectPath, 'utf8'), | ||
115 | url: resources.getFileURL('service-inject', 'index.js'), | ||
116 | }; | ||
117 | |||
118 | let mainWindow: BrowserWindow | undefined; | ||
119 | |||
120 | const store = createMainStore(); | 80 | const store = createMainStore(); |
121 | 81 | ||
122 | initReactions(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 | |||
131 | async 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 | |||
309 | app.on('second-instance', () => { | 82 | app.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 | ||
321 | app.on('window-all-closed', () => { | 86 | app.on('window-all-closed', () => { |
@@ -324,20 +89,11 @@ app.on('window-all-closed', () => { | |||
324 | } | 89 | } |
325 | }); | 90 | }); |
326 | 91 | ||
327 | app | 92 | initReactions(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 | |||
21 | type ReadConfigResult = { found: true; data: unknown } | { found: false }; | ||
22 | |||
23 | export 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 | |||
21 | import type { SharedStoreSnapshotOut } from '@sophie/shared'; | ||
22 | import { | ||
23 | addMiddleware, | ||
24 | getSnapshot, | ||
25 | IJsonPatch, | ||
26 | IMiddlewareEvent, | ||
27 | onPatch, | ||
28 | } from 'mobx-state-tree'; | ||
29 | |||
30 | import type MainStore from '../../stores/MainStore'; | ||
31 | import Disposer from '../../utils/Disposer'; | ||
32 | import { getLogger } from '../../utils/log'; | ||
33 | |||
34 | const log = getLogger('RendererBridge'); | ||
35 | |||
36 | export type PatchListener = (patch: IJsonPatch[]) => void; | ||
37 | |||
38 | export 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 | |||
21 | const CHROMELESS_USER_AGENT_REGEX = /^[^:]+:\/\/accounts\.google\.[^./]+\//; | ||
22 | |||
23 | export 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 | |||
21 | import { | ||
22 | Action, | ||
23 | MainToRendererIpcMessage, | ||
24 | RendererToMainIpcMessage, | ||
25 | } from '@sophie/shared'; | ||
26 | import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron'; | ||
27 | import type { IJsonPatch } from 'mobx-state-tree'; | ||
28 | |||
29 | import type MainStore from '../../../stores/MainStore'; | ||
30 | import { getLogger } from '../../../utils/log'; | ||
31 | import RendererBridge from '../RendererBridge'; | ||
32 | import type { MainWindow, ServiceView } from '../types'; | ||
33 | |||
34 | import ElectronServiceView from './ElectronServiceView'; | ||
35 | import type ElectronViewFactory from './ElectronViewFactory'; | ||
36 | import { openDevToolsWhenReady } from './devTools'; | ||
37 | import lockWebContentsToFile from './lockWebContentsToFile'; | ||
38 | |||
39 | const log = getLogger('ElectronMainWindow'); | ||
40 | |||
41 | export 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 | |||
21 | import { Session, session } from 'electron'; | ||
22 | |||
23 | import type Profile from '../../../stores/Profile'; | ||
24 | import type { Partition } from '../types'; | ||
25 | |||
26 | import type ElectronViewFactory from './ElectronViewFactory'; | ||
27 | |||
28 | export 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 | |||
21 | import type { BrowserViewBounds } from '@sophie/shared'; | ||
22 | import { BrowserView } from 'electron'; | ||
23 | |||
24 | import type Service from '../../../stores/Service'; | ||
25 | import type Resources from '../../resources/Resources'; | ||
26 | import type { ServiceView } from '../types'; | ||
27 | |||
28 | import ElectronPartition from './ElectronPartition'; | ||
29 | import type ElectronViewFactory from './ElectronViewFactory'; | ||
30 | |||
31 | export 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 | |||
21 | import { readFile } from 'node:fs/promises'; | ||
22 | |||
23 | import { ServiceToMainIpcMessage } from '@sophie/service-shared'; | ||
24 | import { ipcMain, WebSource } from 'electron'; | ||
25 | |||
26 | import type MainStore from '../../../stores/MainStore'; | ||
27 | import type Profile from '../../../stores/Profile'; | ||
28 | import type Service from '../../../stores/Service'; | ||
29 | import { getLogger } from '../../../utils/log'; | ||
30 | import type Resources from '../../resources/Resources'; | ||
31 | import type UserAgents from '../UserAgents'; | ||
32 | import type { MainWindow, Partition, ServiceView, ViewFactory } from '../types'; | ||
33 | |||
34 | import ElectronMainWindow from './ElectronMainWindow'; | ||
35 | import ElectronPartition from './ElectronPartition'; | ||
36 | import ElectronServiceView from './ElectronServiceView'; | ||
37 | |||
38 | const log = getLogger('ElectronViewFactory'); | ||
39 | |||
40 | export 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 | |||
21 | import type { BrowserViewBounds } from '@sophie/shared'; | ||
22 | |||
23 | import type MainStore from '../../stores/MainStore'; | ||
24 | import type Profile from '../../stores/Profile'; | ||
25 | import type Service from '../../stores/Service'; | ||
26 | |||
27 | export 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 | |||
39 | export interface MainWindow { | ||
40 | bringToForeground(): void; | ||
41 | |||
42 | setServiceView(serviceView: ServiceView | undefined): void; | ||
43 | |||
44 | dispose(): void; | ||
45 | } | ||
46 | |||
47 | export interface Partition { | ||
48 | readonly id: string; | ||
49 | |||
50 | dispose(): void; | ||
51 | } | ||
52 | |||
53 | export 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 | ||
21 | import { app } from 'electron'; | 21 | import { app, session } from 'electron'; |
22 | 22 | ||
23 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; | 23 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; |
24 | import UserAgents from './infrastructure/electron/UserAgents'; | ||
25 | import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; | ||
26 | import { installDevToolsExtensions } from './infrastructure/electron/impl/devTools'; | ||
27 | import hardenSession from './infrastructure/electron/impl/hardenSession'; | ||
28 | import getDistResources from './infrastructure/resources/impl/getDistResources'; | ||
29 | import loadServices from './reactions/loadServices'; | ||
24 | import synchronizeConfig from './reactions/synchronizeConfig'; | 30 | import synchronizeConfig from './reactions/synchronizeConfig'; |
25 | import synchronizeNativeTheme from './reactions/synchronizeNativeTheme'; | 31 | import synchronizeNativeTheme from './reactions/synchronizeNativeTheme'; |
26 | import type MainStore from './stores/MainStore'; | 32 | import type MainStore from './stores/MainStore'; |
@@ -28,6 +34,7 @@ import type Disposer from './utils/Disposer'; | |||
28 | 34 | ||
29 | export default async function initReactions( | 35 | export 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 | |||
21 | import { autorun, reaction } from 'mobx'; | ||
22 | import { addDisposer } from 'mobx-state-tree'; | ||
23 | |||
24 | import type { | ||
25 | MainWindow, | ||
26 | Partition, | ||
27 | ServiceView, | ||
28 | ViewFactory, | ||
29 | } from '../infrastructure/electron/types'; | ||
30 | import type MainStore from '../stores/MainStore'; | ||
31 | import type Service from '../stores/Service'; | ||
32 | import { getLogger } from '../utils/log'; | ||
33 | |||
34 | const log = getLogger('loadServices'); | ||
35 | |||
36 | export 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 | ||
21 | import type { BrowserViewBounds } from '@sophie/shared'; | 21 | import type { Action, BrowserViewBounds } from '@sophie/shared'; |
22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; | 22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; |
23 | 23 | ||
24 | import type { MainWindow } from '../infrastructure/electron/types'; | ||
25 | import { getLogger } from '../utils/log'; | ||
26 | |||
24 | import GlobalSettings from './GlobalSettings'; | 27 | import GlobalSettings from './GlobalSettings'; |
25 | import Profile from './Profile'; | 28 | import Profile from './Profile'; |
26 | import Service from './Service'; | 29 | import Service from './Service'; |
27 | import SharedStore from './SharedStore'; | 30 | import SharedStore from './SharedStore'; |
28 | 31 | ||
32 | const log = getLogger('MainStore'); | ||
33 | |||
29 | const MainStore = types | 34 | const 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'; | |||
22 | import { Service as ServiceBase } from '@sophie/shared'; | 22 | import { Service as ServiceBase } from '@sophie/shared'; |
23 | import { Instance, getSnapshot } from 'mobx-state-tree'; | 23 | import { Instance, getSnapshot } from 'mobx-state-tree'; |
24 | 24 | ||
25 | import type { ServiceView } from '../infrastructure/electron/types'; | ||
25 | import overrideProps from '../utils/overrideProps'; | 26 | import overrideProps from '../utils/overrideProps'; |
26 | 27 | ||
27 | import ServiceSettings from './ServiceSettings'; | 28 | import 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'; | |||
24 | if (webFrame.parent === null) { | 24 | if (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 | /** |