From 9546dc2aa39ab096ccc723786e718a739d0bdaf9 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 27 Jan 2022 00:17:22 +0100 Subject: refactor: Coding conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make sure that files have a default import with the same name as the file whenever possible to reduce surprise. Also shuffles around some file names for better legibility. Signed-off-by: Kristóf Marussy --- .eslintrc.cjs | 9 + config/getEsbuildConfig.js | 1 + docs/architecture.md | 13 +- package.json | 1 + .../src/controllers/__tests__/initConfig.spec.ts | 229 --------------------- .../controllers/__tests__/initNativeTheme.spec.ts | 75 ------- packages/main/src/controllers/initConfig.ts | 100 --------- packages/main/src/controllers/initNativeTheme.ts | 50 ----- packages/main/src/index.ts | 16 +- .../main/src/infrastructure/ConfigPersistence.ts | 34 --- .../main/src/infrastructure/config/ConfigFile.ts | 142 +++++++++++++ .../src/infrastructure/config/ConfigRepository.ts | 34 +++ .../src/infrastructure/config/ReadConfigResult.ts | 23 +++ .../impl/FileBasedConfigPersistence.ts | 138 ------------- packages/main/src/init.ts | 43 ---- packages/main/src/initReactions.ts | 43 ++++ .../reactions/__tests__/synchronizeConfig.spec.ts | 225 ++++++++++++++++++++ .../__tests__/synchronizeNativeTheme.spec.ts | 77 +++++++ packages/main/src/reactions/synchronizeConfig.ts | 98 +++++++++ .../main/src/reactions/synchronizeNativeTheme.ts | 47 +++++ packages/main/src/stores/GlobalSettings.ts | 12 +- packages/main/src/stores/MainStore.ts | 22 +- packages/main/src/stores/Profile.ts | 12 +- packages/main/src/stores/Service.ts | 16 +- packages/main/src/stores/ServiceSettings.ts | 16 +- packages/main/src/stores/SharedStore.ts | 34 +-- .../main/src/stores/__tests__/SharedStore.spec.ts | 4 +- .../src/contextBridge/createSophieRenderer.ts | 22 +- packages/renderer/src/components/StoreProvider.tsx | 3 +- packages/renderer/src/stores/RendererStore.ts | 24 +-- packages/renderer/vite.config.js | 3 + packages/service-preload/src/index.ts | 4 +- packages/service-shared/src/index.ts | 3 +- packages/service-shared/src/schemas.ts | 16 +- packages/shared/src/index.ts | 21 +- packages/shared/src/schemas.ts | 44 ++-- packages/shared/src/stores/GlobalSettings.ts | 18 +- packages/shared/src/stores/Profile.ts | 14 +- packages/shared/src/stores/ProfileSettings.ts | 14 +- packages/shared/src/stores/Service.ts | 14 +- packages/shared/src/stores/ServiceSettings.ts | 18 +- packages/shared/src/stores/SharedStore.ts | 34 +-- 42 files changed, 941 insertions(+), 825 deletions(-) delete mode 100644 packages/main/src/controllers/__tests__/initConfig.spec.ts delete mode 100644 packages/main/src/controllers/__tests__/initNativeTheme.spec.ts delete mode 100644 packages/main/src/controllers/initConfig.ts delete mode 100644 packages/main/src/controllers/initNativeTheme.ts delete mode 100644 packages/main/src/infrastructure/ConfigPersistence.ts create mode 100644 packages/main/src/infrastructure/config/ConfigFile.ts create mode 100644 packages/main/src/infrastructure/config/ConfigRepository.ts create mode 100644 packages/main/src/infrastructure/config/ReadConfigResult.ts delete mode 100644 packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts delete mode 100644 packages/main/src/init.ts create mode 100644 packages/main/src/initReactions.ts create mode 100644 packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts create mode 100644 packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts create mode 100644 packages/main/src/reactions/synchronizeConfig.ts create mode 100644 packages/main/src/reactions/synchronizeNativeTheme.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 5587ea7..55a055d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,6 +44,8 @@ module.exports = { }, }, rules: { + // TODO Flip this to `ignorePackages` once we are on `nodenext` resolution. + 'import/extensions': ['error', 'never'], 'import/no-unresolved': 'error', 'import/order': [ 'error', @@ -66,6 +68,13 @@ module.exports = { ], // Airbnb prefers forEach. 'unicorn/no-array-for-each': 'off', + // Typescript requires exlicit `undefined` arguments. + 'unicorn/no-useless-undefined': [ + 'error', + { + checkArguments: false, + }, + ], // Common abbreviations are known and readable. 'unicorn/prevent-abbreviations': 'off', }, diff --git a/config/getEsbuildConfig.js b/config/getEsbuildConfig.js index 930f19f..9cef588 100644 --- a/config/getEsbuildConfig.js +++ b/config/getEsbuildConfig.js @@ -27,6 +27,7 @@ export default function getEsbuildConfig(config, extraMetaEnvVars) { ...config, sourcemap: isDevelopment ? config.sourcemap || true : false, define: { + __DEV__: JSON.stringify(isDevelopment), // For mobx 'process.env.NODE_ENV': modeString, 'process.env.MODE': modeString, 'import.meta.env': JSON.stringify({ diff --git a/docs/architecture.md b/docs/architecture.md index 791b57b..b76b2ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,18 +78,15 @@ Any actions of the `RendererStore` that should affect the shared state have to g To reduce the amount of code injected into service frames, service renderer processes contain no stores. Instead, they purely rely on IPC messages to invoke actions in the main process (but they are not notified of the result). -## Controllers +## Reactions -In the main process, _controllers_ react to `MainStore` changes by invoking Electron APIs and subscribe to Electron events in order to invoke `MainStore` actions. +In the main process, _reactions_ react to `MainStore` changes by invoking Electron APIs and subscribe to Electron events in order to invoke `MainStore` actions. -For better testability, controllers may rely on _infrastructure services_ (wrappers) abstracting away the underlying Electron APIs. +For better testability, reactions 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 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. +The infrastructure services and reactions 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). ## React @@ -101,7 +98,7 @@ We must take care not to render anything in this area, since it will be entirely # Packages -The code of Sophie is distirbuted between different Node packages according to how they are loaded into the application. +The code of Sophie is distributed between different Node packages according to how they are loaded into the application. All packages except the renderer package are tree-shaken and bundled with [esbuild](https://esbuild.github.io/) for quicker loading. The web application in the renderer packages is tree-shaken and bundled with [vite](https://vitejs.dev/). diff --git a/package.json b/package.json index 42b9b5e..6cad5f8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lint:only": "yarn lint:eslint . --ext .cjs,.js,.jsx,.ts,.tsx", "lint:precommit": "yarn types && yarn lint:eslint --fix", "lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=16384\" eslint", + "lint:staged": "nano-staged", "typecheck": "yarn types && yarn typecheck:ci", "typecheck:ci": "yarn workspaces foreach -vp run typecheck:workspace", "typecheck:workspace": "yarn g:typecheck", diff --git a/packages/main/src/controllers/__tests__/initConfig.spec.ts b/packages/main/src/controllers/__tests__/initConfig.spec.ts deleted file mode 100644 index fdd22c9..0000000 --- a/packages/main/src/controllers/__tests__/initConfig.spec.ts +++ /dev/null @@ -1,229 +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 { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; -import ms from 'ms'; - -import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; -import { sharedStore, SharedStore } from '../../stores/SharedStore'; -import type Disposer from '../../utils/Disposer'; -import { silenceLogger } from '../../utils/log'; -import initConfig from '../initConfig'; - -let store: SharedStore; -const persistenceService: ConfigPersistence = { - readConfig: jest.fn(), - writeConfig: jest.fn(), - watchConfig: jest.fn(), -}; -const lessThanThrottleMs = ms('0.1s'); -const throttleMs = ms('1s'); - -beforeAll(() => { - jest.useFakeTimers(); - silenceLogger(); -}); - -beforeEach(() => { - store = sharedStore.create(); -}); - -describe('when initializing', () => { - describe('when there is no config file', () => { - beforeEach(() => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: false, - }); - }); - - it('should create a new config file', async () => { - await initConfig(store, persistenceService); - expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); - }); - - it('should bail if there is an an error creating the config file', async () => { - mocked(persistenceService.writeConfig).mockRejectedValue( - new Error('boo'), - ); - await expect(() => - initConfig(store, persistenceService), - ).rejects.toBeInstanceOf(Error); - }); - }); - - describe('when there is a valid config file', () => { - beforeEach(() => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - // Use a default empty config file to not trigger config rewrite. - ...store.config, - themeSource: 'dark', - }, - }); - }); - - it('should read the existing config file is there is one', async () => { - await initConfig(store, persistenceService); - expect(persistenceService.writeConfig).not.toHaveBeenCalled(); - expect(store.settings.themeSource).toBe('dark'); - }); - - it('should bail if it cannot set up a watcher', async () => { - mocked(persistenceService.watchConfig).mockImplementationOnce(() => { - throw new Error('boo'); - }); - await expect(() => - initConfig(store, persistenceService), - ).rejects.toBeInstanceOf(Error); - }); - }); - - it('should update the config file if new details are added during read', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: 'light', - profile: { - name: 'Test profile', - }, - }, - }); - await initConfig(store, persistenceService); - expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); - }); - - it('should not apply an invalid config file but should not overwrite it', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: -1, - }, - }); - await initConfig(store, persistenceService); - expect(store.settings.themeSource).not.toBe(-1); - expect(persistenceService.writeConfig).not.toHaveBeenCalled(); - }); - - it('should bail if it cannot determine whether there is a config file', async () => { - mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); - await expect(() => - initConfig(store, persistenceService), - ).rejects.toBeInstanceOf(Error); - }); -}); - -describe('when it has loaded the config', () => { - let sutDisposer: Disposer; - const watcherDisposer: Disposer = jest.fn(); - let configChangedCallback: () => Promise; - - beforeEach(async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: store.config, - }); - mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer); - sutDisposer = await initConfig(store, persistenceService, throttleMs); - [[configChangedCallback]] = mocked( - persistenceService.watchConfig, - ).mock.calls; - jest.resetAllMocks(); - }); - - it('should throttle saving changes to the config file', () => { - mocked(persistenceService.writeConfig).mockResolvedValue(); - store.settings.setThemeSource('dark'); - jest.advanceTimersByTime(lessThanThrottleMs); - store.settings.setThemeSource('light'); - jest.advanceTimersByTime(throttleMs); - expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); - }); - - it('should handle config writing errors gracefully', () => { - mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); - store.settings.setThemeSource('dark'); - jest.advanceTimersByTime(throttleMs); - expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); - }); - - it('should read the config file when it has changed', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - // Use a default empty config file to not trigger config rewrite. - ...store.config, - themeSource: 'dark', - }, - }); - await configChangedCallback(); - // Do not write back the changes we have just read. - expect(persistenceService.writeConfig).not.toHaveBeenCalled(); - expect(store.settings.themeSource).toBe('dark'); - }); - - it('should update the config file if new details are added', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: 'light', - profile: { - name: 'Test profile', - }, - }, - }); - await configChangedCallback(); - expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); - }); - - it('should not apply an invalid config file when it has changed but should not overwrite it', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: -1, - }, - }); - await configChangedCallback(); - expect(store.settings.themeSource).not.toBe(-1); - expect(persistenceService.writeConfig).not.toHaveBeenCalled(); - }); - - it('should handle config reading errors gracefully', async () => { - mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); - await expect(configChangedCallback()).resolves.not.toThrow(); - }); - - describe('when it was disposed', () => { - beforeEach(() => { - sutDisposer(); - }); - - it('should dispose the watcher', () => { - expect(watcherDisposer).toHaveBeenCalled(); - }); - - it('should not listen to store changes any more', () => { - store.settings.setThemeSource('dark'); - jest.advanceTimersByTime(2 * throttleMs); - expect(persistenceService.writeConfig).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts b/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts deleted file mode 100644 index 9107c78..0000000 --- a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts +++ /dev/null @@ -1,75 +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 { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; - -import { sharedStore, SharedStore } from '../../stores/SharedStore'; -import type Disposer from '../../utils/Disposer'; - -let shouldUseDarkColors = false; - -jest.unstable_mockModule('electron', () => ({ - nativeTheme: { - themeSource: 'system', - get shouldUseDarkColors() { - return shouldUseDarkColors; - }, - on: jest.fn(), - off: jest.fn(), - }, -})); - -const { nativeTheme } = await import('electron'); -const { default: initNativeTheme } = await import('../initNativeTheme'); - -let store: SharedStore; -let disposeSut: Disposer; - -beforeEach(() => { - store = sharedStore.create(); - disposeSut = initNativeTheme(store); -}); - -it('should register a nativeTheme updated listener', () => { - expect(nativeTheme.on).toHaveBeenCalledWith('updated', expect.anything()); -}); - -it('should synchronize themeSource changes to the nativeTheme', () => { - store.settings.setThemeSource('dark'); - expect(nativeTheme.themeSource).toBe('dark'); -}); - -it('should synchronize shouldUseDarkColors changes to the store', () => { - const listener = mocked(nativeTheme.on).mock.calls.find( - ([event]) => event === 'updated', - )![1]; - shouldUseDarkColors = true; - listener(); - expect(store.shouldUseDarkColors).toBe(true); -}); - -it('should remove the listener on dispose', () => { - const listener = mocked(nativeTheme.on).mock.calls.find( - ([event]) => event === 'updated', - )![1]; - disposeSut(); - expect(nativeTheme.off).toHaveBeenCalledWith('updated', listener); -}); diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/controllers/initConfig.ts deleted file mode 100644 index 55bf6df..0000000 --- a/packages/main/src/controllers/initConfig.ts +++ /dev/null @@ -1,100 +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 deepEqual from 'deep-equal'; -import { debounce } from 'lodash-es'; -import { reaction } from 'mobx'; -import ms from 'ms'; - -import type ConfigPersistence from '../infrastructure/ConfigPersistence'; -import { Config, SharedStore } from '../stores/SharedStore'; -import type Disposer from '../utils/Disposer'; -import { getLogger } from '../utils/log'; - -const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); - -const log = getLogger('config'); - -export default async function initConfig( - sharedStore: SharedStore, - persistenceService: ConfigPersistence, - debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, -): Promise { - log.trace('Initializing config controller'); - - let lastConfigOnDisk: Config | undefined; - - async function writeConfig(): Promise { - const { config } = sharedStore; - await persistenceService.writeConfig(config); - lastConfigOnDisk = config; - } - - async function readConfig(): Promise { - const result = await persistenceService.readConfig(); - if (result.found) { - try { - // This cast is unsound if the config file is invalid, - // but we'll throw an error in the end anyways. - sharedStore.loadConfig(result.data as Config); - } catch (error) { - log.error('Failed to apply config snapshot', result.data, error); - return true; - } - lastConfigOnDisk = sharedStore.config; - if (!deepEqual(result.data, lastConfigOnDisk, { strict: true })) { - await writeConfig(); - } - } - return result.found; - } - - if (!(await readConfig())) { - log.info('Config file was not found'); - await writeConfig(); - log.info('Created config file'); - } - - const disposeReaction = reaction( - () => sharedStore.config, - debounce((config) => { - // We can compare snapshots by reference, since it is only recreated on store changes. - if (lastConfigOnDisk !== config) { - writeConfig().catch((error) => { - log.error('Failed to write config on config change', error); - }); - } - }, debounceTime), - ); - - const disposeWatcher = persistenceService.watchConfig(async () => { - try { - await readConfig(); - } catch (error) { - log.error('Failed to read config', error); - } - }, debounceTime); - - return () => { - log.trace('Disposing config controller'); - disposeWatcher(); - disposeReaction(); - }; -} diff --git a/packages/main/src/controllers/initNativeTheme.ts b/packages/main/src/controllers/initNativeTheme.ts deleted file mode 100644 index ce7972a..0000000 --- a/packages/main/src/controllers/initNativeTheme.ts +++ /dev/null @@ -1,50 +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 { autorun } from 'mobx'; - -import type { SharedStore } from '../stores/SharedStore'; -import type Disposer from '../utils/Disposer'; -import { getLogger } from '../utils/log'; - -const log = getLogger('nativeTheme'); - -export default function initNativeTheme(store: SharedStore): Disposer { - log.trace('Initializing nativeTheme controller'); - - const disposeThemeSourceReaction = autorun(() => { - nativeTheme.themeSource = store.settings.themeSource; - log.debug('Set theme source:', store.settings.themeSource); - }); - - store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); - const shouldUseDarkColorsListener = () => { - store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); - log.debug('Set should use dark colors:', nativeTheme.shouldUseDarkColors); - }; - nativeTheme.on('updated', shouldUseDarkColorsListener); - - return () => { - log.trace('Disposing nativeTheme controller'); - nativeTheme.off('updated', shouldUseDarkColorsListener); - disposeThemeSourceReaction(); - }; -} diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index bcdc3d7..a886a16 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -19,22 +19,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { arch } from 'node:os'; import path from 'node:path'; import { URL } from 'node:url'; import { ServiceToMainIpcMessage, - unreadCount, + UnreadCount, WebSource, } from '@sophie/service-shared'; import { - action, + Action, MainToRendererIpcMessage, RendererToMainIpcMessage, } from '@sophie/shared'; import { app, BrowserView, BrowserWindow, ipcMain } from 'electron'; -import { ensureDirSync, readFile, readFileSync } from 'fs-extra'; +import { ensureDirSync } from 'fs-extra'; import { autorun } from 'mobx'; import { getSnapshot, onAction, onPatch } from 'mobx-state-tree'; import osName from 'os-name'; @@ -45,7 +47,7 @@ import { installDevToolsExtensions, openDevToolsWhenReady, } from './devTools'; -import init from './init'; +import initReactions from './initReactions'; import { createMainStore } from './stores/MainStore'; import { getLogger } from './utils/log'; @@ -128,7 +130,7 @@ let mainWindow: BrowserWindow | undefined; const store = createMainStore(); -init(store) +initReactions(store) // eslint-disable-next-line promise/always-return -- `then` instead of top-level await. .then((disposeCompositionRoot) => { app.on('will-quit', disposeCompositionRoot); @@ -267,7 +269,7 @@ async function createWindow(): Promise { return; } try { - const actionToDispatch = action.parse(rawAction); + const actionToDispatch = Action.parse(rawAction); switch (actionToDispatch.action) { case 'set-selected-service-id': store.shared.setSelectedServiceId(actionToDispatch.serviceId); @@ -331,7 +333,7 @@ async function createWindow(): Promise { // otherwise electron emits a no handler registered warning. break; case ServiceToMainIpcMessage.SetUnreadCount: - log.log('Unread count:', unreadCount.parse(args[0])); + log.log('Unread count:', UnreadCount.parse(args[0])); break; default: log.error('Unknown IPC message:', channel, args); diff --git a/packages/main/src/infrastructure/ConfigPersistence.ts b/packages/main/src/infrastructure/ConfigPersistence.ts deleted file mode 100644 index 184fa8d..0000000 --- a/packages/main/src/infrastructure/ConfigPersistence.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 { Config } from '../stores/SharedStore'; -import type Disposer from '../utils/Disposer'; - -export type ReadConfigResult = - | { found: true; data: unknown } - | { found: false }; - -export default interface ConfigPersistence { - readConfig(): Promise; - - writeConfig(config: Config): Promise; - - watchConfig(callback: () => Promise, throttleMs: number): Disposer; -} diff --git a/packages/main/src/infrastructure/config/ConfigFile.ts b/packages/main/src/infrastructure/config/ConfigFile.ts new file mode 100644 index 0000000..193a20d --- /dev/null +++ b/packages/main/src/infrastructure/config/ConfigFile.ts @@ -0,0 +1,142 @@ +/* + * 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'; + +import type { Config } from '../../stores/SharedStore'; +import type Disposer from '../../utils/Disposer'; +import { getLogger } from '../../utils/log'; + +import type ConfigRepository from './ConfigRepository'; +import type ReadConfigResult from './ReadConfigResult'; + +const log = getLogger('ConfigFile'); + +export default class ConfigFile implements ConfigRepository { + readonly #userDataDir: string; + + readonly #configFileName: string; + + readonly #configFilePath: string; + + #writingConfig = false; + + #timeLastWritten: Date | undefined; + + constructor(userDataDir: string, configFileName = 'config.json5') { + this.#userDataDir = userDataDir; + this.#configFileName = configFileName; + this.#configFilePath = path.join(userDataDir, 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: Config): 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/infrastructure/config/ConfigRepository.ts b/packages/main/src/infrastructure/config/ConfigRepository.ts new file mode 100644 index 0000000..0ce7fc1 --- /dev/null +++ b/packages/main/src/infrastructure/config/ConfigRepository.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 { Config } from '../../stores/SharedStore'; +import type Disposer from '../../utils/Disposer'; + +export type ReadConfigResult = + | { found: true; data: unknown } + | { found: false }; + +export default interface ConfigPersistence { + readConfig(): Promise; + + writeConfig(config: Config): Promise; + + watchConfig(callback: () => Promise, throttleMs: number): Disposer; +} diff --git a/packages/main/src/infrastructure/config/ReadConfigResult.ts b/packages/main/src/infrastructure/config/ReadConfigResult.ts new file mode 100644 index 0000000..3b3ee55 --- /dev/null +++ b/packages/main/src/infrastructure/config/ReadConfigResult.ts @@ -0,0 +1,23 @@ +/* + * Copyright (C) 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 + */ + +type ReadConfigResult = { found: true; data: unknown } | { found: false }; + +export default ReadConfigResult; diff --git a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts deleted file mode 100644 index 88d8bf8..0000000 --- a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts +++ /dev/null @@ -1,138 +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 { Config } from '../../stores/SharedStore'; -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: Config): 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 deleted file mode 100644 index fd8dd94..0000000 --- a/packages/main/src/init.ts +++ /dev/null @@ -1,43 +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 { app } from 'electron'; - -import initConfig from './controllers/initConfig'; -import initNativeTheme from './controllers/initNativeTheme'; -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 FileBasedConfigPersistence( - app.getPath('userData'), - ); - const disposeConfigController = await initConfig( - store.shared, - configPersistenceService, - ); - const disposeNativeThemeController = initNativeTheme(store.shared); - - return () => { - disposeNativeThemeController(); - disposeConfigController(); - }; -} diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts new file mode 100644 index 0000000..50e561d --- /dev/null +++ b/packages/main/src/initReactions.ts @@ -0,0 +1,43 @@ +/* + * 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 ConfigFile from './infrastructure/config/ConfigFile'; +import synchronizeConfig from './reactions/synchronizeConfig'; +import synchronizeNativeTheme from './reactions/synchronizeNativeTheme'; +import type MainStore from './stores/MainStore'; +import type Disposer from './utils/Disposer'; + +export default async function initReactions( + store: MainStore, +): Promise { + const configRepository = new ConfigFile(app.getPath('userData')); + const disposeConfigController = await synchronizeConfig( + store.shared, + configRepository, + ); + const disposeNativeThemeController = synchronizeNativeTheme(store.shared); + + return () => { + disposeNativeThemeController(); + disposeConfigController(); + }; +} diff --git a/packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts b/packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts new file mode 100644 index 0000000..c145bf3 --- /dev/null +++ b/packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts @@ -0,0 +1,225 @@ +/* + * 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 { jest } from '@jest/globals'; +import { mocked } from 'jest-mock'; +import ms from 'ms'; + +import type ConfigRepository from '../../infrastructure/config/ConfigRepository'; +import SharedStore from '../../stores/SharedStore'; +import type Disposer from '../../utils/Disposer'; +import { silenceLogger } from '../../utils/log'; +import synchronizeConfig from '../synchronizeConfig'; + +let store: SharedStore; +const repository: ConfigRepository = { + readConfig: jest.fn(), + writeConfig: jest.fn(), + watchConfig: jest.fn(), +}; +const lessThanThrottleMs = ms('0.1s'); +const throttleMs = ms('1s'); + +beforeAll(() => { + jest.useFakeTimers(); + silenceLogger(); +}); + +beforeEach(() => { + store = SharedStore.create(); +}); + +describe('when synchronizeializing', () => { + describe('when there is no config file', () => { + beforeEach(() => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: false, + }); + }); + + it('should create a new config file', async () => { + await synchronizeConfig(store, repository); + expect(repository.writeConfig).toHaveBeenCalledTimes(1); + }); + + it('should bail if there is an an error creating the config file', async () => { + mocked(repository.writeConfig).mockRejectedValue(new Error('boo')); + await expect(() => + synchronizeConfig(store, repository), + ).rejects.toBeInstanceOf(Error); + }); + }); + + describe('when there is a valid config file', () => { + beforeEach(() => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + // Use a default empty config file to not trigger config rewrite. + ...store.config, + themeSource: 'dark', + }, + }); + }); + + it('should read the existing config file is there is one', async () => { + await synchronizeConfig(store, repository); + expect(repository.writeConfig).not.toHaveBeenCalled(); + expect(store.settings.themeSource).toBe('dark'); + }); + + it('should bail if it cannot set up a watcher', async () => { + mocked(repository.watchConfig).mockImplementationOnce(() => { + throw new Error('boo'); + }); + await expect(() => + synchronizeConfig(store, repository), + ).rejects.toBeInstanceOf(Error); + }); + }); + + it('should update the config file if new details are added during read', async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: 'light', + profile: { + name: 'Test profile', + }, + }, + }); + await synchronizeConfig(store, repository); + expect(repository.writeConfig).toHaveBeenCalledTimes(1); + }); + + it('should not apply an invalid config file but should not overwrite it', async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: -1, + }, + }); + await synchronizeConfig(store, repository); + expect(store.settings.themeSource).not.toBe(-1); + expect(repository.writeConfig).not.toHaveBeenCalled(); + }); + + it('should bail if it cannot determine whether there is a config file', async () => { + mocked(repository.readConfig).mockRejectedValue(new Error('boo')); + await expect(() => + synchronizeConfig(store, repository), + ).rejects.toBeInstanceOf(Error); + }); +}); + +describe('when it has loaded the config', () => { + let sutDisposer: Disposer; + const watcherDisposer: Disposer = jest.fn(); + let configChangedCallback: () => Promise; + + beforeEach(async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: store.config, + }); + mocked(repository.watchConfig).mockReturnValueOnce(watcherDisposer); + sutDisposer = await synchronizeConfig(store, repository, throttleMs); + [[configChangedCallback]] = mocked(repository.watchConfig).mock.calls; + jest.resetAllMocks(); + }); + + it('should throttle saving changes to the config file', () => { + mocked(repository.writeConfig).mockResolvedValue(); + store.settings.setThemeSource('dark'); + jest.advanceTimersByTime(lessThanThrottleMs); + store.settings.setThemeSource('light'); + jest.advanceTimersByTime(throttleMs); + expect(repository.writeConfig).toHaveBeenCalledTimes(1); + }); + + it('should handle config writing errors gracefully', () => { + mocked(repository.writeConfig).mockRejectedValue(new Error('boo')); + store.settings.setThemeSource('dark'); + jest.advanceTimersByTime(throttleMs); + expect(repository.writeConfig).toHaveBeenCalledTimes(1); + }); + + it('should read the config file when it has changed', async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + // Use a default empty config file to not trigger config rewrite. + ...store.config, + themeSource: 'dark', + }, + }); + await configChangedCallback(); + // Do not write back the changes we have just read. + expect(repository.writeConfig).not.toHaveBeenCalled(); + expect(store.settings.themeSource).toBe('dark'); + }); + + it('should update the config file if new details are added', async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: 'light', + profile: { + name: 'Test profile', + }, + }, + }); + await configChangedCallback(); + expect(repository.writeConfig).toHaveBeenCalledTimes(1); + }); + + it('should not apply an invalid config file when it has changed but should not overwrite it', async () => { + mocked(repository.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: -1, + }, + }); + await configChangedCallback(); + expect(store.settings.themeSource).not.toBe(-1); + expect(repository.writeConfig).not.toHaveBeenCalled(); + }); + + it('should handle config reading errors gracefully', async () => { + mocked(repository.readConfig).mockRejectedValue(new Error('boo')); + await expect(configChangedCallback()).resolves.not.toThrow(); + }); + + describe('when it was disposed', () => { + beforeEach(() => { + sutDisposer(); + }); + + it('should dispose the watcher', () => { + expect(watcherDisposer).toHaveBeenCalled(); + }); + + it('should not listen to store changes any more', () => { + store.settings.setThemeSource('dark'); + jest.advanceTimersByTime(2 * throttleMs); + expect(repository.writeConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts b/packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts new file mode 100644 index 0000000..cf37568 --- /dev/null +++ b/packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts @@ -0,0 +1,77 @@ +/* + * 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 { jest } from '@jest/globals'; +import { mocked } from 'jest-mock'; + +import SharedStore from '../../stores/SharedStore'; +import type Disposer from '../../utils/Disposer'; + +let shouldUseDarkColors = false; + +jest.unstable_mockModule('electron', () => ({ + nativeTheme: { + themeSource: 'system', + get shouldUseDarkColors() { + return shouldUseDarkColors; + }, + on: jest.fn(), + off: jest.fn(), + }, +})); + +const { nativeTheme } = await import('electron'); +const { default: synchronizeNativeTheme } = await import( + '../synchronizeNativeTheme' +); + +let store: SharedStore; +let disposeSut: Disposer; + +beforeEach(() => { + store = SharedStore.create(); + disposeSut = synchronizeNativeTheme(store); +}); + +it('should register a nativeTheme updated listener', () => { + expect(nativeTheme.on).toHaveBeenCalledWith('updated', expect.anything()); +}); + +it('should synchronize themeSource changes to the nativeTheme', () => { + store.settings.setThemeSource('dark'); + expect(nativeTheme.themeSource).toBe('dark'); +}); + +it('should synchronize shouldUseDarkColors changes to the store', () => { + const listener = mocked(nativeTheme.on).mock.calls.find( + ([event]) => event === 'updated', + )![1]; + shouldUseDarkColors = true; + listener(); + expect(store.shouldUseDarkColors).toBe(true); +}); + +it('should remove the listener on dispose', () => { + const listener = mocked(nativeTheme.on).mock.calls.find( + ([event]) => event === 'updated', + )![1]; + disposeSut(); + expect(nativeTheme.off).toHaveBeenCalledWith('updated', listener); +}); diff --git a/packages/main/src/reactions/synchronizeConfig.ts b/packages/main/src/reactions/synchronizeConfig.ts new file mode 100644 index 0000000..480cc1a --- /dev/null +++ b/packages/main/src/reactions/synchronizeConfig.ts @@ -0,0 +1,98 @@ +/* + * 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 deepEqual from 'deep-equal'; +import { debounce } from 'lodash-es'; +import { reaction } from 'mobx'; +import ms from 'ms'; + +import type ConfigRepository from '../infrastructure/config/ConfigRepository'; +import type SharedStore from '../stores/SharedStore'; +import type { Config } from '../stores/SharedStore'; +import type Disposer from '../utils/Disposer'; +import { getLogger } from '../utils/log'; + +const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); + +const log = getLogger('synchronizeConfig'); + +export default async function synchronizeConfig( + sharedStore: SharedStore, + repository: ConfigRepository, + debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, +): Promise { + let lastConfigOnDisk: Config | undefined; + + async function writeConfig(): Promise { + const { config } = sharedStore; + await repository.writeConfig(config); + lastConfigOnDisk = config; + } + + async function readConfig(): Promise { + const result = await repository.readConfig(); + if (result.found) { + try { + // This cast is unsound if the config file is invalid, + // but we'll throw an error in the end anyways. + sharedStore.loadConfig(result.data as Config); + } catch (error) { + log.error('Failed to apply config snapshot', result.data, error); + return true; + } + lastConfigOnDisk = sharedStore.config; + if (!deepEqual(result.data, lastConfigOnDisk, { strict: true })) { + await writeConfig(); + } + } + return result.found; + } + + if (!(await readConfig())) { + log.info('Config file was not found'); + await writeConfig(); + log.info('Created config file'); + } + + const disposeReaction = reaction( + () => sharedStore.config, + debounce((config) => { + // We can compare snapshots by reference, since it is only recreated on store changes. + if (lastConfigOnDisk !== config) { + writeConfig().catch((error) => { + log.error('Failed to write config on config change', error); + }); + } + }, debounceTime), + ); + + const disposeWatcher = repository.watchConfig(async () => { + try { + await readConfig(); + } catch (error) { + log.error('Failed to read config', error); + } + }, debounceTime); + + return () => { + disposeWatcher(); + disposeReaction(); + }; +} diff --git a/packages/main/src/reactions/synchronizeNativeTheme.ts b/packages/main/src/reactions/synchronizeNativeTheme.ts new file mode 100644 index 0000000..8c4edb3 --- /dev/null +++ b/packages/main/src/reactions/synchronizeNativeTheme.ts @@ -0,0 +1,47 @@ +/* + * 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 { autorun } from 'mobx'; + +import type SharedStore from '../stores/SharedStore'; +import type Disposer from '../utils/Disposer'; +import { getLogger } from '../utils/log'; + +const log = getLogger('synchronizeNativeTheme'); + +export default function initNativeTheme(store: SharedStore): Disposer { + const disposeThemeSourceReaction = autorun(() => { + nativeTheme.themeSource = store.settings.themeSource; + log.debug('Set theme source:', store.settings.themeSource); + }); + + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + const shouldUseDarkColorsListener = () => { + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + log.debug('Set should use dark colors:', nativeTheme.shouldUseDarkColors); + }; + nativeTheme.on('updated', shouldUseDarkColorsListener); + + return () => { + nativeTheme.off('updated', shouldUseDarkColorsListener); + disposeThemeSourceReaction(); + }; +} diff --git a/packages/main/src/stores/GlobalSettings.ts b/packages/main/src/stores/GlobalSettings.ts index 1eb13b3..0a54aa3 100644 --- a/packages/main/src/stores/GlobalSettings.ts +++ b/packages/main/src/stores/GlobalSettings.ts @@ -19,18 +19,24 @@ */ import { - globalSettings as originalGlobalSettings, + GlobalSettings as GlobalSettingsBase, ThemeSource, } from '@sophie/shared'; import { Instance } from 'mobx-state-tree'; -export const globalSettings = originalGlobalSettings.actions((self) => ({ +const GlobalSettings = GlobalSettingsBase.actions((self) => ({ setThemeSource(mode: ThemeSource): void { self.themeSource = mode; }, })); -export interface GlobalSettings extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface GlobalSettings extends Instance {} + +export default GlobalSettings; export type { GlobalSettingsSnapshotIn, diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts index 18f5bf9..ff014c9 100644 --- a/packages/main/src/stores/MainStore.ts +++ b/packages/main/src/stores/MainStore.ts @@ -21,12 +21,12 @@ import type { BrowserViewBounds } from '@sophie/shared'; import { applySnapshot, Instance, types } from 'mobx-state-tree'; -import { GlobalSettings } from './GlobalSettings'; -import { Profile } from './Profile'; -import { Service } from './Service'; -import { sharedStore } from './SharedStore'; +import GlobalSettings from './GlobalSettings'; +import Profile from './Profile'; +import Service from './Service'; +import SharedStore from './SharedStore'; -export const mainStore = types +const MainStore = types .model('MainStore', { browserViewBounds: types.optional( types.model('BrowserViewBounds', { @@ -37,7 +37,7 @@ export const mainStore = types }), {}, ), - shared: types.optional(sharedStore, {}), + shared: types.optional(SharedStore, {}), }) .views((self) => ({ get settings(): GlobalSettings { @@ -56,8 +56,14 @@ export const mainStore = types }, })); -export interface MainStore extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface MainStore extends Instance {} + +export default MainStore; export function createMainStore(): MainStore { - return mainStore.create(); + return MainStore.create(); } diff --git a/packages/main/src/stores/Profile.ts b/packages/main/src/stores/Profile.ts index 5f77fe4..ec2a64b 100644 --- a/packages/main/src/stores/Profile.ts +++ b/packages/main/src/stores/Profile.ts @@ -19,7 +19,7 @@ */ import { - profile as originalProfile, + Profile as ProfileBase, ProfileSettingsSnapshotIn, } from '@sophie/shared'; import { getSnapshot, Instance } from 'mobx-state-tree'; @@ -30,14 +30,20 @@ export interface ProfileConfig extends ProfileSettingsSnapshotIn { id?: string | undefined; } -export const profile = originalProfile.views((self) => ({ +const Profile = ProfileBase.views((self) => ({ get config(): ProfileConfig { const { id, settings } = self; return { ...getSnapshot(settings), id }; }, })); -export interface Profile extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface Profile extends Instance {} + +export default Profile; export type ProfileSettingsSnapshotWithId = [string, ProfileSettingsSnapshotIn]; diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts index 331805b..e70caa6 100644 --- a/packages/main/src/stores/Service.ts +++ b/packages/main/src/stores/Service.ts @@ -19,14 +19,14 @@ */ import type { UnreadCount } from '@sophie/service-shared'; -import { service as originalService } from '@sophie/shared'; +import { Service as ServiceBase } from '@sophie/shared'; import { Instance, getSnapshot, ReferenceIdentifier } from 'mobx-state-tree'; import generateId from '../utils/generateId'; import overrideProps from '../utils/overrideProps'; import { ProfileSettingsSnapshotWithId } from './Profile'; -import { serviceSettings, ServiceSettingsSnapshotIn } from './ServiceSettings'; +import ServiceSettings, { ServiceSettingsSnapshotIn } from './ServiceSettings'; export interface ServiceConfig extends Omit { @@ -35,8 +35,8 @@ export interface ServiceConfig profile?: ReferenceIdentifier | undefined; } -export const service = overrideProps(originalService, { - settings: serviceSettings, +const Service = overrideProps(ServiceBase, { + settings: ServiceSettings, }) .views((self) => ({ get config(): ServiceConfig { @@ -83,7 +83,13 @@ export const service = overrideProps(originalService, { }, })); -export interface Service extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface Service extends Instance {} + +export default Service; export type ServiceSettingsSnapshotWithId = [string, ServiceSettingsSnapshotIn]; diff --git a/packages/main/src/stores/ServiceSettings.ts b/packages/main/src/stores/ServiceSettings.ts index 960de9b..e6f48c6 100644 --- a/packages/main/src/stores/ServiceSettings.ts +++ b/packages/main/src/stores/ServiceSettings.ts @@ -18,18 +18,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { serviceSettings as originalServiceSettings } from '@sophie/shared'; +import { ServiceSettings as ServiceSettingsBase } from '@sophie/shared'; import { Instance, types } from 'mobx-state-tree'; import overrideProps from '../utils/overrideProps'; -import { profile } from './Profile'; +import Profile from './Profile'; -export const serviceSettings = overrideProps(originalServiceSettings, { - profile: types.reference(profile), +const ServiceSettings = overrideProps(ServiceSettingsBase, { + profile: types.reference(Profile), }); -export interface ServiceSettings extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface ServiceSettings extends Instance {} + +export default ServiceSettings; export type { ServiceSettingsSnapshotIn, diff --git a/packages/main/src/stores/SharedStore.ts b/packages/main/src/stores/SharedStore.ts index 499d1ee..c34af75 100644 --- a/packages/main/src/stores/SharedStore.ts +++ b/packages/main/src/stores/SharedStore.ts @@ -18,7 +18,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { sharedStore as originalSharedStore } from '@sophie/shared'; +import { SharedStore as SharedStoreBase } from '@sophie/shared'; import { applySnapshot, getSnapshot, @@ -28,18 +28,16 @@ import { IReferenceType, IStateTreeNode, IType, - resolveIdentifier, types, } from 'mobx-state-tree'; import { getLogger } from '../utils/log'; import overrideProps from '../utils/overrideProps'; -import { globalSettings, GlobalSettingsSnapshotIn } from './GlobalSettings'; -import { addMissingProfileIds, profile, ProfileConfig } from './Profile'; -import { +import GlobalSettings, { GlobalSettingsSnapshotIn } from './GlobalSettings'; +import Profile, { addMissingProfileIds, ProfileConfig } from './Profile'; +import Service, { addMissingServiceIdsAndProfiles, - service, ServiceConfig, } from './Service'; @@ -86,13 +84,13 @@ function applySettings< current.push(...toApply.map(([id]) => id)); } -export const sharedStore = overrideProps(originalSharedStore, { - settings: types.optional(globalSettings, {}), - profilesById: types.map(profile), - profiles: types.array(types.reference(profile)), - servicesById: types.map(service), - services: types.array(types.reference(service)), - selectedService: types.safeReference(service), +const SharedStore = overrideProps(SharedStoreBase, { + settings: types.optional(GlobalSettings, {}), + profilesById: types.map(Profile), + profiles: types.array(types.reference(Profile)), + servicesById: types.map(Service), + services: types.array(types.reference(Service)), + selectedService: types.safeReference(Service), }) .views((self) => ({ get config(): Config { @@ -142,7 +140,7 @@ export const sharedStore = overrideProps(originalSharedStore, { self.shouldUseDarkColors = shouldUseDarkColors; }, setSelectedServiceId(serviceId: string): void { - const serviceInstance = resolveIdentifier(service, self, serviceId); + const serviceInstance = self.servicesById.get(serviceId); if (serviceInstance === undefined) { log.warn('Trying to select unknown service', serviceId); return; @@ -152,7 +150,13 @@ export const sharedStore = overrideProps(originalSharedStore, { }, })); -export interface SharedStore extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface SharedStore extends Instance {} + +export default SharedStore; export type { SharedStoreSnapshotIn, diff --git a/packages/main/src/stores/__tests__/SharedStore.spec.ts b/packages/main/src/stores/__tests__/SharedStore.spec.ts index 3ea187c..dfd59a1 100644 --- a/packages/main/src/stores/__tests__/SharedStore.spec.ts +++ b/packages/main/src/stores/__tests__/SharedStore.spec.ts @@ -20,7 +20,7 @@ import type { ProfileConfig } from '../Profile'; import type { ServiceConfig } from '../Service'; -import { Config, sharedStore, SharedStore } from '../SharedStore'; +import SharedStore, { Config } from '../SharedStore'; const profileProps: ProfileConfig = { name: 'Test profile', @@ -34,7 +34,7 @@ const serviceProps: ServiceConfig = { let sut: SharedStore; beforeEach(() => { - sut = sharedStore.create(); + sut = SharedStore.create(); }); describe('loadConfig', () => { diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts index 6003c8b..8bdf07e 100644 --- a/packages/preload/src/contextBridge/createSophieRenderer.ts +++ b/packages/preload/src/contextBridge/createSophieRenderer.ts @@ -20,7 +20,6 @@ import { Action, - action, MainToRendererIpcMessage, RendererToMainIpcMessage, SharedStoreListener, @@ -32,30 +31,33 @@ import log from 'loglevel'; import type { IJsonPatch } from 'mobx-state-tree'; class SharedStoreConnector { - private onSharedStoreChangeCalled = false; + readonly #allowReplaceListener: boolean; - private listener: SharedStoreListener | undefined; + #onSharedStoreChangeCalled = false; - constructor(private readonly allowReplaceListener: boolean) { + #listener: SharedStoreListener | undefined; + + constructor(allowReplaceListener: boolean) { + this.#allowReplaceListener = allowReplaceListener; ipcRenderer.on( MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { try { // `mobx-state-tree` will validate the patch, so we can safely cast here. - this.listener?.onPatch(patch as IJsonPatch[]); + this.#listener?.onPatch(patch as IJsonPatch[]); } catch (error) { log.error('Shared store listener onPatch failed', error); - this.listener = undefined; + this.#listener = undefined; } }, ); } async onSharedStoreChange(listener: SharedStoreListener): Promise { - if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { + if (this.#onSharedStoreChangeCalled && !this.#allowReplaceListener) { throw new Error('Shared store change listener was already set'); } - this.onSharedStoreChangeCalled = true; + this.#onSharedStoreChangeCalled = true; let success = false; let snapshot: unknown; try { @@ -71,14 +73,14 @@ class SharedStoreConnector { } // `mobx-state-tree` will validate the snapshot, so we can safely cast here. listener.onSnapshot(snapshot as SharedStoreSnapshotIn); - this.listener = listener; + this.#listener = listener; } } function dispatchAction(actionToDispatch: Action): void { // Let the full zod parse error bubble up to the main world, // since all data it may contain was provided from the main world. - const parsedAction = action.parse(actionToDispatch); + const parsedAction = Action.parse(actionToDispatch); try { ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); } catch (error) { diff --git a/packages/renderer/src/components/StoreProvider.tsx b/packages/renderer/src/components/StoreProvider.tsx index 3360a43..de63083 100644 --- a/packages/renderer/src/components/StoreProvider.tsx +++ b/packages/renderer/src/components/StoreProvider.tsx @@ -20,9 +20,8 @@ import React, { createContext, useContext } from 'react'; -import type { RendererStore } from '../stores/RendererStore'; +import type RendererStore from '../stores/RendererStore'; -// eslint-disable-next-line unicorn/no-useless-undefined -- `createContext` expects 1 parameter. const StoreContext = createContext(undefined); export function useStore(): RendererStore { diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts index 4cbf6aa..c5a94df 100644 --- a/packages/renderer/src/stores/RendererStore.ts +++ b/packages/renderer/src/stores/RendererStore.ts @@ -20,18 +20,12 @@ import { BrowserViewBounds, - sharedStore, + SharedStore, Service, SophieRenderer, ThemeSource, } from '@sophie/shared'; -import { - applySnapshot, - applyPatch, - Instance, - types, - IJsonPatch, -} from 'mobx-state-tree'; +import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree'; import RendererEnv from '../env/RendererEnv'; import getEnv from '../env/getEnv'; @@ -39,9 +33,9 @@ import { getLogger } from '../utils/log'; const log = getLogger('RendererStore'); -export const rendererStore = types +const RendererStore = types .model('RendererStore', { - shared: types.optional(sharedStore, {}), + shared: types.optional(SharedStore, {}), }) .views((self) => ({ get services(): Service[] { @@ -79,7 +73,13 @@ export const rendererStore = types }, })); -export interface RendererStore extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface RendererStore extends Instance {} + +export default RendererStore; /** * Creates a new `RootStore` with a new environment and connects it to `ipc`. @@ -95,7 +95,7 @@ export function createAndConnectRendererStore( const env: RendererEnv = { dispatchMainAction: ipc.dispatchAction, }; - const store = rendererStore.create({}, env); + const store = RendererStore.create({}, env); ipc .onSharedStoreChange({ diff --git a/packages/renderer/vite.config.js b/packages/renderer/vite.config.js index cb0203c..63c4f77 100644 --- a/packages/renderer/vite.config.js +++ b/packages/renderer/vite.config.js @@ -48,6 +48,9 @@ export default { optimizeDeps: { exclude: ['@sophie/shared'], }, + define: { + __DEV__: JSON.stringify(isDevelopment), // For mobx + }, build: { target: chrome, assetsDir: '.', diff --git a/packages/service-preload/src/index.ts b/packages/service-preload/src/index.ts index 8b6630a..5383f42 100644 --- a/packages/service-preload/src/index.ts +++ b/packages/service-preload/src/index.ts @@ -18,7 +18,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ServiceToMainIpcMessage, webSource } from '@sophie/service-shared'; +import { ServiceToMainIpcMessage, WebSource } from '@sophie/service-shared'; import { ipcRenderer, webFrame } from 'electron'; if (webFrame.parent === null) { @@ -52,7 +52,7 @@ async function fetchAndExecuteInjectScript(): Promise { const apiExposedResponse: unknown = await ipcRenderer.invoke( ServiceToMainIpcMessage.ApiExposedInMainWorld, ); - const injectSource = webSource.parse(apiExposedResponse); + const injectSource = WebSource.parse(apiExposedResponse); // Isolated world 0 is the main world. await webFrame.executeJavaScriptInIsolatedWorld(0, [injectSource]); } diff --git a/packages/service-shared/src/index.ts b/packages/service-shared/src/index.ts index 94be734..a2e5ee5 100644 --- a/packages/service-shared/src/index.ts +++ b/packages/service-shared/src/index.ts @@ -20,5 +20,4 @@ export { MainToServiceIpcMessage, ServiceToMainIpcMessage } from './ipc'; -export type { UnreadCount, WebSource } from './schemas'; -export { unreadCount, webSource } from './schemas'; +export { UnreadCount, WebSource } from './schemas'; diff --git a/packages/service-shared/src/schemas.ts b/packages/service-shared/src/schemas.ts index 586750c..0b31eb7 100644 --- a/packages/service-shared/src/schemas.ts +++ b/packages/service-shared/src/schemas.ts @@ -20,16 +20,24 @@ import { z } from 'zod'; -export const unreadCount = z.object({ +export const UnreadCount = z.object({ direct: z.number().nonnegative().optional(), indirect: z.number().nonnegative().optional(), }); -export type UnreadCount = z.infer; +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the schema definition. +*/ +export type UnreadCount = z.infer; -export const webSource = z.object({ +export const WebSource = z.object({ code: z.string(), url: z.string().nonempty(), }); -export type WebSource = z.infer; +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the schema definition. +*/ +export type WebSource = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 55cf5ce..3d30488 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,40 +22,33 @@ export type { default as SophieRenderer } from './contextBridge/SophieRenderer'; export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc'; -export type { Action, BrowserViewBounds, ThemeSource } from './schemas'; -export { action, browserViewBounds, themeSource } from './schemas'; +export { Action, BrowserViewBounds, ThemeSource } from './schemas'; export type { - GlobalSettings, GlobalSettingsSnapshotIn, GlobalSettingsSnapshotOut, } from './stores/GlobalSettings'; -export { globalSettings } from './stores/GlobalSettings'; +export { default as GlobalSettings } from './stores/GlobalSettings'; -export type { Profile } from './stores/Profile'; -export { profile } from './stores/Profile'; +export { default as Profile } from './stores/Profile'; export type { - ProfileSettings, ProfileSettingsSnapshotIn, ProfileSettingsSnapshotOut, } from './stores/ProfileSettings'; -export { profileSettings } from './stores/ProfileSettings'; +export { default as ProfileSettings } from './stores/ProfileSettings'; -export type { Service } from './stores/Service'; -export { service } from './stores/Service'; +export { default as Service } from './stores/Service'; export type { - ServiceSettings, ServiceSettingsSnapshotIn, ServiceSettingsSnapshotOut, } from './stores/ServiceSettings'; -export { serviceSettings } from './stores/ServiceSettings'; +export { default as ServiceSettings } from './stores/ServiceSettings'; export type { - SharedStore, SharedStoreListener, SharedStoreSnapshotIn, SharedStoreSnapshotOut, } from './stores/SharedStore'; -export { sharedStore } from './stores/SharedStore'; +export { default as SharedStore } from './stores/SharedStore'; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 7fb9717..edf3741 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -20,43 +20,55 @@ import { z } from 'zod'; -const setSelectedServiceId = z.object({ +const SetSelectedServiceId = z.object({ action: z.literal('set-selected-service-id'), serviceId: z.string(), }); -export const browserViewBounds = z.object({ +export const BrowserViewBounds = z.object({ x: z.number().int().nonnegative(), y: z.number().int().nonnegative(), width: z.number().int().nonnegative(), height: z.number().int().nonnegative(), }); -export type BrowserViewBounds = z.infer; +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the schema definition. +*/ +export type BrowserViewBounds = z.infer; -const setBrowserViewBoundsAction = z.object({ +const SetBrowserViewBoundsAction = z.object({ action: z.literal('set-browser-view-bounds'), - browserViewBounds, + browserViewBounds: BrowserViewBounds, }); -export const themeSource = z.enum(['system', 'light', 'dark']); +export const ThemeSource = z.enum(['system', 'light', 'dark']); -export type ThemeSource = z.infer; +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the schema definition. +*/ +export type ThemeSource = z.infer; -const setThemeSourceAction = z.object({ +const SetThemeSourceAction = z.object({ action: z.literal('set-theme-source'), - themeSource, + themeSource: ThemeSource, }); -const reloadAllServicesAction = z.object({ +const ReloadAllServicesAction = z.object({ action: z.literal('reload-all-services'), }); -export const action = z.union([ - setSelectedServiceId, - setBrowserViewBoundsAction, - setThemeSourceAction, - reloadAllServicesAction, +export const Action = z.union([ + SetSelectedServiceId, + SetBrowserViewBoundsAction, + SetThemeSourceAction, + ReloadAllServicesAction, ]); -export type Action = z.infer; +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the schema definition. +*/ +export type Action = z.infer; diff --git a/packages/shared/src/stores/GlobalSettings.ts b/packages/shared/src/stores/GlobalSettings.ts index bd0155a..3a813b8 100644 --- a/packages/shared/src/stores/GlobalSettings.ts +++ b/packages/shared/src/stores/GlobalSettings.ts @@ -20,16 +20,22 @@ import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; -import { themeSource } from '../schemas'; +import { ThemeSource } from '../schemas'; -export const globalSettings = types.model('GlobalSettings', { - themeSource: types.optional(types.enumeration(themeSource.options), 'system'), +const GlobalSettings = types.model('GlobalSettings', { + themeSource: types.optional(types.enumeration(ThemeSource.options), 'system'), }); -export interface GlobalSettings extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface GlobalSettings extends Instance {} + +export default GlobalSettings; export interface GlobalSettingsSnapshotIn - extends SnapshotIn {} + extends SnapshotIn {} export interface GlobalSettingsSnapshotOut - extends SnapshotOut {} + extends SnapshotOut {} diff --git a/packages/shared/src/stores/Profile.ts b/packages/shared/src/stores/Profile.ts index bb058f6..256c33e 100644 --- a/packages/shared/src/stores/Profile.ts +++ b/packages/shared/src/stores/Profile.ts @@ -20,11 +20,17 @@ import { Instance, types } from 'mobx-state-tree'; -import { profileSettings } from './ProfileSettings'; +import ProfileSettings from './ProfileSettings'; -export const profile = types.model('Profile', { +const Profile = types.model('Profile', { id: types.identifier, - settings: profileSettings, + settings: ProfileSettings, }); -export interface Profile extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface Profile extends Instance {} + +export default Profile; diff --git a/packages/shared/src/stores/ProfileSettings.ts b/packages/shared/src/stores/ProfileSettings.ts index ec8da5f..9f2b27c 100644 --- a/packages/shared/src/stores/ProfileSettings.ts +++ b/packages/shared/src/stores/ProfileSettings.ts @@ -20,14 +20,20 @@ import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; -export const profileSettings = types.model('ProfileSettings', { +const ProfileSettings = types.model('ProfileSettings', { name: types.string, }); -export interface ProfileSettings extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface ProfileSettings extends Instance {} + +export default ProfileSettings; export interface ProfileSettingsSnapshotIn - extends SnapshotIn {} + extends SnapshotIn {} export interface ProfileSettingsSnapshotOut - extends SnapshotOut {} + extends SnapshotOut {} diff --git a/packages/shared/src/stores/Service.ts b/packages/shared/src/stores/Service.ts index 36acd3d..4a7334d 100644 --- a/packages/shared/src/stores/Service.ts +++ b/packages/shared/src/stores/Service.ts @@ -20,11 +20,11 @@ import { Instance, types } from 'mobx-state-tree'; -import { serviceSettings } from './ServiceSettings'; +import ServiceSettings from './ServiceSettings'; -export const service = types.model('Service', { +const Service = types.model('Service', { id: types.identifier, - settings: serviceSettings, + settings: ServiceSettings, currentUrl: types.maybe(types.string), canGoBack: false, canGoForward: false, @@ -37,4 +37,10 @@ export const service = types.model('Service', { indirectMessageCount: 0, }); -export interface Service extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface Service extends Instance {} + +export default Service; diff --git a/packages/shared/src/stores/ServiceSettings.ts b/packages/shared/src/stores/ServiceSettings.ts index 54cd7eb..6ba1dfa 100644 --- a/packages/shared/src/stores/ServiceSettings.ts +++ b/packages/shared/src/stores/ServiceSettings.ts @@ -20,19 +20,25 @@ import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; -import { profile } from './Profile'; +import Profile from './Profile'; -export const serviceSettings = types.model('ServiceSettings', { +const ServiceSettings = types.model('ServiceSettings', { name: types.string, - profile: types.reference(profile), + profile: types.reference(Profile), // TODO: Remove this once recipes are added. url: types.string, }); -export interface ServiceSettings extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface ServiceSettings extends Instance {} + +export default ServiceSettings; export interface ServiceSettingsSnapshotIn - extends SnapshotIn {} + extends SnapshotIn {} export interface ServiceSettingsSnapshotOut - extends SnapshotOut {} + extends SnapshotOut {} diff --git a/packages/shared/src/stores/SharedStore.ts b/packages/shared/src/stores/SharedStore.ts index f301b9d..0cac3a5 100644 --- a/packages/shared/src/stores/SharedStore.ts +++ b/packages/shared/src/stores/SharedStore.ts @@ -26,26 +26,32 @@ import { SnapshotOut, } from 'mobx-state-tree'; -import { globalSettings } from './GlobalSettings'; -import { profile } from './Profile'; -import { service } from './Service'; - -export const sharedStore = types.model('SharedStore', { - settings: types.optional(globalSettings, {}), - profilesById: types.map(profile), - profiles: types.array(types.reference(profile)), - servicesById: types.map(service), - services: types.array(types.reference(service)), - selectedService: types.safeReference(service), +import GlobalSettings from './GlobalSettings'; +import Profile from './Profile'; +import Service from './Service'; + +const SharedStore = types.model('SharedStore', { + settings: types.optional(GlobalSettings, {}), + profilesById: types.map(Profile), + profiles: types.array(types.reference(Profile)), + servicesById: types.map(Service), + services: types.array(types.reference(Service)), + selectedService: types.safeReference(Service), shouldUseDarkColors: false, }); -export interface SharedStore extends Instance {} +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface SharedStore extends Instance {} + +export default SharedStore; -export interface SharedStoreSnapshotIn extends SnapshotIn {} +export interface SharedStoreSnapshotIn extends SnapshotIn {} export interface SharedStoreSnapshotOut - extends SnapshotOut {} + extends SnapshotOut {} export interface SharedStoreListener { onSnapshot(snapshot: SharedStoreSnapshotIn): void; -- cgit v1.2.3-54-g00ecf