aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-27 19:41:46 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-27 19:51:14 +0100
commitf5f27eddc93314e8e10ab96c7bdb5c626142a1d3 (patch)
tree99ce5eca8c2bf3590612b24e33d4fe727bd240ab
parentfix: Allow the shared store listener to re-register in dev mode (diff)
downloadsophie-f5f27eddc93314e8e10ab96c7bdb5c626142a1d3.tar.gz
sophie-f5f27eddc93314e8e10ab96c7bdb5c626142a1d3.tar.zst
sophie-f5f27eddc93314e8e10ab96c7bdb5c626142a1d3.zip
refactor: Inversion of control with typed-inject
-rw-r--r--packages/main/package.json3
-rw-r--r--packages/main/src/controllers/ConfigController.ts105
-rw-r--r--packages/main/src/controllers/MainController.ts (renamed from packages/main/src/services/NativeThemeService.ts)26
-rw-r--r--packages/main/src/controllers/NativeThemeController.ts37
-rw-r--r--packages/main/src/index.ts15
-rw-r--r--packages/main/src/injector.ts39
-rw-r--r--packages/main/src/services/ConfigPersistenceService.ts53
-rw-r--r--packages/shared/src/stores/SharedStore.ts2
-rw-r--r--yarn.lock8
9 files changed, 166 insertions, 122 deletions
diff --git a/packages/main/package.json b/packages/main/package.json
index 48268fb..55bc663 100644
--- a/packages/main/package.json
+++ b/packages/main/package.json
@@ -17,7 +17,8 @@
17 "lodash": "^4.17.21", 17 "lodash": "^4.17.21",
18 "mobx": "^6.3.10", 18 "mobx": "^6.3.10",
19 "mobx-state-tree": "^5.1.0", 19 "mobx-state-tree": "^5.1.0",
20 "ms": "^2.1.3" 20 "ms": "^2.1.3",
21 "typed-inject": "^3.0.1"
21 }, 22 },
22 "devDependencies": { 23 "devDependencies": {
23 "@types/electron-devtools-installer": "^2.2.1", 24 "@types/electron-devtools-installer": "^2.2.1",
diff --git a/packages/main/src/controllers/ConfigController.ts b/packages/main/src/controllers/ConfigController.ts
index 6690548..a28746c 100644
--- a/packages/main/src/controllers/ConfigController.ts
+++ b/packages/main/src/controllers/ConfigController.ts
@@ -25,56 +25,62 @@ import {
25 IDisposer, 25 IDisposer,
26 onSnapshot, 26 onSnapshot,
27} from 'mobx-state-tree'; 27} from 'mobx-state-tree';
28import ms from 'ms';
29 28
30import { ConfigPersistenceService } from '../services/ConfigPersistenceService'; 29import type { ConfigPersistenceService } from '../services/ConfigPersistenceService';
31import { Config, ConfigSnapshotOut } from '../stores/Config'; 30import type { Config, ConfigSnapshotOut } from '../stores/Config';
32 31
33const DEFAULT_DEBOUNCE_TIME = ms('1s'); 32export class ConfigController {
33 static inject = ['configPersistenceService', 'configDebounceTime'] as const;
34 34
35class ConfigController { 35 private config: Config | null = null;
36 readonly #config: Config;
37 36
38 readonly #persistenceService: ConfigPersistenceService; 37 private onSnapshotDisposer: IDisposer | null = null;
39 38
40 readonly #onSnapshotDisposer: IDisposer; 39 private lastSnapshotOnDisk: ConfigSnapshotOut | null = null;
41 40
42 readonly #watcherDisposer: IDisposer; 41 private writingConfig: boolean = false;
43 42
44 #lastSnapshotOnDisk: ConfigSnapshotOut | null = null; 43 constructor(
44 private readonly persistenceService: ConfigPersistenceService,
45 private readonly debounceTime: number,
46 ) {
47 }
45 48
46 #writingConfig: boolean = false; 49 async initConfig(config: Config): Promise<void> {
50 this.config = config;
47 51
48 #configMTime: Date | null = null; 52 const foundConfig: boolean = await this.readConfig();
53 if (!foundConfig) {
54 console.log('Creating new config file');
55 try {
56 await this.writeConfig();
57 } catch (err) {
58 console.error('Failed to initialize config');
59 }
60 }
49 61
50 constructor( 62 this.onSnapshotDisposer = onSnapshot(this.config, debounce((snapshot) => {
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. 63 // We can compare snapshots by reference, since it is only recreated on store changes.
59 if (this.#lastSnapshotOnDisk !== snapshot) { 64 if (this.lastSnapshotOnDisk !== snapshot) {
60 this.#writeConfig().catch((err) => { 65 this.writeConfig().catch((err) => {
61 console.log('Failed to write config on config change', err); 66 console.log('Failed to write config on config change', err);
62 }) 67 })
63 } 68 }
64 }, debounceTime)); 69 }, this.debounceTime));
65 this.#watcherDisposer = this.#persistenceService.watchConfig(async (mtime) => { 70
66 if (!this.#writingConfig && (this.#configMTime === null || mtime > this.#configMTime)) { 71 this.persistenceService.watchConfig(async () => {
67 await this.#readConfig(); 72 if (!this.writingConfig) {
73 await this.readConfig();
68 } 74 }
69 }, debounceTime); 75 }, this.debounceTime);
70 } 76 }
71 77
72 async #readConfig(): Promise<boolean> { 78 private async readConfig(): Promise<boolean> {
73 const result = await this.#persistenceService.readConfig(); 79 const result = await this.persistenceService.readConfig();
74 if (result.found) { 80 if (result.found) {
75 try { 81 try {
76 applySnapshot(this.#config, result.data); 82 applySnapshot(this.config!, result.data);
77 this.#lastSnapshotOnDisk = getSnapshot(this.#config); 83 this.lastSnapshotOnDisk = getSnapshot(this.config!);
78 console.log('Loaded config'); 84 console.log('Loaded config');
79 } catch (err) { 85 } catch (err) {
80 console.error('Failed to read config', result.data, err); 86 console.error('Failed to read config', result.data, err);
@@ -83,42 +89,19 @@ class ConfigController {
83 return result.found; 89 return result.found;
84 } 90 }
85 91
86 async #writeConfig(): Promise<void> { 92 private async writeConfig(): Promise<void> {
87 const snapshot = getSnapshot(this.#config); 93 const snapshot = getSnapshot(this.config!);
88 this.#writingConfig = true; 94 this.writingConfig = true;
89 try { 95 try {
90 this.#configMTime = await this.#persistenceService.writeConfig(snapshot); 96 await this.persistenceService.writeConfig(snapshot);
91 this.#lastSnapshotOnDisk = snapshot; 97 this.lastSnapshotOnDisk = snapshot;
92 console.log('Wrote config'); 98 console.log('Wrote config');
93 } finally { 99 } finally {
94 this.#writingConfig = false; 100 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 } 101 }
108 } 102 }
109 103
110 dispose(): void { 104 dispose(): void {
111 this.#onSnapshotDisposer(); 105 this.onSnapshotDisposer?.();
112 this.#watcherDisposer();
113 } 106 }
114} 107}
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/NativeThemeService.ts b/packages/main/src/controllers/MainController.ts
index 7a26c3c..6b97330 100644
--- a/packages/main/src/services/NativeThemeService.ts
+++ b/packages/main/src/controllers/MainController.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 { nativeTheme } from 'electron'; 21import type { ConfigController } from './ConfigController';
22import type { IDisposer } from 'mobx-state-tree'; 22import type { NativeThemeController } from './NativeThemeController';
23import type { ThemeSource } from '@sophie/shared'; 23import type { MainStore } from '../stores/MainStore';
24 24
25export class NativeThemeService { 25export class MainController {
26 setThemeSource(themeSource: ThemeSource): void { 26 static inject = ['configController', 'nativeThemeController'] as const;
27 nativeTheme.themeSource = themeSource; 27
28 constructor(
29 private readonly configController: ConfigController,
30 private readonly nativeThemeController: NativeThemeController,
31 ) {
28 } 32 }
29 33
30 onShouldUseDarkColorsUpdated(callback: (shouldUseDarkColors: boolean) => void): IDisposer { 34 async connect(store: MainStore): Promise<void> {
31 const wrappedCallback = () => { 35 await this.configController.initConfig(store.config);
32 callback(nativeTheme.shouldUseDarkColors); 36 this.nativeThemeController.connect(store);
33 };
34 wrappedCallback();
35 nativeTheme.on('updated', wrappedCallback);
36 return () => nativeTheme.off('updated', wrappedCallback);
37 } 37 }
38} 38}
diff --git a/packages/main/src/controllers/NativeThemeController.ts b/packages/main/src/controllers/NativeThemeController.ts
index 07a3292..a50d41e 100644
--- a/packages/main/src/controllers/NativeThemeController.ts
+++ b/packages/main/src/controllers/NativeThemeController.ts
@@ -18,21 +18,32 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { nativeTheme } from 'electron';
21import { autorun } from 'mobx'; 22import { autorun } from 'mobx';
22import type { IDisposer } from 'mobx-state-tree'; 23import { IDisposer } from 'mobx-state-tree';
23 24
24import type { NativeThemeService } from '../services/NativeThemeService';
25import type { MainStore } from '../stores/MainStore'; 25import type { MainStore } from '../stores/MainStore';
26 26
27export function initNativeTheme(store: MainStore, service: NativeThemeService): IDisposer { 27export class NativeThemeController {
28 const themeSourceReactionDisposer = autorun(() => { 28 private autorunDisposer: IDisposer | null = null;
29 service.setThemeSource(store.config.themeSource); 29
30 }); 30 private shouldUseDarkColorsListener: (() => void) | null = null;
31 const onShouldUseDarkColorsUpdatedDisposer = service.onShouldUseDarkColorsUpdated( 31
32 store.setShouldUseDarkColors, 32 connect(store: MainStore): void {
33 ); 33 this.autorunDisposer = autorun(() => {
34 return () => { 34 nativeTheme.themeSource = store.config.themeSource;
35 onShouldUseDarkColorsUpdatedDisposer(); 35 });
36 themeSourceReactionDisposer(); 36 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
37 }; 37 this.shouldUseDarkColorsListener = () => {
38 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
39 };
40 nativeTheme.on('updated', this.shouldUseDarkColorsListener);
41 }
42
43 dispose(): void {
44 if (this.shouldUseDarkColorsListener !== null) {
45 nativeTheme.off('updated', this.shouldUseDarkColorsListener);
46 }
47 this.autorunDisposer?.();
48 }
38} 49}
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 7aa3ee9..8eb0803 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -45,10 +45,7 @@ import {
45 installDevToolsExtensions, 45 installDevToolsExtensions,
46 openDevToolsWhenReady, 46 openDevToolsWhenReady,
47} from './devTools'; 47} from './devTools';
48import { initConfig } from './controllers/ConfigController'; 48import { injector } from './injector';
49import { initNativeTheme } from './controllers/NativeThemeController';
50import { ConfigPersistenceService } from './services/ConfigPersistenceService';
51import { NativeThemeService } from './services/NativeThemeService';
52import { createMainStore } from './stores/MainStore'; 49import { createMainStore } from './stores/MainStore';
53 50
54const isDevelopment = import.meta.env.MODE === 'development'; 51const isDevelopment = import.meta.env.MODE === 'development';
@@ -108,12 +105,10 @@ let mainWindow: BrowserWindow | null = null;
108 105
109const store = createMainStore(); 106const store = createMainStore();
110 107
111initConfig( 108const controller = injector.resolve('mainController');
112 store.config, 109controller.connect(store).catch((err) => {
113 new ConfigPersistenceService(app.getPath('userData'), 'config.json5'), 110 console.log('Error while initializing app', err);
114).then(() => { 111});
115 initNativeTheme(store, new NativeThemeService());
116}).catch((err) => console.error(err));
117 112
118const rendererBaseUrl = getResourceUrl('../renderer/'); 113const rendererBaseUrl = getResourceUrl('../renderer/');
119function shouldCancelMainWindowRequest(url: string, method: string): boolean { 114function shouldCancelMainWindowRequest(url: string, method: string): boolean {
diff --git a/packages/main/src/injector.ts b/packages/main/src/injector.ts
new file mode 100644
index 0000000..2c05c85
--- /dev/null
+++ b/packages/main/src/injector.ts
@@ -0,0 +1,39 @@
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 { app } from 'electron';
22import ms from 'ms';
23import { createInjector, Injector } from 'typed-inject';
24
25import { ConfigController } from './controllers/ConfigController';
26import { MainController } from './controllers/MainController';
27import { NativeThemeController } from './controllers/NativeThemeController';
28import { ConfigPersistenceService } from './services/ConfigPersistenceService';
29
30export const injector: Injector<{
31 'mainController': MainController,
32}> = createInjector()
33 .provideFactory('userDataDir', () => app.getPath('userData'))
34 .provideValue('configFileName', 'config.json5')
35 .provideValue('configDebounceTime', ms('1s'))
36 .provideClass('configPersistenceService', ConfigPersistenceService)
37 .provideClass('configController', ConfigController)
38 .provideClass('nativeThemeController', NativeThemeController)
39 .provideClass('mainController', MainController);
diff --git a/packages/main/src/services/ConfigPersistenceService.ts b/packages/main/src/services/ConfigPersistenceService.ts
index 85b0088..61123d9 100644
--- a/packages/main/src/services/ConfigPersistenceService.ts
+++ b/packages/main/src/services/ConfigPersistenceService.ts
@@ -17,12 +17,10 @@
17 * 17 *
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20import { FSWatcher, watch } from 'fs';
21import { watch } from 'fs';
22import { readFile, stat, writeFile } from 'fs/promises'; 21import { readFile, stat, writeFile } from 'fs/promises';
23import JSON5 from 'json5'; 22import JSON5 from 'json5';
24import { throttle } from 'lodash'; 23import { throttle } from 'lodash';
25import { IDisposer } from 'mobx-state-tree';
26import { join } from 'path'; 24import { join } from 'path';
27 25
28import type { ConfigSnapshotOut } from '../stores/Config'; 26import type { ConfigSnapshotOut } from '../stores/Config';
@@ -30,25 +28,26 @@ import type { ConfigSnapshotOut } from '../stores/Config';
30export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; 28export type ReadConfigResult = { found: true; data: unknown; } | { found: false; };
31 29
32export class ConfigPersistenceService { 30export class ConfigPersistenceService {
33 readonly #userDataDir: string; 31 static inject = ['userDataDir', 'configFileName'] as const;
32
33 private readonly configFilePath: string;
34 34
35 readonly #configFileName: string; 35 private timeLastWritten: Date | null = null;
36 36
37 readonly #configFilePath: string; 37 private watcher: FSWatcher | null = null;
38 38
39 constructor( 39 constructor(
40 userDataDir: string, 40 private readonly userDataDir: string,
41 configFileName: string, 41 private readonly configFileName: string,
42 ) { 42 ) {
43 this.#userDataDir = userDataDir; 43 this.configFileName = configFileName;
44 this.#configFileName = configFileName; 44 this.configFilePath = join(this.userDataDir, this.configFileName);
45 this.#configFilePath = join(this.#userDataDir, this.#configFileName);
46 } 45 }
47 46
48 async readConfig(): Promise<ReadConfigResult> { 47 async readConfig(): Promise<ReadConfigResult> {
49 let configStr; 48 let configStr;
50 try { 49 try {
51 configStr = await readFile(this.#configFilePath, 'utf8'); 50 configStr = await readFile(this.configFilePath, 'utf8');
52 } catch (err) { 51 } catch (err) {
53 if ((err as NodeJS.ErrnoException).code === 'ENOENT') { 52 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
54 return { found: false }; 53 return { found: false };
@@ -61,20 +60,24 @@ export class ConfigPersistenceService {
61 }; 60 };
62 } 61 }
63 62
64 async writeConfig(configSnapshot: ConfigSnapshotOut): Promise<Date> { 63 async writeConfig(configSnapshot: ConfigSnapshotOut): Promise<void> {
65 const configJson = JSON5.stringify(configSnapshot, { 64 const configJson = JSON5.stringify(configSnapshot, {
66 space: 2, 65 space: 2,
67 }); 66 });
68 await writeFile(this.#configFilePath, configJson, 'utf8'); 67 await writeFile(this.configFilePath, configJson, 'utf8');
69 const { mtime } = await stat(this.#configFilePath); 68 const stats = await stat(this.configFilePath);
70 return mtime; 69 this.timeLastWritten = stats.mtime;
71 } 70 }
72 71
73 watchConfig(callback: (mtime: Date) => Promise<void>, throttleMs: number): IDisposer { 72 watchConfig(callback: () => Promise<void>, throttleMs: number): void {
73 if (this.watcher !== null) {
74 throw new Error('watchConfig was already called');
75 }
76
74 const configChanged = throttle(async () => { 77 const configChanged = throttle(async () => {
75 let mtime: Date; 78 let mtime: Date;
76 try { 79 try {
77 const stats = await stat(this.#configFilePath); 80 const stats = await stat(this.configFilePath);
78 mtime = stats.mtime; 81 mtime = stats.mtime;
79 } catch (err) { 82 } catch (err) {
80 if ((err as NodeJS.ErrnoException).code === 'ENOENT') { 83 if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -82,22 +85,26 @@ export class ConfigPersistenceService {
82 } 85 }
83 throw err; 86 throw err;
84 } 87 }
85 return callback(mtime); 88 if (this.timeLastWritten === null || mtime > this.timeLastWritten) {
89 return callback();
90 }
86 }, throttleMs); 91 }, throttleMs);
87 92
88 const watcher = watch(this.#userDataDir, { 93 this.watcher = watch(this.userDataDir, {
89 persistent: false, 94 persistent: false,
90 }); 95 });
91 96
92 watcher.on('change', (eventType, filename) => { 97 this.watcher.on('change', (eventType, filename) => {
93 if (eventType === 'change' 98 if (eventType === 'change'
94 && (filename === this.#configFileName || filename === null)) { 99 && (filename === this.configFileName || filename === null)) {
95 configChanged()?.catch((err) => { 100 configChanged()?.catch((err) => {
96 console.log('Unhandled error while listening for config changes', err); 101 console.log('Unhandled error while listening for config changes', err);
97 }); 102 });
98 } 103 }
99 }); 104 });
105 }
100 106
101 return () => watcher.close(); 107 dispose(): void {
108 this.watcher?.close();
102 } 109 }
103} 110}
diff --git a/packages/shared/src/stores/SharedStore.ts b/packages/shared/src/stores/SharedStore.ts
index cfff6d5..c6c3ddc 100644
--- a/packages/shared/src/stores/SharedStore.ts
+++ b/packages/shared/src/stores/SharedStore.ts
@@ -30,7 +30,7 @@ import { config } from './Config';
30 30
31export const sharedStore = types.model('SharedStore', { 31export const sharedStore = types.model('SharedStore', {
32 config: types.optional(config, {}), 32 config: types.optional(config, {}),
33 shouldUseDarkColors: true, 33 shouldUseDarkColors: false,
34}); 34});
35 35
36export interface SharedStore extends Instance<typeof sharedStore> {} 36export interface SharedStore extends Instance<typeof sharedStore> {}
diff --git a/yarn.lock b/yarn.lock
index 214d987..918aa7a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1238,6 +1238,7 @@ __metadata:
1238 mobx-state-tree: ^5.1.0 1238 mobx-state-tree: ^5.1.0
1239 ms: ^2.1.3 1239 ms: ^2.1.3
1240 rimraf: ^3.0.2 1240 rimraf: ^3.0.2
1241 typed-inject: ^3.0.1
1241 typescript: ^4.5.4 1242 typescript: ^4.5.4
1242 vite: ^2.7.7 1243 vite: ^2.7.7
1243 languageName: unknown 1244 languageName: unknown
@@ -7377,6 +7378,13 @@ __metadata:
7377 languageName: node 7378 languageName: node
7378 linkType: hard 7379 linkType: hard
7379 7380
7381"typed-inject@npm:^3.0.1":
7382 version: 3.0.1
7383 resolution: "typed-inject@npm:3.0.1"
7384 checksum: a400797a42951bc46a38873e9be40fe90fdba6ae3362e2d4da4972c4ec48e962ff167bc5f090ca42dc20bb3f6063cd07a80426df520c64dc9e94d1e89ff4779e
7385 languageName: node
7386 linkType: hard
7387
7380"typedarray-to-buffer@npm:^3.1.5": 7388"typedarray-to-buffer@npm:^3.1.5":
7381 version: 3.1.5 7389 version: 3.1.5
7382 resolution: "typedarray-to-buffer@npm:3.1.5" 7390 resolution: "typedarray-to-buffer@npm:3.1.5"