aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-26 17:29:58 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-26 17:42:27 +0100
commitfe5d0bb29e850e36693cae5594adf16f764aedf9 (patch)
treec3d7e050518ef658756972b789959a24943f3df0
parentfeat: Switch to json5 config format (diff)
downloadsophie-fe5d0bb29e850e36693cae5594adf16f764aedf9.tar.gz
sophie-fe5d0bb29e850e36693cae5594adf16f764aedf9.tar.zst
sophie-fe5d0bb29e850e36693cae5594adf16f764aedf9.zip
refactor: Config persistence architecture
The architecture in the main process is split into 3 main parts: * services: interfaces for services are injected into the stores through the MainEnv interface (for testability) * services/impl: electron-specific implementations of services * stores: the actions of the stores can invoke (asynchronous) services
-rw-r--r--packages/main/package.json6
-rw-r--r--packages/main/src/index.ts117
-rw-r--r--packages/main/src/services/ConfigPersistence.ts36
-rw-r--r--packages/main/src/services/MainEnv.ts38
-rw-r--r--packages/main/src/services/impl/ConfigPersistenceImpl.ts102
-rw-r--r--packages/main/src/stores/Config.ts96
-rw-r--r--packages/main/src/stores/MainStore.ts (renamed from packages/main/src/stores/RootStore.ts)23
-rw-r--r--yarn.lock8
8 files changed, 302 insertions, 124 deletions
diff --git a/packages/main/package.json b/packages/main/package.json
index 96cd52c..024fba8 100644
--- a/packages/main/package.json
+++ b/packages/main/package.json
@@ -14,11 +14,15 @@
14 "@sophie/shared": "workspace:*", 14 "@sophie/shared": "workspace:*",
15 "electron": "16.0.5", 15 "electron": "16.0.5",
16 "json5": "^2.2.0", 16 "json5": "^2.2.0",
17 "lodash": "^4.17.21",
17 "mobx": "^6.3.10", 18 "mobx": "^6.3.10",
18 "mobx-state-tree": "^5.1.0" 19 "mobx-state-tree": "^5.1.0",
20 "ms": "^2.1.3"
19 }, 21 },
20 "devDependencies": { 22 "devDependencies": {
21 "@types/electron-devtools-installer": "^2.2.1", 23 "@types/electron-devtools-installer": "^2.2.1",
24 "@types/lodash": "^4.14.178",
25 "@types/ms": "^0.7.31",
22 "@types/node": "^17.0.4", 26 "@types/node": "^17.0.4",
23 "electron-devtools-installer": "^3.2.0", 27 "electron-devtools-installer": "^3.2.0",
24 "rimraf": "^3.0.2", 28 "rimraf": "^3.0.2",
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 8055ce2..8297ff5 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -26,20 +26,9 @@ import {
26 nativeTheme, 26 nativeTheme,
27} from 'electron'; 27} from 'electron';
28import { readFileSync } from 'fs'; 28import { readFileSync } from 'fs';
29import { 29import { readFile } from 'fs/promises';
30 readFile,
31 stat,
32 watch,
33 writeFile,
34} from 'fs/promises';
35import JSON5 from 'json5';
36import { autorun } from 'mobx'; 30import { autorun } from 'mobx';
37import { 31import { getSnapshot, onPatch } from 'mobx-state-tree';
38 applySnapshot,
39 getSnapshot,
40 onPatch,
41 onSnapshot,
42} from 'mobx-state-tree';
43import { join } from 'path'; 32import { join } from 'path';
44import { 33import {
45 ServiceToMainIpcMessage, 34 ServiceToMainIpcMessage,
@@ -58,8 +47,8 @@ import {
58 installDevToolsExtensions, 47 installDevToolsExtensions,
59 openDevToolsWhenReady, 48 openDevToolsWhenReady,
60} from './devTools'; 49} from './devTools';
61import { ConfigSnapshotOut } from './stores/Config'; 50import { ConfigPersistenceImpl } from './services/impl/ConfigPersistenceImpl';
62import { createRootStore } from './stores/RootStore'; 51import { createMainStore } from './stores/MainStore';
63 52
64const isDevelopment = import.meta.env.MODE === 'development'; 53const isDevelopment = import.meta.env.MODE === 'development';
65 54
@@ -116,7 +105,12 @@ if (isDevelopment) {
116 105
117let mainWindow: BrowserWindow | null = null; 106let mainWindow: BrowserWindow | null = null;
118 107
119const store = createRootStore(); 108const store = createMainStore({
109 configPersistence: new ConfigPersistenceImpl(
110 app.getPath('userData'),
111 'config.json5',
112 ),
113});
120 114
121autorun(() => { 115autorun(() => {
122 nativeTheme.themeSource = store.config.themeSource; 116 nativeTheme.themeSource = store.config.themeSource;
@@ -127,96 +121,7 @@ nativeTheme.on('updated', () => {
127 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); 121 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
128}); 122});
129 123
130const userDataDir = app.getPath('userData'); 124store.config.initConfig();
131const configFileName = 'config.json5';
132const configPath = join(userDataDir, configFileName);
133let loadingConfig = false;
134let savingConfig = false;
135let configMtime: Date | null = null;
136
137async function loadConfig(): Promise<void> {
138 let configStr: string;
139 try {
140 configStr = await readFile(configPath, 'utf8');
141 } catch (err) {
142 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
143 console.log('Creating config', configPath);
144 return saveConfig(getSnapshot(store.config));
145 }
146 throw err;
147 }
148 let configSnapshot: unknown;
149 try {
150 configSnapshot = JSON5.parse(configStr);
151 } catch (err) {
152 console.error('Invalid config file', configPath, err);
153 return;
154 }
155 loadingConfig = true;
156 try {
157 applySnapshot(store.config, configSnapshot);
158 } catch (err) {
159 console.error('Falied to apply snaphot', configPath, configSnapshot, err);
160 } finally {
161 loadingConfig = false;
162 }
163}
164
165async function saveConfig(configSnapshot: ConfigSnapshotOut): Promise<void> {
166 const configJson = JSON5.stringify(configSnapshot, {
167 space: 2,
168 });
169 savingConfig = true;
170 try {
171 await writeFile(configPath, configJson, 'utf8');
172 const stats = await stat(configPath);
173 configMtime = stats.mtime;
174 } finally {
175 savingConfig = false;
176 }
177 console.log('Wrote config', configPath);
178}
179
180onSnapshot(store.config, (snapshot) => {
181 if (!loadingConfig) {
182 saveConfig(snapshot).catch((err) => {
183 console.error('Failed to save config', configPath, err);
184 });
185 }
186});
187
188async function watchConfig(): Promise<void> {
189 const configWatcher = watch(userDataDir, {
190 persistent: false,
191 });
192 for await (const { eventType, filename } of configWatcher) {
193 if (eventType !== 'change'
194 && (filename !== configFileName || filename !== null)) {
195 continue;
196 }
197 let mtime: Date;
198 try {
199 const stats = await stat(configPath);
200 mtime = stats.mtime;
201 } catch (err) {
202 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
203 continue;
204 }
205 throw err;
206 }
207 if (!savingConfig && (configMtime === null || mtime > configMtime)) {
208 await loadConfig();
209 configMtime = mtime;
210 console.log('Reloaded config', configPath);
211 }
212 }
213}
214
215loadConfig().catch((err) => {
216 console.error('Failed to load config', configPath, err);
217}).then(watchConfig).catch((err) => {
218 console.error('Error when watching for config changes', configPath, err);
219});
220 125
221const rendererBaseUrl = getResourceUrl('../renderer/'); 126const rendererBaseUrl = getResourceUrl('../renderer/');
222function shouldCancelMainWindowRequest(url: string, method: string): boolean { 127function shouldCancelMainWindowRequest(url: string, method: string): boolean {
diff --git a/packages/main/src/services/ConfigPersistence.ts b/packages/main/src/services/ConfigPersistence.ts
new file mode 100644
index 0000000..f9a82de
--- /dev/null
+++ b/packages/main/src/services/ConfigPersistence.ts
@@ -0,0 +1,36 @@
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 { IDisposer } from 'mobx-state-tree';
22import ms from 'ms';
23
24import type { ConfigSnapshotOut } from '../stores/Config';
25
26export const CONFIG_DEBOUNCE_TIME: number = ms('1s');
27
28export type ReadConfigResult = { found: true; data: unknown; } | { found: false; };
29
30export interface ConfigPersistence {
31 readConfig(): Promise<ReadConfigResult>;
32
33 writeConfig(configSnapshot: ConfigSnapshotOut): Promise<Date>;
34
35 watchConfig(callback: (mtime: Date) => Promise<void>): IDisposer;
36}
diff --git a/packages/main/src/services/MainEnv.ts b/packages/main/src/services/MainEnv.ts
new file mode 100644
index 0000000..23ee9a1
--- /dev/null
+++ b/packages/main/src/services/MainEnv.ts
@@ -0,0 +1,38 @@
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 { IAnyStateTreeNode, getEnv as getAnyEnv } from 'mobx-state-tree';
22
23import type { ConfigPersistence } from './ConfigPersistence';
24
25export interface MainEnv {
26 configPersistence: ConfigPersistence;
27}
28
29/**
30 * Gets a well-typed environment from `model`.
31 *
32 * Only useable inside state trees created by `createAndConnectRootStore`.
33 *
34 * @param model The state tree node.
35 */
36export function getEnv(model: IAnyStateTreeNode): MainEnv {
37 return getAnyEnv<MainEnv>(model);
38}
diff --git a/packages/main/src/services/impl/ConfigPersistenceImpl.ts b/packages/main/src/services/impl/ConfigPersistenceImpl.ts
new file mode 100644
index 0000000..097ab74
--- /dev/null
+++ b/packages/main/src/services/impl/ConfigPersistenceImpl.ts
@@ -0,0 +1,102 @@
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 { watch } from 'fs';
22import { readFile, stat, writeFile } from 'fs/promises';
23import JSON5 from 'json5';
24import { throttle } from 'lodash';
25import { IDisposer } from 'mobx-state-tree';
26import { join } from 'path';
27
28import { CONFIG_DEBOUNCE_TIME, ConfigPersistence, ReadConfigResult } from '../ConfigPersistence';
29import type { ConfigSnapshotOut } from '../../stores/Config';
30
31export class ConfigPersistenceImpl implements ConfigPersistence {
32 readonly #userDataDir: string;
33
34 readonly #configFileName: string;
35
36 readonly #configFilePath: string;
37
38 constructor(
39 userDataDir: string,
40 configFileName: string,
41 ) {
42 this.#userDataDir = userDataDir;
43 this.#configFileName = configFileName;
44 this.#configFilePath = join(this.#userDataDir, this.#configFileName);
45 }
46
47 async readConfig(): Promise<ReadConfigResult> {
48 let configStr;
49 try {
50 configStr = await readFile(this.#configFilePath, 'utf8');
51 } catch (err) {
52 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
53 return { found: false };
54 }
55 throw err;
56 }
57 return {
58 found: true,
59 data: JSON5.parse(configStr),
60 };
61 }
62
63 async writeConfig(configSnapshot: ConfigSnapshotOut): Promise<Date> {
64 const configJson = JSON5.stringify(configSnapshot, {
65 space: 2,
66 });
67 await writeFile(this.#configFilePath, configJson, 'utf8');
68 const { mtime } = await stat(this.#configFilePath);
69 return mtime;
70 }
71
72 watchConfig(callback: (mtime: Date) => Promise<void>): IDisposer {
73 const configChanged = throttle(async () => {
74 let mtime: Date;
75 try {
76 const stats = await stat(this.#configFilePath);
77 mtime = stats.mtime;
78 } catch (err) {
79 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
80 return;
81 }
82 throw err;
83 }
84 return callback(mtime);
85 }, CONFIG_DEBOUNCE_TIME);
86
87 const watcher = watch(this.#userDataDir, {
88 persistent: false,
89 });
90
91 watcher.on('change', (eventType, filename) => {
92 if (eventType === 'change'
93 && (filename === this.#configFileName || filename === null)) {
94 configChanged()?.catch((err) => {
95 console.log('Unhandled error while listening for config changes', err);
96 });
97 }
98 });
99
100 return () => watcher.close();
101 }
102}
diff --git a/packages/main/src/stores/Config.ts b/packages/main/src/stores/Config.ts
index 483a491..eb53635 100644
--- a/packages/main/src/stores/Config.ts
+++ b/packages/main/src/stores/Config.ts
@@ -18,15 +18,103 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { Instance } from 'mobx-state-tree'; 21import { debounce } from 'lodash';
22import { config as originalConfig, ThemeSource } from '@sophie/shared'; 22import {
23 applySnapshot,
24 flow,
25 getSnapshot,
26 IDisposer,
27 Instance,
28 onSnapshot,
29} from 'mobx-state-tree';
30import {
31 config as originalConfig,
32 ConfigSnapshotIn,
33 ConfigSnapshotOut,
34 ThemeSource,
35} from '@sophie/shared';
23 36
24export type { ConfigSnapshotIn, ConfigSnapshotOut } from '@sophie/shared'; 37import { CONFIG_DEBOUNCE_TIME, ReadConfigResult } from '../services/ConfigPersistence';
38import { getEnv } from '../services/MainEnv';
25 39
26export const config = originalConfig.actions((self) => ({ 40export const config = originalConfig.actions((self) => ({
27 setThemeSource(mode: ThemeSource) { 41 setThemeSource(mode: ThemeSource) {
28 self.themeSource = mode; 42 self.themeSource = mode;
29 }, 43 },
30})); 44})).actions((self) => {
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});
31 117
32export interface Config extends Instance<typeof config> {} 118export interface Config extends Instance<typeof config> {}
119
120export type { ConfigSnapshotIn, ConfigSnapshotOut };
diff --git a/packages/main/src/stores/RootStore.ts b/packages/main/src/stores/MainStore.ts
index 31e2b71..ee215a7 100644
--- a/packages/main/src/stores/RootStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -19,15 +19,13 @@
19 */ 19 */
20 20
21import { applySnapshot, Instance, types } from 'mobx-state-tree'; 21import { applySnapshot, Instance, types } from 'mobx-state-tree';
22import { 22import { BrowserViewBounds, emptySharedStore } from '@sophie/shared';
23 BrowserViewBounds,
24 emptySharedStore,
25} from '@sophie/shared';
26 23
27import type { Config } from './Config'; 24import type { Config } from './Config';
25import { MainEnv } from '../services/MainEnv';
28import { sharedStore } from './SharedStore'; 26import { sharedStore } from './SharedStore';
29 27
30export const rootStore = types.model('RootStore', { 28export const mainStore = types.model('MainStore', {
31 browserViewBounds: types.model('BrowserViewBounds', { 29 browserViewBounds: types.model('BrowserViewBounds', {
32 x: 0, 30 x: 0,
33 y: 0, 31 y: 0,
@@ -48,11 +46,14 @@ export const rootStore = types.model('RootStore', {
48 } 46 }
49})); 47}));
50 48
51export interface RootStore extends Instance<typeof rootStore> {} 49export interface RootStore extends Instance<typeof mainStore> {}
52 50
53export function createRootStore(): RootStore { 51export function createMainStore(env: MainEnv): RootStore {
54 return rootStore.create({ 52 return mainStore.create(
55 browserViewBounds: {}, 53 {
56 shared: emptySharedStore, 54 browserViewBounds: {},
57 }); 55 shared: emptySharedStore,
56 },
57 env,
58 );
58} 59}
diff --git a/yarn.lock b/yarn.lock
index 8e929b7..64a8f07 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -838,12 +838,16 @@ __metadata:
838 "@sophie/service-shared": "workspace:*" 838 "@sophie/service-shared": "workspace:*"
839 "@sophie/shared": "workspace:*" 839 "@sophie/shared": "workspace:*"
840 "@types/electron-devtools-installer": ^2.2.1 840 "@types/electron-devtools-installer": ^2.2.1
841 "@types/lodash": ^4.14.178
842 "@types/ms": ^0.7.31
841 "@types/node": ^17.0.4 843 "@types/node": ^17.0.4
842 electron: 16.0.5 844 electron: 16.0.5
843 electron-devtools-installer: ^3.2.0 845 electron-devtools-installer: ^3.2.0
844 json5: ^2.2.0 846 json5: ^2.2.0
847 lodash: ^4.17.21
845 mobx: ^6.3.10 848 mobx: ^6.3.10
846 mobx-state-tree: ^5.1.0 849 mobx-state-tree: ^5.1.0
850 ms: ^2.1.3
847 rimraf: ^3.0.2 851 rimraf: ^3.0.2
848 typescript: ^4.5.4 852 typescript: ^4.5.4
849 vite: ^2.7.6 853 vite: ^2.7.6
@@ -1027,7 +1031,7 @@ __metadata:
1027 languageName: node 1031 languageName: node
1028 linkType: hard 1032 linkType: hard
1029 1033
1030"@types/ms@npm:*": 1034"@types/ms@npm:*, @types/ms@npm:^0.7.31":
1031 version: 0.7.31 1035 version: 0.7.31
1032 resolution: "@types/ms@npm:0.7.31" 1036 resolution: "@types/ms@npm:0.7.31"
1033 checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da 1037 checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da
@@ -4088,7 +4092,7 @@ __metadata:
4088 languageName: node 4092 languageName: node
4089 linkType: hard 4093 linkType: hard
4090 4094
4091"ms@npm:^2.0.0": 4095"ms@npm:^2.0.0, ms@npm:^2.1.3":
4092 version: 2.1.3 4096 version: 2.1.3
4093 resolution: "ms@npm:2.1.3" 4097 resolution: "ms@npm:2.1.3"
4094 checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d 4098 checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d