From e56cdad02c00adf3b779d9de62d460e78be204a6 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sun, 26 Dec 2021 19:17:23 +0100 Subject: 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) --- packages/main/src/controllers/ConfigController.ts | 124 +++++++++++++++++++++ .../main/src/controllers/NativeThemeController.ts | 38 +++++++ packages/main/src/index.ts | 29 ++--- packages/main/src/services/ConfigPersistence.ts | 36 ------ .../main/src/services/ConfigPersistenceService.ts | 103 +++++++++++++++++ packages/main/src/services/MainEnv.ts | 38 ------- packages/main/src/services/NativeThemeService.ts | 38 +++++++ .../src/services/impl/ConfigPersistenceImpl.ts | 102 ----------------- packages/main/src/stores/Config.ts | 87 +-------------- packages/main/src/stores/MainStore.ts | 16 +-- 10 files changed, 322 insertions(+), 289 deletions(-) create mode 100644 packages/main/src/controllers/ConfigController.ts create mode 100644 packages/main/src/controllers/NativeThemeController.ts delete mode 100644 packages/main/src/services/ConfigPersistence.ts create mode 100644 packages/main/src/services/ConfigPersistenceService.ts delete mode 100644 packages/main/src/services/MainEnv.ts create mode 100644 packages/main/src/services/NativeThemeService.ts delete mode 100644 packages/main/src/services/impl/ConfigPersistenceImpl.ts (limited to 'packages') 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 @@ +/* + * 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 { debounce } from 'lodash'; +import { + applySnapshot, + getSnapshot, + IDisposer, + onSnapshot, +} from 'mobx-state-tree'; +import ms from 'ms'; + +import { ConfigPersistenceService } from '../services/ConfigPersistenceService'; +import { Config, ConfigSnapshotOut } from '../stores/Config'; + +const DEFAULT_DEBOUNCE_TIME = ms('1s'); + +class ConfigController { + readonly #config: Config; + + readonly #persistenceService: ConfigPersistenceService; + + readonly #onSnapshotDisposer: IDisposer; + + readonly #watcherDisposer: IDisposer; + + #lastSnapshotOnDisk: ConfigSnapshotOut | null = null; + + #writingConfig: boolean = false; + + #configMTime: Date | null = null; + + constructor( + config: Config, + persistenceService: ConfigPersistenceService, + debounceTime: number, + ) { + this.#config = config; + this.#persistenceService = persistenceService; + 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) => { + 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(); + } + }, debounceTime); + } + + async #readConfig(): Promise { + const result = await this.#persistenceService.readConfig(); + if (result.found) { + try { + 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); + } + } + return result.found; + } + + async #writeConfig(): Promise { + const snapshot = getSnapshot(this.#config); + this.#writingConfig = true; + try { + this.#configMTime = 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'); + } + } + } + + dispose(): void { + this.#onSnapshotDisposer(); + this.#watcherDisposer(); + } +} + +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/NativeThemeController.ts b/packages/main/src/controllers/NativeThemeController.ts new file mode 100644 index 0000000..07a3292 --- /dev/null +++ b/packages/main/src/controllers/NativeThemeController.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 { autorun } from 'mobx'; +import type { 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(); + }; +} 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 { BrowserView, BrowserWindow, ipcMain, - nativeTheme, } from 'electron'; import { readFileSync } from 'fs'; import { readFile } from 'fs/promises'; @@ -47,7 +46,10 @@ import { installDevToolsExtensions, openDevToolsWhenReady, } from './devTools'; -import { ConfigPersistenceImpl } from './services/impl/ConfigPersistenceImpl'; +import { initConfig } from './controllers/ConfigController'; +import { initNativeTheme } from './controllers/NativeThemeController'; +import { ConfigPersistenceService } from './services/ConfigPersistenceService'; +import { NativeThemeService } from './services/NativeThemeService'; import { createMainStore } from './stores/MainStore'; const isDevelopment = import.meta.env.MODE === 'development'; @@ -105,23 +107,14 @@ if (isDevelopment) { let mainWindow: BrowserWindow | null = null; -const store = createMainStore({ - configPersistence: new ConfigPersistenceImpl( - app.getPath('userData'), - 'config.json5', - ), -}); - -autorun(() => { - nativeTheme.themeSource = store.config.themeSource; -}); - -store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); -nativeTheme.on('updated', () => { - store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); -}); +const store = createMainStore(); -store.config.initConfig(); +initConfig( + store.config, + new ConfigPersistenceService(app.getPath('userData'), 'config.json5'), +).then(() => { + initNativeTheme(store, new NativeThemeService()); +}).catch((err) => console.error(err)); const rendererBaseUrl = getResourceUrl('../renderer/'); function shouldCancelMainWindowRequest(url: string, method: string): boolean { diff --git a/packages/main/src/services/ConfigPersistence.ts b/packages/main/src/services/ConfigPersistence.ts deleted file mode 100644 index f9a82de..0000000 --- a/packages/main/src/services/ConfigPersistence.ts +++ /dev/null @@ -1,36 +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 { IDisposer } from 'mobx-state-tree'; -import ms from 'ms'; - -import type { ConfigSnapshotOut } from '../stores/Config'; - -export const CONFIG_DEBOUNCE_TIME: number = ms('1s'); - -export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; - -export interface ConfigPersistence { - readConfig(): Promise; - - writeConfig(configSnapshot: ConfigSnapshotOut): Promise; - - watchConfig(callback: (mtime: Date) => Promise): IDisposer; -} diff --git a/packages/main/src/services/ConfigPersistenceService.ts b/packages/main/src/services/ConfigPersistenceService.ts new file mode 100644 index 0000000..85b0088 --- /dev/null +++ b/packages/main/src/services/ConfigPersistenceService.ts @@ -0,0 +1,103 @@ +/* + * 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 { 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'; + +export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; + +export class ConfigPersistenceService { + readonly #userDataDir: string; + + readonly #configFileName: string; + + readonly #configFilePath: string; + + constructor( + userDataDir: string, + configFileName: string, + ) { + this.#userDataDir = userDataDir; + this.#configFileName = configFileName; + this.#configFilePath = join(this.#userDataDir, this.#configFileName); + } + + async readConfig(): Promise { + let configStr; + try { + configStr = await readFile(this.#configFilePath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { found: false }; + } + throw err; + } + return { + found: true, + data: JSON5.parse(configStr), + }; + } + + 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; + } + + watchConfig(callback: (mtime: Date) => Promise, throttleMs: number): IDisposer { + const configChanged = throttle(async () => { + let mtime: Date; + try { + const stats = await stat(this.#configFilePath); + mtime = stats.mtime; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw err; + } + return callback(mtime); + }, throttleMs); + + const watcher = watch(this.#userDataDir, { + persistent: false, + }); + + watcher.on('change', (eventType, filename) => { + if (eventType === 'change' + && (filename === this.#configFileName || filename === null)) { + configChanged()?.catch((err) => { + console.log('Unhandled error while listening for config changes', err); + }); + } + }); + + return () => watcher.close(); + } +} diff --git a/packages/main/src/services/MainEnv.ts b/packages/main/src/services/MainEnv.ts deleted file mode 100644 index 23ee9a1..0000000 --- a/packages/main/src/services/MainEnv.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 { IAnyStateTreeNode, getEnv as getAnyEnv } from 'mobx-state-tree'; - -import type { ConfigPersistence } from './ConfigPersistence'; - -export interface MainEnv { - configPersistence: ConfigPersistence; -} - -/** - * Gets a well-typed environment from `model`. - * - * Only useable inside state trees created by `createAndConnectRootStore`. - * - * @param model The state tree node. - */ -export function getEnv(model: IAnyStateTreeNode): MainEnv { - return getAnyEnv(model); -} diff --git a/packages/main/src/services/NativeThemeService.ts b/packages/main/src/services/NativeThemeService.ts new file mode 100644 index 0000000..7a26c3c --- /dev/null +++ b/packages/main/src/services/NativeThemeService.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 { 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/main/src/services/impl/ConfigPersistenceImpl.ts b/packages/main/src/services/impl/ConfigPersistenceImpl.ts deleted file mode 100644 index 097ab74..0000000 --- a/packages/main/src/services/impl/ConfigPersistenceImpl.ts +++ /dev/null @@ -1,102 +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 { 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 { CONFIG_DEBOUNCE_TIME, ConfigPersistence, ReadConfigResult } from '../ConfigPersistence'; -import type { ConfigSnapshotOut } from '../../stores/Config'; - -export class ConfigPersistenceImpl implements ConfigPersistence { - readonly #userDataDir: string; - - readonly #configFileName: string; - - readonly #configFilePath: string; - - constructor( - userDataDir: string, - configFileName: string, - ) { - this.#userDataDir = userDataDir; - this.#configFileName = configFileName; - this.#configFilePath = join(this.#userDataDir, this.#configFileName); - } - - async readConfig(): Promise { - let configStr; - try { - configStr = await readFile(this.#configFilePath, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return { found: false }; - } - throw err; - } - return { - found: true, - data: JSON5.parse(configStr), - }; - } - - 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; - } - - watchConfig(callback: (mtime: Date) => Promise): IDisposer { - const configChanged = throttle(async () => { - let mtime: Date; - try { - const stats = await stat(this.#configFilePath); - mtime = stats.mtime; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return; - } - throw err; - } - return callback(mtime); - }, CONFIG_DEBOUNCE_TIME); - - const watcher = watch(this.#userDataDir, { - persistent: false, - }); - - watcher.on('change', (eventType, filename) => { - if (eventType === 'change' - && (filename === this.#configFileName || filename === null)) { - configChanged()?.catch((err) => { - console.log('Unhandled error while listening for config changes', err); - }); - } - }); - - return () => watcher.close(); - } -} 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 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { debounce } from 'lodash'; -import { - applySnapshot, - flow, - getSnapshot, - IDisposer, - Instance, - onSnapshot, -} from 'mobx-state-tree'; +import { Instance } from 'mobx-state-tree'; import { config as originalConfig, ConfigSnapshotIn, @@ -34,86 +26,11 @@ import { ThemeSource, } from '@sophie/shared'; -import { CONFIG_DEBOUNCE_TIME, ReadConfigResult } from '../services/ConfigPersistence'; -import { getEnv } from '../services/MainEnv'; - export const config = originalConfig.actions((self) => ({ setThemeSource(mode: ThemeSource) { self.themeSource = mode; }, -})).actions((self) => { - let lastSnapshotOnDisk: ConfigSnapshotOut | null = null; - let writingConfig = false; - let configMtime: Date | null = null; - let onSnapshotDisposer: IDisposer | null = null; - let watcherDisposer: IDisposer | null = null; - - function dispose() { - onSnapshotDisposer?.(); - watcherDisposer?.(); - } - - const actions: { - beforeDetach(): void, - readConfig(): Promise; - writeConfig(): Promise; - initConfig(): Promise; - } = { - beforeDetach() { - dispose(); - }, - readConfig: flow(function*() { - const result: ReadConfigResult = yield getEnv(self).configPersistence.readConfig(); - if (result.found) { - try { - applySnapshot(self, result.data); - lastSnapshotOnDisk = getSnapshot(self); - console.log('Loaded config'); - } catch (err) { - console.error('Failed to read config', result.data, err); - } - } - return result.found; - }), - writeConfig: flow(function*() { - const snapshot = getSnapshot(self); - writingConfig = true; - try { - configMtime = yield getEnv(self).configPersistence.writeConfig(snapshot); - lastSnapshotOnDisk = snapshot; - console.log('Wrote config'); - } finally { - writingConfig = false; - } - }), - initConfig: flow(function*() { - dispose(); - const foundConfig: boolean = yield actions.readConfig(); - if (!foundConfig) { - console.log('Creating new config file'); - try { - yield actions.writeConfig(); - } catch (err) { - console.error('Failed to initialize config'); - } - } - onSnapshotDisposer = onSnapshot(self, debounce((snapshot) => { - // We can compare snapshots by reference, since it is only recreated on store changes. - if (lastSnapshotOnDisk !== snapshot) { - actions.writeConfig().catch((err) => { - console.log('Failed to write config on config change', err); - }) - } - }, CONFIG_DEBOUNCE_TIME)); - watcherDisposer = getEnv(self).configPersistence.watchConfig(async (mtime) => { - if (!writingConfig && (configMtime === null || mtime > configMtime)) { - await actions.readConfig(); - } - }); - }), - }; - return actions; -}); +})); export interface Config extends Instance {} 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'; import { BrowserViewBounds, emptySharedStore } from '@sophie/shared'; import type { Config } from './Config'; -import { MainEnv } from '../services/MainEnv'; import { sharedStore } from './SharedStore'; export const mainStore = types.model('MainStore', { @@ -46,14 +45,11 @@ export const mainStore = types.model('MainStore', { } })); -export interface RootStore extends Instance {} +export interface MainStore extends Instance {} -export function createMainStore(env: MainEnv): RootStore { - return mainStore.create( - { - browserViewBounds: {}, - shared: emptySharedStore, - }, - env, - ); +export function createMainStore(): MainStore { + return mainStore.create({ + browserViewBounds: {}, + shared: emptySharedStore, + }); } -- cgit v1.2.3-54-g00ecf