From 286abe8d982ec3b08580910157c6ed886aea20ca Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 20 Jan 2022 18:46:00 +0100 Subject: refactor: Rename main services to infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes it clear that we are talking about wrappers for nodejs and electron functionality, and not the services being displayed by the application. Signed-off-by: Kristóf Marussy --- docs/architecture.md | 8 +- .../src/controllers/__tests__/initConfig.spec.ts | 4 +- packages/main/src/controllers/initConfig.ts | 4 +- .../main/src/infrastructure/ConfigPersistence.ts | 34 +++++ .../impl/FileBasedConfigPersistence.ts | 138 ++++++++++++++++++++ packages/main/src/init.ts | 4 +- .../main/src/services/ConfigPersistenceService.ts | 34 ----- .../services/impl/ConfigPersistenceServiceImpl.ts | 140 --------------------- 8 files changed, 182 insertions(+), 184 deletions(-) create mode 100644 packages/main/src/infrastructure/ConfigPersistence.ts create mode 100644 packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts delete mode 100644 packages/main/src/services/ConfigPersistenceService.ts delete mode 100644 packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0122809..791b57b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,11 +82,11 @@ Instead, they purely rely on IPC messages to invoke actions in the main process In the main process, _controllers_ react to `MainStore` changes by invoking Electron APIs and subscribe to Electron events in order to invoke `MainStore` actions. -For easier testability, controllers may rely on _services_ abstracting away the underlying Electron APIs. -The service has to come with a TypeScript interface and an implementation. -In the tests, the default implementation of the interface is replaced by a mock. +For better testability, controllers may rely on _infrastructure services_ (wrappers) abstracting away the underlying Electron APIs. +Each infrastructure of the service has to come with a TypeScript interface and at least one implementation. +In the tests, the default implementations of the interfaces are replaced by mocks. -The services and controllers are instatiated and connected to the `MainStore` in the [composition root](https://gitlab.com/say-hi-to-sophie/sophie/-/blob/main/packages/main/src/compositionRoot.ts). +The infrastructure services and controllers are instantiated and connected to the `MainStore` in the [composition root](https://gitlab.com/say-hi-to-sophie/sophie/-/blob/main/packages/main/src/init.ts). **TODO:** While a service is a common term in MVC application architecture, we should come up with a different name to avoid clashing witch services, i.e., web sites loaded by Sophie. diff --git a/packages/main/src/controllers/__tests__/initConfig.spec.ts b/packages/main/src/controllers/__tests__/initConfig.spec.ts index 241ab2d..dc00b9d 100644 --- a/packages/main/src/controllers/__tests__/initConfig.spec.ts +++ b/packages/main/src/controllers/__tests__/initConfig.spec.ts @@ -23,14 +23,14 @@ import { mocked } from 'jest-mock'; import { getSnapshot } from 'mobx-state-tree'; import ms from 'ms'; -import type ConfigPersistenceService from '../../services/ConfigPersistenceService'; +import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; import { Config, config as configModel } from '../../stores/Config'; import type Disposer from '../../utils/Disposer'; import { silenceLogger } from '../../utils/log'; import initConfig from '../initConfig'; let config: Config; -const persistenceService: ConfigPersistenceService = { +const persistenceService: ConfigPersistence = { readConfig: jest.fn(), writeConfig: jest.fn(), watchConfig: jest.fn(), diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/controllers/initConfig.ts index 2dcabaf..c8cd335 100644 --- a/packages/main/src/controllers/initConfig.ts +++ b/packages/main/src/controllers/initConfig.ts @@ -23,7 +23,7 @@ import { debounce } from 'lodash-es'; import { getSnapshot, onSnapshot } from 'mobx-state-tree'; import ms from 'ms'; -import type ConfigPersistenceService from '../services/ConfigPersistenceService'; +import type ConfigPersistence from '../infrastructure/ConfigPersistence'; import { Config, ConfigFileIn, ConfigSnapshotOut } from '../stores/Config'; import type Disposer from '../utils/Disposer'; import { getLogger } from '../utils/log'; @@ -34,7 +34,7 @@ const log = getLogger('config'); export default async function initConfig( config: Config, - persistenceService: ConfigPersistenceService, + persistenceService: ConfigPersistence, debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, ): Promise { log.trace('Initializing config controller'); diff --git a/packages/main/src/infrastructure/ConfigPersistence.ts b/packages/main/src/infrastructure/ConfigPersistence.ts new file mode 100644 index 0000000..4b96f01 --- /dev/null +++ b/packages/main/src/infrastructure/ConfigPersistence.ts @@ -0,0 +1,34 @@ +/* + * 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 { ConfigSnapshotOut } from '../stores/Config'; +import type Disposer from '../utils/Disposer'; + +export type ReadConfigResult = + | { found: true; data: unknown } + | { found: false }; + +export default interface ConfigPersistence { + readConfig(): Promise; + + writeConfig(configSnapshot: ConfigSnapshotOut): Promise; + + watchConfig(callback: () => Promise, throttleMs: number): Disposer; +} diff --git a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts new file mode 100644 index 0000000..06e3fab --- /dev/null +++ b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts @@ -0,0 +1,138 @@ +/* + * 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 'node:fs'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import JSON5 from 'json5'; +import throttle from 'lodash-es/throttle'; + +import type { ConfigSnapshotOut } from '../../stores/Config'; +import type Disposer from '../../utils/Disposer'; +import { getLogger } from '../../utils/log'; +import type ConfigPersistence from '../ConfigPersistence'; +import type { ReadConfigResult } from '../ConfigPersistence'; + +const log = getLogger('fileBasedConfigPersistence'); + +export default class FileBasedConfigPersistence implements ConfigPersistence { + private readonly configFilePath: string; + + private writingConfig = false; + + private timeLastWritten: Date | undefined; + + constructor( + private readonly userDataDir: string, + private readonly configFileName: string = 'config.json5', + ) { + this.configFileName = configFileName; + this.configFilePath = path.join(this.userDataDir, this.configFileName); + } + + async readConfig(): Promise { + let configStr: string; + try { + configStr = await readFile(this.configFilePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + log.debug('Config file', this.configFilePath, 'was not found'); + return { found: false }; + } + throw error; + } + log.info('Read config file', this.configFilePath); + return { + found: true, + data: JSON5.parse(configStr), + }; + } + + async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { + const configJson = JSON5.stringify(configSnapshot, { + space: 2, + }); + this.writingConfig = true; + try { + await writeFile(this.configFilePath, configJson, 'utf8'); + const { mtime } = await stat(this.configFilePath); + log.trace('Config file', this.configFilePath, 'last written at', mtime); + this.timeLastWritten = mtime; + } finally { + this.writingConfig = false; + } + log.info('Wrote config file', this.configFilePath); + } + + watchConfig(callback: () => Promise, throttleMs: number): Disposer { + log.debug('Installing watcher for', this.userDataDir); + + const configChanged = throttle(async () => { + let mtime: Date; + try { + const stats = await stat(this.configFilePath); + mtime = stats.mtime; + log.trace('Config file last modified at', mtime); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + log.debug( + 'Config file', + this.configFilePath, + 'was deleted after being changed', + ); + return; + } + throw error; + } + if ( + !this.writingConfig && + (this.timeLastWritten === undefined || mtime > this.timeLastWritten) + ) { + log.debug( + 'Found a config file modified at', + mtime, + 'whish is newer than last written', + this.timeLastWritten, + ); + await callback(); + } + }, throttleMs); + + const watcher = watch(this.userDataDir, { + persistent: false, + }); + + watcher.on('change', (eventType, filename) => { + if ( + eventType === 'change' && + (filename === this.configFileName || filename === null) + ) { + configChanged()?.catch((err) => { + log.error('Unhandled error while listening for config changes', err); + }); + } + }); + + return () => { + log.trace('Removing watcher for', this.configFilePath); + watcher.close(); + }; + } +} diff --git a/packages/main/src/init.ts b/packages/main/src/init.ts index f3794bb..236a075 100644 --- a/packages/main/src/init.ts +++ b/packages/main/src/init.ts @@ -22,12 +22,12 @@ import { app } from 'electron'; import initConfig from './controllers/initConfig'; import initNativeTheme from './controllers/initNativeTheme'; -import ConfigPersistenceServiceImpl from './services/impl/ConfigPersistenceServiceImpl'; +import FileBasedConfigPersistence from './infrastructure/impl/FileBasedConfigPersistence'; import { MainStore } from './stores/MainStore'; import type Disposer from './utils/Disposer'; export default async function init(store: MainStore): Promise { - const configPersistenceService = new ConfigPersistenceServiceImpl( + const configPersistenceService = new FileBasedConfigPersistence( app.getPath('userData'), ); const disposeConfigController = await initConfig( diff --git a/packages/main/src/services/ConfigPersistenceService.ts b/packages/main/src/services/ConfigPersistenceService.ts deleted file mode 100644 index ee5696d..0000000 --- a/packages/main/src/services/ConfigPersistenceService.ts +++ /dev/null @@ -1,34 +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 type { ConfigSnapshotOut } from '../stores/Config'; -import type Disposer from '../utils/Disposer'; - -export type ReadConfigResult = - | { found: true; data: unknown } - | { found: false }; - -export default interface ConfigPersistenceService { - readConfig(): Promise; - - writeConfig(configSnapshot: ConfigSnapshotOut): Promise; - - watchConfig(callback: () => Promise, throttleMs: number): Disposer; -} diff --git a/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts b/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts deleted file mode 100644 index a11a9da..0000000 --- a/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts +++ /dev/null @@ -1,140 +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 'node:fs'; -import { readFile, stat, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -import JSON5 from 'json5'; -import throttle from 'lodash-es/throttle'; - -import type { ConfigSnapshotOut } from '../../stores/Config'; -import type Disposer from '../../utils/Disposer'; -import { getLogger } from '../../utils/log'; -import type ConfigPersistenceService from '../ConfigPersistenceService'; -import type { ReadConfigResult } from '../ConfigPersistenceService'; - -const log = getLogger('configPersistence'); - -export default class ConfigPersistenceServiceImpl - implements ConfigPersistenceService -{ - private readonly configFilePath: string; - - private writingConfig = false; - - private timeLastWritten: Date | undefined; - - constructor( - private readonly userDataDir: string, - private readonly configFileName: string = 'config.json5', - ) { - this.configFileName = configFileName; - this.configFilePath = path.join(this.userDataDir, this.configFileName); - } - - async readConfig(): Promise { - let configStr; - try { - configStr = await readFile(this.configFilePath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - log.debug('Config file', this.configFilePath, 'was not found'); - return { found: false }; - } - throw error; - } - log.info('Read config file', this.configFilePath); - return { - found: true, - data: JSON5.parse(configStr), - }; - } - - async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { - const configJson = JSON5.stringify(configSnapshot, { - space: 2, - }); - this.writingConfig = true; - try { - await writeFile(this.configFilePath, configJson, 'utf8'); - const { mtime } = await stat(this.configFilePath); - log.trace('Config file', this.configFilePath, 'last written at', mtime); - this.timeLastWritten = mtime; - } finally { - this.writingConfig = false; - } - log.info('Wrote config file', this.configFilePath); - } - - watchConfig(callback: () => Promise, throttleMs: number): Disposer { - log.debug('Installing watcher for', this.userDataDir); - - const configChanged = throttle(async () => { - let mtime: Date; - try { - const stats = await stat(this.configFilePath); - mtime = stats.mtime; - log.trace('Config file last modified at', mtime); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - log.debug( - 'Config file', - this.configFilePath, - 'was deleted after being changed', - ); - return; - } - throw error; - } - if ( - !this.writingConfig && - (this.timeLastWritten === undefined || mtime > this.timeLastWritten) - ) { - log.debug( - 'Found a config file modified at', - mtime, - 'whish is newer than last written', - this.timeLastWritten, - ); - await callback(); - } - }, throttleMs); - - const watcher = watch(this.userDataDir, { - persistent: false, - }); - - watcher.on('change', (eventType, filename) => { - if ( - eventType === 'change' && - (filename === this.configFileName || filename === null) - ) { - configChanged()?.catch((err) => { - log.error('Unhandled error while listening for config changes', err); - }); - } - }); - - return () => { - log.trace('Removing watcher for', this.configFilePath); - watcher.close(); - }; - } -} -- cgit v1.2.3-54-g00ecf