From f5f27eddc93314e8e10ab96c7bdb5c626142a1d3 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 27 Dec 2021 19:41:46 +0100 Subject: refactor: Inversion of control with typed-inject --- packages/main/package.json | 3 +- packages/main/src/controllers/ConfigController.ts | 105 +++++++++------------ packages/main/src/controllers/MainController.ts | 38 ++++++++ .../main/src/controllers/NativeThemeController.ts | 37 +++++--- packages/main/src/index.ts | 15 +-- packages/main/src/injector.ts | 39 ++++++++ .../main/src/services/ConfigPersistenceService.ts | 53 ++++++----- packages/main/src/services/NativeThemeService.ts | 38 -------- packages/shared/src/stores/SharedStore.ts | 2 +- 9 files changed, 183 insertions(+), 147 deletions(-) create mode 100644 packages/main/src/controllers/MainController.ts create mode 100644 packages/main/src/injector.ts delete mode 100644 packages/main/src/services/NativeThemeService.ts (limited to 'packages') 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 @@ "lodash": "^4.17.21", "mobx": "^6.3.10", "mobx-state-tree": "^5.1.0", - "ms": "^2.1.3" + "ms": "^2.1.3", + "typed-inject": "^3.0.1" }, "devDependencies": { "@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 { IDisposer, onSnapshot, } from 'mobx-state-tree'; -import ms from 'ms'; -import { ConfigPersistenceService } from '../services/ConfigPersistenceService'; -import { Config, ConfigSnapshotOut } from '../stores/Config'; +import type { ConfigPersistenceService } from '../services/ConfigPersistenceService'; +import type { Config, ConfigSnapshotOut } from '../stores/Config'; -const DEFAULT_DEBOUNCE_TIME = ms('1s'); +export class ConfigController { + static inject = ['configPersistenceService', 'configDebounceTime'] as const; -class ConfigController { - readonly #config: Config; + private config: Config | null = null; - readonly #persistenceService: ConfigPersistenceService; + private onSnapshotDisposer: IDisposer | null = null; - readonly #onSnapshotDisposer: IDisposer; + private lastSnapshotOnDisk: ConfigSnapshotOut | null = null; - readonly #watcherDisposer: IDisposer; + private writingConfig: boolean = false; - #lastSnapshotOnDisk: ConfigSnapshotOut | null = null; + constructor( + private readonly persistenceService: ConfigPersistenceService, + private readonly debounceTime: number, + ) { + } - #writingConfig: boolean = false; + async initConfig(config: Config): Promise { + this.config = config; - #configMTime: Date | null = null; + const foundConfig: boolean = await this.readConfig(); + if (!foundConfig) { + console.log('Creating new config file'); + try { + await this.writeConfig(); + } catch (err) { + console.error('Failed to initialize config'); + } + } - constructor( - config: Config, - persistenceService: ConfigPersistenceService, - debounceTime: number, - ) { - this.#config = config; - this.#persistenceService = persistenceService; - this.#onSnapshotDisposer = onSnapshot(this.#config, debounce((snapshot) => { + this.onSnapshotDisposer = onSnapshot(this.config, debounce((snapshot) => { // We can compare snapshots by reference, since it is only recreated on store changes. - if (this.#lastSnapshotOnDisk !== snapshot) { - this.#writeConfig().catch((err) => { + if (this.lastSnapshotOnDisk !== snapshot) { + this.writeConfig().catch((err) => { console.log('Failed to write config on config change', err); }) } - }, debounceTime)); - this.#watcherDisposer = this.#persistenceService.watchConfig(async (mtime) => { - if (!this.#writingConfig && (this.#configMTime === null || mtime > this.#configMTime)) { - await this.#readConfig(); + }, this.debounceTime)); + + this.persistenceService.watchConfig(async () => { + if (!this.writingConfig) { + await this.readConfig(); } - }, debounceTime); + }, this.debounceTime); } - async #readConfig(): Promise { - const result = await this.#persistenceService.readConfig(); + private async readConfig(): Promise { + const result = await this.persistenceService.readConfig(); if (result.found) { try { - applySnapshot(this.#config, result.data); - this.#lastSnapshotOnDisk = getSnapshot(this.#config); + applySnapshot(this.config!, result.data); + this.lastSnapshotOnDisk = getSnapshot(this.config!); console.log('Loaded config'); } catch (err) { console.error('Failed to read config', result.data, err); @@ -83,42 +89,19 @@ class ConfigController { return result.found; } - async #writeConfig(): Promise { - const snapshot = getSnapshot(this.#config); - this.#writingConfig = true; + private async writeConfig(): Promise { + const snapshot = getSnapshot(this.config!); + this.writingConfig = true; try { - this.#configMTime = await this.#persistenceService.writeConfig(snapshot); - this.#lastSnapshotOnDisk = snapshot; + await this.persistenceService.writeConfig(snapshot); + this.lastSnapshotOnDisk = snapshot; console.log('Wrote config'); } finally { - this.#writingConfig = false; - } - } - - async initConfig(): Promise { - const foundConfig: boolean = await this.#readConfig(); - if (!foundConfig) { - console.log('Creating new config file'); - try { - await this.#writeConfig(); - } catch (err) { - console.error('Failed to initialize config'); - } + this.writingConfig = false; } } dispose(): void { - this.#onSnapshotDisposer(); - this.#watcherDisposer(); + this.onSnapshotDisposer?.(); } } - -export async function initConfig( - config: Config, - persistenceService: ConfigPersistenceService, - debounceTime: number = DEFAULT_DEBOUNCE_TIME, -): Promise { - const controller = new ConfigController(config, persistenceService, debounceTime); - await controller.initConfig(); - return () => controller.dispose(); -} diff --git a/packages/main/src/controllers/MainController.ts b/packages/main/src/controllers/MainController.ts new file mode 100644 index 0000000..6b97330 --- /dev/null +++ b/packages/main/src/controllers/MainController.ts @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ConfigController } from './ConfigController'; +import type { NativeThemeController } from './NativeThemeController'; +import type { MainStore } from '../stores/MainStore'; + +export class MainController { + static inject = ['configController', 'nativeThemeController'] as const; + + constructor( + private readonly configController: ConfigController, + private readonly nativeThemeController: NativeThemeController, + ) { + } + + async connect(store: MainStore): Promise { + await this.configController.initConfig(store.config); + this.nativeThemeController.connect(store); + } +} 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 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { nativeTheme } from 'electron'; import { autorun } from 'mobx'; -import type { IDisposer } from 'mobx-state-tree'; +import { IDisposer } from 'mobx-state-tree'; -import type { NativeThemeService } from '../services/NativeThemeService'; import type { MainStore } from '../stores/MainStore'; -export function initNativeTheme(store: MainStore, service: NativeThemeService): IDisposer { - const themeSourceReactionDisposer = autorun(() => { - service.setThemeSource(store.config.themeSource); - }); - const onShouldUseDarkColorsUpdatedDisposer = service.onShouldUseDarkColorsUpdated( - store.setShouldUseDarkColors, - ); - return () => { - onShouldUseDarkColorsUpdatedDisposer(); - themeSourceReactionDisposer(); - }; +export class NativeThemeController { + private autorunDisposer: IDisposer | null = null; + + private shouldUseDarkColorsListener: (() => void) | null = null; + + connect(store: MainStore): void { + this.autorunDisposer = autorun(() => { + nativeTheme.themeSource = store.config.themeSource; + }); + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + this.shouldUseDarkColorsListener = () => { + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + }; + nativeTheme.on('updated', this.shouldUseDarkColorsListener); + } + + dispose(): void { + if (this.shouldUseDarkColorsListener !== null) { + nativeTheme.off('updated', this.shouldUseDarkColorsListener); + } + this.autorunDisposer?.(); + } } 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 { installDevToolsExtensions, openDevToolsWhenReady, } from './devTools'; -import { initConfig } from './controllers/ConfigController'; -import { initNativeTheme } from './controllers/NativeThemeController'; -import { ConfigPersistenceService } from './services/ConfigPersistenceService'; -import { NativeThemeService } from './services/NativeThemeService'; +import { injector } from './injector'; import { createMainStore } from './stores/MainStore'; const isDevelopment = import.meta.env.MODE === 'development'; @@ -108,12 +105,10 @@ let mainWindow: BrowserWindow | null = null; const store = createMainStore(); -initConfig( - store.config, - new ConfigPersistenceService(app.getPath('userData'), 'config.json5'), -).then(() => { - initNativeTheme(store, new NativeThemeService()); -}).catch((err) => console.error(err)); +const controller = injector.resolve('mainController'); +controller.connect(store).catch((err) => { + console.log('Error while initializing app', err); +}); const rendererBaseUrl = getResourceUrl('../renderer/'); function 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 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { app } from 'electron'; +import ms from 'ms'; +import { createInjector, Injector } from 'typed-inject'; + +import { ConfigController } from './controllers/ConfigController'; +import { MainController } from './controllers/MainController'; +import { NativeThemeController } from './controllers/NativeThemeController'; +import { ConfigPersistenceService } from './services/ConfigPersistenceService'; + +export const injector: Injector<{ + 'mainController': MainController, +}> = createInjector() + .provideFactory('userDataDir', () => app.getPath('userData')) + .provideValue('configFileName', 'config.json5') + .provideValue('configDebounceTime', ms('1s')) + .provideClass('configPersistenceService', ConfigPersistenceService) + .provideClass('configController', ConfigController) + .provideClass('nativeThemeController', NativeThemeController) + .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 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import { watch } from 'fs'; +import { FSWatcher, watch } from 'fs'; import { readFile, stat, writeFile } from 'fs/promises'; import JSON5 from 'json5'; import { throttle } from 'lodash'; -import { IDisposer } from 'mobx-state-tree'; import { join } from 'path'; import type { ConfigSnapshotOut } from '../stores/Config'; @@ -30,25 +28,26 @@ import type { ConfigSnapshotOut } from '../stores/Config'; export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; export class ConfigPersistenceService { - readonly #userDataDir: string; + static inject = ['userDataDir', 'configFileName'] as const; + + private readonly configFilePath: string; - readonly #configFileName: string; + private timeLastWritten: Date | null = null; - readonly #configFilePath: string; + private watcher: FSWatcher | null = null; constructor( - userDataDir: string, - configFileName: string, + private readonly userDataDir: string, + private readonly configFileName: string, ) { - this.#userDataDir = userDataDir; - this.#configFileName = configFileName; - this.#configFilePath = join(this.#userDataDir, this.#configFileName); + this.configFileName = configFileName; + this.configFilePath = join(this.userDataDir, this.configFileName); } async readConfig(): Promise { let configStr; try { - configStr = await readFile(this.#configFilePath, 'utf8'); + configStr = await readFile(this.configFilePath, 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return { found: false }; @@ -61,20 +60,24 @@ export class ConfigPersistenceService { }; } - async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { + async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { const configJson = JSON5.stringify(configSnapshot, { space: 2, }); - await writeFile(this.#configFilePath, configJson, 'utf8'); - const { mtime } = await stat(this.#configFilePath); - return mtime; + await writeFile(this.configFilePath, configJson, 'utf8'); + const stats = await stat(this.configFilePath); + this.timeLastWritten = stats.mtime; } - watchConfig(callback: (mtime: Date) => Promise, throttleMs: number): IDisposer { + watchConfig(callback: () => Promise, throttleMs: number): void { + if (this.watcher !== null) { + throw new Error('watchConfig was already called'); + } + const configChanged = throttle(async () => { let mtime: Date; try { - const stats = await stat(this.#configFilePath); + const stats = await stat(this.configFilePath); mtime = stats.mtime; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { @@ -82,22 +85,26 @@ export class ConfigPersistenceService { } throw err; } - return callback(mtime); + if (this.timeLastWritten === null || mtime > this.timeLastWritten) { + return callback(); + } }, throttleMs); - const watcher = watch(this.#userDataDir, { + this.watcher = watch(this.userDataDir, { persistent: false, }); - watcher.on('change', (eventType, filename) => { + this.watcher.on('change', (eventType, filename) => { if (eventType === 'change' - && (filename === this.#configFileName || filename === null)) { + && (filename === this.configFileName || filename === null)) { configChanged()?.catch((err) => { console.log('Unhandled error while listening for config changes', err); }); } }); + } - return () => watcher.close(); + dispose(): void { + this.watcher?.close(); } } diff --git a/packages/main/src/services/NativeThemeService.ts b/packages/main/src/services/NativeThemeService.ts deleted file mode 100644 index 7a26c3c..0000000 --- a/packages/main/src/services/NativeThemeService.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2021-2022 Kristóf Marussy - * - * This file is part of Sophie. - * - * Sophie is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { nativeTheme } from 'electron'; -import type { IDisposer } from 'mobx-state-tree'; -import type { ThemeSource } from '@sophie/shared'; - -export class NativeThemeService { - setThemeSource(themeSource: ThemeSource): void { - nativeTheme.themeSource = themeSource; - } - - onShouldUseDarkColorsUpdated(callback: (shouldUseDarkColors: boolean) => void): IDisposer { - const wrappedCallback = () => { - callback(nativeTheme.shouldUseDarkColors); - }; - wrappedCallback(); - nativeTheme.on('updated', wrappedCallback); - return () => nativeTheme.off('updated', wrappedCallback); - } -} 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'; export const sharedStore = types.model('SharedStore', { config: types.optional(config, {}), - shouldUseDarkColors: true, + shouldUseDarkColors: false, }); export interface SharedStore extends Instance {} -- cgit v1.2.3-54-g00ecf