aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-26 19:17:23 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-26 19:17:23 +0100
commite56cdad02c00adf3b779d9de62d460e78be204a6 (patch)
tree638d8ac94380b3d675f13c6eabc1e5ee51126342
parentrefactor: Config persistence architecture (diff)
downloadsophie-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)
-rw-r--r--packages/main/src/controllers/ConfigController.ts124
-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.ts29
-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.ts87
-rw-r--r--packages/main/src/stores/MainStore.ts16
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
21import { debounce } from 'lodash';
22import {
23 applySnapshot,
24 getSnapshot,
25 IDisposer,
26 onSnapshot,
27} from 'mobx-state-tree';
28import ms from 'ms';
29
30import { ConfigPersistenceService } from '../services/ConfigPersistenceService';
31import { Config, ConfigSnapshotOut } from '../stores/Config';
32
33const DEFAULT_DEBOUNCE_TIME = ms('1s');
34
35class 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
116export 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
21import { IAnyStateTreeNode, getEnv as getAnyEnv } from 'mobx-state-tree'; 21import { autorun } from 'mobx';
22import type { IDisposer } from 'mobx-state-tree';
22 23
23import type { ConfigPersistence } from './ConfigPersistence'; 24import type { NativeThemeService } from '../services/NativeThemeService';
25import type { MainStore } from '../stores/MainStore';
24 26
25export interface MainEnv { 27export 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 };
36export 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';
28import { readFileSync } from 'fs'; 27import { readFileSync } from 'fs';
29import { readFile } from 'fs/promises'; 28import { readFile } from 'fs/promises';
@@ -47,7 +46,10 @@ import {
47 installDevToolsExtensions, 46 installDevToolsExtensions,
48 openDevToolsWhenReady, 47 openDevToolsWhenReady,
49} from './devTools'; 48} from './devTools';
50import { ConfigPersistenceImpl } from './services/impl/ConfigPersistenceImpl'; 49import { initConfig } from './controllers/ConfigController';
50import { initNativeTheme } from './controllers/NativeThemeController';
51import { ConfigPersistenceService } from './services/ConfigPersistenceService';
52import { NativeThemeService } from './services/NativeThemeService';
51import { createMainStore } from './stores/MainStore'; 53import { createMainStore } from './stores/MainStore';
52 54
53const isDevelopment = import.meta.env.MODE === 'development'; 55const isDevelopment = import.meta.env.MODE === 'development';
@@ -105,23 +107,14 @@ if (isDevelopment) {
105 107
106let mainWindow: BrowserWindow | null = null; 108let mainWindow: BrowserWindow | null = null;
107 109
108const store = createMainStore({ 110const store = createMainStore();
109 configPersistence: new ConfigPersistenceImpl(
110 app.getPath('userData'),
111 'config.json5',
112 ),
113});
114
115autorun(() => {
116 nativeTheme.themeSource = store.config.themeSource;
117});
118
119store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
120nativeTheme.on('updated', () => {
121 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
122});
123 111
124store.config.initConfig(); 112initConfig(
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
126const rendererBaseUrl = getResourceUrl('../renderer/'); 119const rendererBaseUrl = getResourceUrl('../renderer/');
127function shouldCancelMainWindowRequest(url: string, method: string): boolean { 120function 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';
25import { IDisposer } from 'mobx-state-tree'; 25import { IDisposer } from 'mobx-state-tree';
26import { join } from 'path'; 26import { join } from 'path';
27 27
28import { CONFIG_DEBOUNCE_TIME, ConfigPersistence, ReadConfigResult } from '../ConfigPersistence'; 28import type { ConfigSnapshotOut } from '../stores/Config';
29import type { ConfigSnapshotOut } from '../../stores/Config';
30 29
31export class ConfigPersistenceImpl implements ConfigPersistence { 30export type ReadConfigResult = { found: true; data: unknown; } | { found: false; };
31
32export 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
21import { IDisposer } from 'mobx-state-tree'; 21import { nativeTheme } from 'electron';
22import ms from 'ms'; 22import type { IDisposer } from 'mobx-state-tree';
23import type { ThemeSource } from '@sophie/shared';
23 24
24import type { ConfigSnapshotOut } from '../stores/Config'; 25export class NativeThemeService {
26 setThemeSource(themeSource: ThemeSource): void {
27 nativeTheme.themeSource = themeSource;
28 }
25 29
26export const CONFIG_DEBOUNCE_TIME: number = ms('1s'); 30 onShouldUseDarkColorsUpdated(callback: (shouldUseDarkColors: boolean) => void): IDisposer {
27 31 const wrappedCallback = () => {
28export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; 32 callback(nativeTheme.shouldUseDarkColors);
29 33 };
30export 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
21import { debounce } from 'lodash'; 21import { Instance } from 'mobx-state-tree';
22import {
23 applySnapshot,
24 flow,
25 getSnapshot,
26 IDisposer,
27 Instance,
28 onSnapshot,
29} from 'mobx-state-tree';
30import { 22import {
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
37import { CONFIG_DEBOUNCE_TIME, ReadConfigResult } from '../services/ConfigPersistence';
38import { getEnv } from '../services/MainEnv';
39
40export const config = originalConfig.actions((self) => ({ 29export 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
118export interface Config extends Instance<typeof config> {} 35export 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';
22import { BrowserViewBounds, emptySharedStore } from '@sophie/shared'; 22import { BrowserViewBounds, emptySharedStore } from '@sophie/shared';
23 23
24import type { Config } from './Config'; 24import type { Config } from './Config';
25import { MainEnv } from '../services/MainEnv';
26import { sharedStore } from './SharedStore'; 25import { sharedStore } from './SharedStore';
27 26
28export const mainStore = types.model('MainStore', { 27export const mainStore = types.model('MainStore', {
@@ -46,14 +45,11 @@ export const mainStore = types.model('MainStore', {
46 } 45 }
47})); 46}));
48 47
49export interface RootStore extends Instance<typeof mainStore> {} 48export interface MainStore extends Instance<typeof mainStore> {}
50 49
51export function createMainStore(env: MainEnv): RootStore { 50export 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}