diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-12-26 19:17:23 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-12-26 19:17:23 +0100 |
commit | e56cdad02c00adf3b779d9de62d460e78be204a6 (patch) | |
tree | 638d8ac94380b3d675f13c6eabc1e5ee51126342 /packages/main | |
parent | refactor: Config persistence architecture (diff) | |
download | sophie-e56cdad02c00adf3b779d9de62d460e78be204a6.tar.gz sophie-e56cdad02c00adf3b779d9de62d460e78be204a6.tar.zst sophie-e56cdad02c00adf3b779d9de62d460e78be204a6.zip |
refactor: Clarify main process architecture
* stores: reactive data structures to hold application state
* controllers: subscribe to store changes and call store actions in
response to external events from services
* services: integrate with the nodejs and electron environment (should
be mocked for unit testing)
Diffstat (limited to 'packages/main')
-rw-r--r-- | packages/main/src/controllers/ConfigController.ts | 124 | ||||
-rw-r--r-- | packages/main/src/controllers/NativeThemeController.ts (renamed from packages/main/src/services/MainEnv.ts) | 30 | ||||
-rw-r--r-- | packages/main/src/index.ts | 29 | ||||
-rw-r--r-- | packages/main/src/services/ConfigPersistenceService.ts (renamed from packages/main/src/services/impl/ConfigPersistenceImpl.ts) | 11 | ||||
-rw-r--r-- | packages/main/src/services/NativeThemeService.ts (renamed from packages/main/src/services/ConfigPersistence.ts) | 28 | ||||
-rw-r--r-- | packages/main/src/stores/Config.ts | 87 | ||||
-rw-r--r-- | packages/main/src/stores/MainStore.ts | 16 |
7 files changed, 179 insertions, 146 deletions
diff --git a/packages/main/src/controllers/ConfigController.ts b/packages/main/src/controllers/ConfigController.ts new file mode 100644 index 0000000..6690548 --- /dev/null +++ b/packages/main/src/controllers/ConfigController.ts | |||
@@ -0,0 +1,124 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-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 { debounce } from 'lodash'; | ||
22 | import { | ||
23 | applySnapshot, | ||
24 | getSnapshot, | ||
25 | IDisposer, | ||
26 | onSnapshot, | ||
27 | } from 'mobx-state-tree'; | ||
28 | import ms from 'ms'; | ||
29 | |||
30 | import { ConfigPersistenceService } from '../services/ConfigPersistenceService'; | ||
31 | import { Config, ConfigSnapshotOut } from '../stores/Config'; | ||
32 | |||
33 | const DEFAULT_DEBOUNCE_TIME = ms('1s'); | ||
34 | |||
35 | class ConfigController { | ||
36 | readonly #config: Config; | ||
37 | |||
38 | readonly #persistenceService: ConfigPersistenceService; | ||
39 | |||
40 | readonly #onSnapshotDisposer: IDisposer; | ||
41 | |||
42 | readonly #watcherDisposer: IDisposer; | ||
43 | |||
44 | #lastSnapshotOnDisk: ConfigSnapshotOut | null = null; | ||
45 | |||
46 | #writingConfig: boolean = false; | ||
47 | |||
48 | #configMTime: Date | null = null; | ||
49 | |||
50 | constructor( | ||
51 | config: Config, | ||
52 | persistenceService: ConfigPersistenceService, | ||
53 | debounceTime: number, | ||
54 | ) { | ||
55 | this.#config = config; | ||
56 | this.#persistenceService = persistenceService; | ||
57 | this.#onSnapshotDisposer = onSnapshot(this.#config, debounce((snapshot) => { | ||
58 | // We can compare snapshots by reference, since it is only recreated on store changes. | ||
59 | if (this.#lastSnapshotOnDisk !== snapshot) { | ||
60 | this.#writeConfig().catch((err) => { | ||
61 | console.log('Failed to write config on config change', err); | ||
62 | }) | ||
63 | } | ||
64 | }, debounceTime)); | ||
65 | this.#watcherDisposer = this.#persistenceService.watchConfig(async (mtime) => { | ||
66 | if (!this.#writingConfig && (this.#configMTime === null || mtime > this.#configMTime)) { | ||
67 | await this.#readConfig(); | ||
68 | } | ||
69 | }, debounceTime); | ||
70 | } | ||
71 | |||
72 | async #readConfig(): Promise<boolean> { | ||
73 | const result = await this.#persistenceService.readConfig(); | ||
74 | if (result.found) { | ||
75 | try { | ||
76 | applySnapshot(this.#config, result.data); | ||
77 | this.#lastSnapshotOnDisk = getSnapshot(this.#config); | ||
78 | console.log('Loaded config'); | ||
79 | } catch (err) { | ||
80 | console.error('Failed to read config', result.data, err); | ||
81 | } | ||
82 | } | ||
83 | return result.found; | ||
84 | } | ||
85 | |||
86 | async #writeConfig(): Promise<void> { | ||
87 | const snapshot = getSnapshot(this.#config); | ||
88 | this.#writingConfig = true; | ||
89 | try { | ||
90 | this.#configMTime = await this.#persistenceService.writeConfig(snapshot); | ||
91 | this.#lastSnapshotOnDisk = snapshot; | ||
92 | console.log('Wrote config'); | ||
93 | } finally { | ||
94 | this.#writingConfig = false; | ||
95 | } | ||
96 | } | ||
97 | |||
98 | async initConfig(): Promise<void> { | ||
99 | const foundConfig: boolean = await this.#readConfig(); | ||
100 | if (!foundConfig) { | ||
101 | console.log('Creating new config file'); | ||
102 | try { | ||
103 | await this.#writeConfig(); | ||
104 | } catch (err) { | ||
105 | console.error('Failed to initialize config'); | ||
106 | } | ||
107 | } | ||
108 | } | ||
109 | |||
110 | dispose(): void { | ||
111 | this.#onSnapshotDisposer(); | ||
112 | this.#watcherDisposer(); | ||
113 | } | ||
114 | } | ||
115 | |||
116 | export async function initConfig( | ||
117 | config: Config, | ||
118 | persistenceService: ConfigPersistenceService, | ||
119 | debounceTime: number = DEFAULT_DEBOUNCE_TIME, | ||
120 | ): Promise<IDisposer> { | ||
121 | const controller = new ConfigController(config, persistenceService, debounceTime); | ||
122 | await controller.initConfig(); | ||
123 | return () => controller.dispose(); | ||
124 | } | ||
diff --git a/packages/main/src/services/MainEnv.ts b/packages/main/src/controllers/NativeThemeController.ts index 23ee9a1..07a3292 100644 --- a/packages/main/src/services/MainEnv.ts +++ b/packages/main/src/controllers/NativeThemeController.ts | |||
@@ -18,21 +18,21 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { IAnyStateTreeNode, getEnv as getAnyEnv } from 'mobx-state-tree'; | 21 | import { autorun } from 'mobx'; |
22 | import type { IDisposer } from 'mobx-state-tree'; | ||
22 | 23 | ||
23 | import type { ConfigPersistence } from './ConfigPersistence'; | 24 | import type { NativeThemeService } from '../services/NativeThemeService'; |
25 | import type { MainStore } from '../stores/MainStore'; | ||
24 | 26 | ||
25 | export interface MainEnv { | 27 | export function initNativeTheme(store: MainStore, service: NativeThemeService): IDisposer { |
26 | configPersistence: ConfigPersistence; | 28 | const themeSourceReactionDisposer = autorun(() => { |
27 | } | 29 | service.setThemeSource(store.config.themeSource); |
28 | 30 | }); | |
29 | /** | 31 | const onShouldUseDarkColorsUpdatedDisposer = service.onShouldUseDarkColorsUpdated( |
30 | * Gets a well-typed environment from `model`. | 32 | store.setShouldUseDarkColors, |
31 | * | 33 | ); |
32 | * Only useable inside state trees created by `createAndConnectRootStore`. | 34 | return () => { |
33 | * | 35 | onShouldUseDarkColorsUpdatedDisposer(); |
34 | * @param model The state tree node. | 36 | themeSourceReactionDisposer(); |
35 | */ | 37 | }; |
36 | export function getEnv(model: IAnyStateTreeNode): MainEnv { | ||
37 | return getAnyEnv<MainEnv>(model); | ||
38 | } | 38 | } |
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 8297ff5..67f5546 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts | |||
@@ -23,7 +23,6 @@ import { | |||
23 | BrowserView, | 23 | BrowserView, |
24 | BrowserWindow, | 24 | BrowserWindow, |
25 | ipcMain, | 25 | ipcMain, |
26 | nativeTheme, | ||
27 | } from 'electron'; | 26 | } from 'electron'; |
28 | import { readFileSync } from 'fs'; | 27 | import { readFileSync } from 'fs'; |
29 | import { readFile } from 'fs/promises'; | 28 | import { readFile } from 'fs/promises'; |
@@ -47,7 +46,10 @@ import { | |||
47 | installDevToolsExtensions, | 46 | installDevToolsExtensions, |
48 | openDevToolsWhenReady, | 47 | openDevToolsWhenReady, |
49 | } from './devTools'; | 48 | } from './devTools'; |
50 | import { ConfigPersistenceImpl } from './services/impl/ConfigPersistenceImpl'; | 49 | import { initConfig } from './controllers/ConfigController'; |
50 | import { initNativeTheme } from './controllers/NativeThemeController'; | ||
51 | import { ConfigPersistenceService } from './services/ConfigPersistenceService'; | ||
52 | import { NativeThemeService } from './services/NativeThemeService'; | ||
51 | import { createMainStore } from './stores/MainStore'; | 53 | import { createMainStore } from './stores/MainStore'; |
52 | 54 | ||
53 | const isDevelopment = import.meta.env.MODE === 'development'; | 55 | const isDevelopment = import.meta.env.MODE === 'development'; |
@@ -105,23 +107,14 @@ if (isDevelopment) { | |||
105 | 107 | ||
106 | let mainWindow: BrowserWindow | null = null; | 108 | let mainWindow: BrowserWindow | null = null; |
107 | 109 | ||
108 | const store = createMainStore({ | 110 | const store = createMainStore(); |
109 | configPersistence: new ConfigPersistenceImpl( | ||
110 | app.getPath('userData'), | ||
111 | 'config.json5', | ||
112 | ), | ||
113 | }); | ||
114 | |||
115 | autorun(() => { | ||
116 | nativeTheme.themeSource = store.config.themeSource; | ||
117 | }); | ||
118 | |||
119 | store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); | ||
120 | nativeTheme.on('updated', () => { | ||
121 | store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); | ||
122 | }); | ||
123 | 111 | ||
124 | store.config.initConfig(); | 112 | initConfig( |
113 | store.config, | ||
114 | new ConfigPersistenceService(app.getPath('userData'), 'config.json5'), | ||
115 | ).then(() => { | ||
116 | initNativeTheme(store, new NativeThemeService()); | ||
117 | }).catch((err) => console.error(err)); | ||
125 | 118 | ||
126 | const rendererBaseUrl = getResourceUrl('../renderer/'); | 119 | const rendererBaseUrl = getResourceUrl('../renderer/'); |
127 | function shouldCancelMainWindowRequest(url: string, method: string): boolean { | 120 | function shouldCancelMainWindowRequest(url: string, method: string): boolean { |
diff --git a/packages/main/src/services/impl/ConfigPersistenceImpl.ts b/packages/main/src/services/ConfigPersistenceService.ts index 097ab74..85b0088 100644 --- a/packages/main/src/services/impl/ConfigPersistenceImpl.ts +++ b/packages/main/src/services/ConfigPersistenceService.ts | |||
@@ -25,10 +25,11 @@ import { throttle } from 'lodash'; | |||
25 | import { IDisposer } from 'mobx-state-tree'; | 25 | import { IDisposer } from 'mobx-state-tree'; |
26 | import { join } from 'path'; | 26 | import { join } from 'path'; |
27 | 27 | ||
28 | import { CONFIG_DEBOUNCE_TIME, ConfigPersistence, ReadConfigResult } from '../ConfigPersistence'; | 28 | import type { ConfigSnapshotOut } from '../stores/Config'; |
29 | import type { ConfigSnapshotOut } from '../../stores/Config'; | ||
30 | 29 | ||
31 | export class ConfigPersistenceImpl implements ConfigPersistence { | 30 | export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; |
31 | |||
32 | export class ConfigPersistenceService { | ||
32 | readonly #userDataDir: string; | 33 | readonly #userDataDir: string; |
33 | 34 | ||
34 | readonly #configFileName: string; | 35 | readonly #configFileName: string; |
@@ -69,7 +70,7 @@ export class ConfigPersistenceImpl implements ConfigPersistence { | |||
69 | return mtime; | 70 | return mtime; |
70 | } | 71 | } |
71 | 72 | ||
72 | watchConfig(callback: (mtime: Date) => Promise<void>): IDisposer { | 73 | watchConfig(callback: (mtime: Date) => Promise<void>, throttleMs: number): IDisposer { |
73 | const configChanged = throttle(async () => { | 74 | const configChanged = throttle(async () => { |
74 | let mtime: Date; | 75 | let mtime: Date; |
75 | try { | 76 | try { |
@@ -82,7 +83,7 @@ export class ConfigPersistenceImpl implements ConfigPersistence { | |||
82 | throw err; | 83 | throw err; |
83 | } | 84 | } |
84 | return callback(mtime); | 85 | return callback(mtime); |
85 | }, CONFIG_DEBOUNCE_TIME); | 86 | }, throttleMs); |
86 | 87 | ||
87 | const watcher = watch(this.#userDataDir, { | 88 | const watcher = watch(this.#userDataDir, { |
88 | persistent: false, | 89 | persistent: false, |
diff --git a/packages/main/src/services/ConfigPersistence.ts b/packages/main/src/services/NativeThemeService.ts index f9a82de..7a26c3c 100644 --- a/packages/main/src/services/ConfigPersistence.ts +++ b/packages/main/src/services/NativeThemeService.ts | |||
@@ -18,19 +18,21 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { IDisposer } from 'mobx-state-tree'; | 21 | import { nativeTheme } from 'electron'; |
22 | import ms from 'ms'; | 22 | import type { IDisposer } from 'mobx-state-tree'; |
23 | import type { ThemeSource } from '@sophie/shared'; | ||
23 | 24 | ||
24 | import type { ConfigSnapshotOut } from '../stores/Config'; | 25 | export class NativeThemeService { |
26 | setThemeSource(themeSource: ThemeSource): void { | ||
27 | nativeTheme.themeSource = themeSource; | ||
28 | } | ||
25 | 29 | ||
26 | export const CONFIG_DEBOUNCE_TIME: number = ms('1s'); | 30 | onShouldUseDarkColorsUpdated(callback: (shouldUseDarkColors: boolean) => void): IDisposer { |
27 | 31 | const wrappedCallback = () => { | |
28 | export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; | 32 | callback(nativeTheme.shouldUseDarkColors); |
29 | 33 | }; | |
30 | export interface ConfigPersistence { | 34 | wrappedCallback(); |
31 | readConfig(): Promise<ReadConfigResult>; | 35 | nativeTheme.on('updated', wrappedCallback); |
32 | 36 | return () => nativeTheme.off('updated', wrappedCallback); | |
33 | writeConfig(configSnapshot: ConfigSnapshotOut): Promise<Date>; | 37 | } |
34 | |||
35 | watchConfig(callback: (mtime: Date) => Promise<void>): IDisposer; | ||
36 | } | 38 | } |
diff --git a/packages/main/src/stores/Config.ts b/packages/main/src/stores/Config.ts index eb53635..7d1168f 100644 --- a/packages/main/src/stores/Config.ts +++ b/packages/main/src/stores/Config.ts | |||
@@ -18,15 +18,7 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { debounce } from 'lodash'; | 21 | import { Instance } from 'mobx-state-tree'; |
22 | import { | ||
23 | applySnapshot, | ||
24 | flow, | ||
25 | getSnapshot, | ||
26 | IDisposer, | ||
27 | Instance, | ||
28 | onSnapshot, | ||
29 | } from 'mobx-state-tree'; | ||
30 | import { | 22 | import { |
31 | config as originalConfig, | 23 | config as originalConfig, |
32 | ConfigSnapshotIn, | 24 | ConfigSnapshotIn, |
@@ -34,86 +26,11 @@ import { | |||
34 | ThemeSource, | 26 | ThemeSource, |
35 | } from '@sophie/shared'; | 27 | } from '@sophie/shared'; |
36 | 28 | ||
37 | import { CONFIG_DEBOUNCE_TIME, ReadConfigResult } from '../services/ConfigPersistence'; | ||
38 | import { getEnv } from '../services/MainEnv'; | ||
39 | |||
40 | export const config = originalConfig.actions((self) => ({ | 29 | export const config = originalConfig.actions((self) => ({ |
41 | setThemeSource(mode: ThemeSource) { | 30 | setThemeSource(mode: ThemeSource) { |
42 | self.themeSource = mode; | 31 | self.themeSource = mode; |
43 | }, | 32 | }, |
44 | })).actions((self) => { | 33 | })); |
45 | let lastSnapshotOnDisk: ConfigSnapshotOut | null = null; | ||
46 | let writingConfig = false; | ||
47 | let configMtime: Date | null = null; | ||
48 | let onSnapshotDisposer: IDisposer | null = null; | ||
49 | let watcherDisposer: IDisposer | null = null; | ||
50 | |||
51 | function dispose() { | ||
52 | onSnapshotDisposer?.(); | ||
53 | watcherDisposer?.(); | ||
54 | } | ||
55 | |||
56 | const actions: { | ||
57 | beforeDetach(): void, | ||
58 | readConfig(): Promise<boolean>; | ||
59 | writeConfig(): Promise<void>; | ||
60 | initConfig(): Promise<void>; | ||
61 | } = { | ||
62 | beforeDetach() { | ||
63 | dispose(); | ||
64 | }, | ||
65 | readConfig: flow(function*() { | ||
66 | const result: ReadConfigResult = yield getEnv(self).configPersistence.readConfig(); | ||
67 | if (result.found) { | ||
68 | try { | ||
69 | applySnapshot(self, result.data); | ||
70 | lastSnapshotOnDisk = getSnapshot(self); | ||
71 | console.log('Loaded config'); | ||
72 | } catch (err) { | ||
73 | console.error('Failed to read config', result.data, err); | ||
74 | } | ||
75 | } | ||
76 | return result.found; | ||
77 | }), | ||
78 | writeConfig: flow(function*() { | ||
79 | const snapshot = getSnapshot(self); | ||
80 | writingConfig = true; | ||
81 | try { | ||
82 | configMtime = yield getEnv(self).configPersistence.writeConfig(snapshot); | ||
83 | lastSnapshotOnDisk = snapshot; | ||
84 | console.log('Wrote config'); | ||
85 | } finally { | ||
86 | writingConfig = false; | ||
87 | } | ||
88 | }), | ||
89 | initConfig: flow(function*() { | ||
90 | dispose(); | ||
91 | const foundConfig: boolean = yield actions.readConfig(); | ||
92 | if (!foundConfig) { | ||
93 | console.log('Creating new config file'); | ||
94 | try { | ||
95 | yield actions.writeConfig(); | ||
96 | } catch (err) { | ||
97 | console.error('Failed to initialize config'); | ||
98 | } | ||
99 | } | ||
100 | onSnapshotDisposer = onSnapshot(self, debounce((snapshot) => { | ||
101 | // We can compare snapshots by reference, since it is only recreated on store changes. | ||
102 | if (lastSnapshotOnDisk !== snapshot) { | ||
103 | actions.writeConfig().catch((err) => { | ||
104 | console.log('Failed to write config on config change', err); | ||
105 | }) | ||
106 | } | ||
107 | }, CONFIG_DEBOUNCE_TIME)); | ||
108 | watcherDisposer = getEnv(self).configPersistence.watchConfig(async (mtime) => { | ||
109 | if (!writingConfig && (configMtime === null || mtime > configMtime)) { | ||
110 | await actions.readConfig(); | ||
111 | } | ||
112 | }); | ||
113 | }), | ||
114 | }; | ||
115 | return actions; | ||
116 | }); | ||
117 | 34 | ||
118 | export interface Config extends Instance<typeof config> {} | 35 | export interface Config extends Instance<typeof config> {} |
119 | 36 | ||
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts index ee215a7..4b85c22 100644 --- a/packages/main/src/stores/MainStore.ts +++ b/packages/main/src/stores/MainStore.ts | |||
@@ -22,7 +22,6 @@ import { applySnapshot, Instance, types } from 'mobx-state-tree'; | |||
22 | import { BrowserViewBounds, emptySharedStore } from '@sophie/shared'; | 22 | import { BrowserViewBounds, emptySharedStore } from '@sophie/shared'; |
23 | 23 | ||
24 | import type { Config } from './Config'; | 24 | import type { Config } from './Config'; |
25 | import { MainEnv } from '../services/MainEnv'; | ||
26 | import { sharedStore } from './SharedStore'; | 25 | import { sharedStore } from './SharedStore'; |
27 | 26 | ||
28 | export const mainStore = types.model('MainStore', { | 27 | export const mainStore = types.model('MainStore', { |
@@ -46,14 +45,11 @@ export const mainStore = types.model('MainStore', { | |||
46 | } | 45 | } |
47 | })); | 46 | })); |
48 | 47 | ||
49 | export interface RootStore extends Instance<typeof mainStore> {} | 48 | export interface MainStore extends Instance<typeof mainStore> {} |
50 | 49 | ||
51 | export function createMainStore(env: MainEnv): RootStore { | 50 | export function createMainStore(): MainStore { |
52 | return mainStore.create( | 51 | return mainStore.create({ |
53 | { | 52 | browserViewBounds: {}, |
54 | browserViewBounds: {}, | 53 | shared: emptySharedStore, |
55 | shared: emptySharedStore, | 54 | }); |
56 | }, | ||
57 | env, | ||
58 | ); | ||
59 | } | 55 | } |