aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-01-27 00:17:22 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-02-08 21:43:17 +0100
commit9546dc2aa39ab096ccc723786e718a739d0bdaf9 (patch)
tree9c3afc6155cc59f6dd1235397230aaa15a5f8cec
parentrefactor: Apply shared store patches in batches (diff)
downloadsophie-9546dc2aa39ab096ccc723786e718a739d0bdaf9.tar.gz
sophie-9546dc2aa39ab096ccc723786e718a739d0bdaf9.tar.zst
sophie-9546dc2aa39ab096ccc723786e718a739d0bdaf9.zip
refactor: Coding conventions
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 <kristof@marussy.com>
-rw-r--r--.eslintrc.cjs9
-rw-r--r--config/getEsbuildConfig.js1
-rw-r--r--docs/architecture.md13
-rw-r--r--package.json1
-rw-r--r--packages/main/src/index.ts16
-rw-r--r--packages/main/src/infrastructure/config/ConfigFile.ts (renamed from packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts)70
-rw-r--r--packages/main/src/infrastructure/config/ConfigRepository.ts (renamed from packages/main/src/infrastructure/ConfigPersistence.ts)4
-rw-r--r--packages/main/src/infrastructure/config/ReadConfigResult.ts23
-rw-r--r--packages/main/src/initReactions.ts (renamed from packages/main/src/init.ts)22
-rw-r--r--packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts (renamed from packages/main/src/controllers/__tests__/initConfig.spec.ts)84
-rw-r--r--packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts (renamed from packages/main/src/controllers/__tests__/initNativeTheme.spec.ts)10
-rw-r--r--packages/main/src/reactions/synchronizeConfig.ts (renamed from packages/main/src/controllers/initConfig.ts)20
-rw-r--r--packages/main/src/reactions/synchronizeNativeTheme.ts (renamed from packages/main/src/controllers/initNativeTheme.ts)7
-rw-r--r--packages/main/src/stores/GlobalSettings.ts12
-rw-r--r--packages/main/src/stores/MainStore.ts22
-rw-r--r--packages/main/src/stores/Profile.ts12
-rw-r--r--packages/main/src/stores/Service.ts16
-rw-r--r--packages/main/src/stores/ServiceSettings.ts16
-rw-r--r--packages/main/src/stores/SharedStore.ts34
-rw-r--r--packages/main/src/stores/__tests__/SharedStore.spec.ts4
-rw-r--r--packages/preload/src/contextBridge/createSophieRenderer.ts22
-rw-r--r--packages/renderer/src/components/StoreProvider.tsx3
-rw-r--r--packages/renderer/src/stores/RendererStore.ts24
-rw-r--r--packages/renderer/vite.config.js3
-rw-r--r--packages/service-preload/src/index.ts4
-rw-r--r--packages/service-shared/src/index.ts3
-rw-r--r--packages/service-shared/src/schemas.ts16
-rw-r--r--packages/shared/src/index.ts21
-rw-r--r--packages/shared/src/schemas.ts44
-rw-r--r--packages/shared/src/stores/GlobalSettings.ts18
-rw-r--r--packages/shared/src/stores/Profile.ts14
-rw-r--r--packages/shared/src/stores/ProfileSettings.ts14
-rw-r--r--packages/shared/src/stores/Service.ts14
-rw-r--r--packages/shared/src/stores/ServiceSettings.ts18
-rw-r--r--packages/shared/src/stores/SharedStore.ts34
35 files changed, 382 insertions, 266 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 5587ea7..55a055d 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -44,6 +44,8 @@ module.exports = {
44 }, 44 },
45 }, 45 },
46 rules: { 46 rules: {
47 // TODO Flip this to `ignorePackages` once we are on `nodenext` resolution.
48 'import/extensions': ['error', 'never'],
47 'import/no-unresolved': 'error', 49 'import/no-unresolved': 'error',
48 'import/order': [ 50 'import/order': [
49 'error', 51 'error',
@@ -66,6 +68,13 @@ module.exports = {
66 ], 68 ],
67 // Airbnb prefers forEach. 69 // Airbnb prefers forEach.
68 'unicorn/no-array-for-each': 'off', 70 'unicorn/no-array-for-each': 'off',
71 // Typescript requires exlicit `undefined` arguments.
72 'unicorn/no-useless-undefined': [
73 'error',
74 {
75 checkArguments: false,
76 },
77 ],
69 // Common abbreviations are known and readable. 78 // Common abbreviations are known and readable.
70 'unicorn/prevent-abbreviations': 'off', 79 'unicorn/prevent-abbreviations': 'off',
71 }, 80 },
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) {
27 ...config, 27 ...config,
28 sourcemap: isDevelopment ? config.sourcemap || true : false, 28 sourcemap: isDevelopment ? config.sourcemap || true : false,
29 define: { 29 define: {
30 __DEV__: JSON.stringify(isDevelopment), // For mobx
30 'process.env.NODE_ENV': modeString, 31 'process.env.NODE_ENV': modeString,
31 'process.env.MODE': modeString, 32 'process.env.MODE': modeString,
32 'import.meta.env': JSON.stringify({ 33 '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
78To reduce the amount of code injected into service frames, service renderer processes contain no stores. 78To reduce the amount of code injected into service frames, service renderer processes contain no stores.
79Instead, they purely rely on IPC messages to invoke actions in the main process (but they are not notified of the result). 79Instead, they purely rely on IPC messages to invoke actions in the main process (but they are not notified of the result).
80 80
81## Controllers 81## Reactions
82 82
83In the main process, _controllers_ react to `MainStore` changes by invoking Electron APIs and subscribe to Electron events in order to invoke `MainStore` actions. 83In the main process, _reactions_ react to `MainStore` changes by invoking Electron APIs and subscribe to Electron events in order to invoke `MainStore` actions.
84 84
85For better testability, controllers may rely on _infrastructure services_ (wrappers) abstracting away the underlying Electron APIs. 85For better testability, reactions may rely on _infrastructure services_ (wrappers) abstracting away the underlying Electron APIs.
86Each infrastructure of the service has to come with a TypeScript interface and at least one implementation. 86Each infrastructure of the service has to come with a TypeScript interface and at least one implementation.
87In the tests, the default implementations of the interfaces are replaced by mocks. 87In the tests, the default implementations of the interfaces are replaced by mocks.
88 88
89The 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). 89The 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).
90
91**TODO:**
92While 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.
93 90
94## React 91## React
95 92
@@ -101,7 +98,7 @@ We must take care not to render anything in this area, since it will be entirely
101 98
102# Packages 99# Packages
103 100
104The code of Sophie is distirbuted between different Node packages according to how they are loaded into the application. 101The code of Sophie is distributed between different Node packages according to how they are loaded into the application.
105 102
106All packages except the renderer package are tree-shaken and bundled with [esbuild](https://esbuild.github.io/) for quicker loading. 103All packages except the renderer package are tree-shaken and bundled with [esbuild](https://esbuild.github.io/) for quicker loading.
107The web application in the renderer packages is tree-shaken and bundled with [vite](https://vitejs.dev/). 104The 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 @@
33 "lint:only": "yarn lint:eslint . --ext .cjs,.js,.jsx,.ts,.tsx", 33 "lint:only": "yarn lint:eslint . --ext .cjs,.js,.jsx,.ts,.tsx",
34 "lint:precommit": "yarn types && yarn lint:eslint --fix", 34 "lint:precommit": "yarn types && yarn lint:eslint --fix",
35 "lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=16384\" eslint", 35 "lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=16384\" eslint",
36 "lint:staged": "nano-staged",
36 "typecheck": "yarn types && yarn typecheck:ci", 37 "typecheck": "yarn types && yarn typecheck:ci",
37 "typecheck:ci": "yarn workspaces foreach -vp run typecheck:workspace", 38 "typecheck:ci": "yarn workspaces foreach -vp run typecheck:workspace",
38 "typecheck:workspace": "yarn g:typecheck", 39 "typecheck:workspace": "yarn g:typecheck",
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 @@
19 * SPDX-License-Identifier: AGPL-3.0-only 19 * SPDX-License-Identifier: AGPL-3.0-only
20 */ 20 */
21 21
22import { readFileSync } from 'node:fs';
23import { readFile } from 'node:fs/promises';
22import { arch } from 'node:os'; 24import { arch } from 'node:os';
23import path from 'node:path'; 25import path from 'node:path';
24import { URL } from 'node:url'; 26import { URL } from 'node:url';
25 27
26import { 28import {
27 ServiceToMainIpcMessage, 29 ServiceToMainIpcMessage,
28 unreadCount, 30 UnreadCount,
29 WebSource, 31 WebSource,
30} from '@sophie/service-shared'; 32} from '@sophie/service-shared';
31import { 33import {
32 action, 34 Action,
33 MainToRendererIpcMessage, 35 MainToRendererIpcMessage,
34 RendererToMainIpcMessage, 36 RendererToMainIpcMessage,
35} from '@sophie/shared'; 37} from '@sophie/shared';
36import { app, BrowserView, BrowserWindow, ipcMain } from 'electron'; 38import { app, BrowserView, BrowserWindow, ipcMain } from 'electron';
37import { ensureDirSync, readFile, readFileSync } from 'fs-extra'; 39import { ensureDirSync } from 'fs-extra';
38import { autorun } from 'mobx'; 40import { autorun } from 'mobx';
39import { getSnapshot, onAction, onPatch } from 'mobx-state-tree'; 41import { getSnapshot, onAction, onPatch } from 'mobx-state-tree';
40import osName from 'os-name'; 42import osName from 'os-name';
@@ -45,7 +47,7 @@ import {
45 installDevToolsExtensions, 47 installDevToolsExtensions,
46 openDevToolsWhenReady, 48 openDevToolsWhenReady,
47} from './devTools'; 49} from './devTools';
48import init from './init'; 50import initReactions from './initReactions';
49import { createMainStore } from './stores/MainStore'; 51import { createMainStore } from './stores/MainStore';
50import { getLogger } from './utils/log'; 52import { getLogger } from './utils/log';
51 53
@@ -128,7 +130,7 @@ let mainWindow: BrowserWindow | undefined;
128 130
129const store = createMainStore(); 131const store = createMainStore();
130 132
131init(store) 133initReactions(store)
132 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await. 134 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await.
133 .then((disposeCompositionRoot) => { 135 .then((disposeCompositionRoot) => {
134 app.on('will-quit', disposeCompositionRoot); 136 app.on('will-quit', disposeCompositionRoot);
@@ -267,7 +269,7 @@ async function createWindow(): Promise<unknown> {
267 return; 269 return;
268 } 270 }
269 try { 271 try {
270 const actionToDispatch = action.parse(rawAction); 272 const actionToDispatch = Action.parse(rawAction);
271 switch (actionToDispatch.action) { 273 switch (actionToDispatch.action) {
272 case 'set-selected-service-id': 274 case 'set-selected-service-id':
273 store.shared.setSelectedServiceId(actionToDispatch.serviceId); 275 store.shared.setSelectedServiceId(actionToDispatch.serviceId);
@@ -331,7 +333,7 @@ async function createWindow(): Promise<unknown> {
331 // otherwise electron emits a no handler registered warning. 333 // otherwise electron emits a no handler registered warning.
332 break; 334 break;
333 case ServiceToMainIpcMessage.SetUnreadCount: 335 case ServiceToMainIpcMessage.SetUnreadCount:
334 log.log('Unread count:', unreadCount.parse(args[0])); 336 log.log('Unread count:', UnreadCount.parse(args[0]));
335 break; 337 break;
336 default: 338 default:
337 log.error('Unknown IPC message:', channel, args); 339 log.error('Unknown IPC message:', channel, args);
diff --git a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts b/packages/main/src/infrastructure/config/ConfigFile.ts
index 88d8bf8..193a20d 100644
--- a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts
+++ b/packages/main/src/infrastructure/config/ConfigFile.ts
@@ -17,48 +17,52 @@
17 * 17 *
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20
20import { watch } from 'node:fs'; 21import { watch } from 'node:fs';
21import { readFile, stat, writeFile } from 'node:fs/promises'; 22import { readFile, stat, writeFile } from 'node:fs/promises';
22import path from 'node:path'; 23import path from 'node:path';
23 24
24import JSON5 from 'json5'; 25import JSON5 from 'json5';
25import throttle from 'lodash-es/throttle'; 26import { throttle } from 'lodash-es';
26 27
27import type { Config } from '../../stores/SharedStore'; 28import type { Config } from '../../stores/SharedStore';
28import type Disposer from '../../utils/Disposer'; 29import type Disposer from '../../utils/Disposer';
29import { getLogger } from '../../utils/log'; 30import { getLogger } from '../../utils/log';
30import type ConfigPersistence from '../ConfigPersistence';
31import type { ReadConfigResult } from '../ConfigPersistence';
32 31
33const log = getLogger('fileBasedConfigPersistence'); 32import type ConfigRepository from './ConfigRepository';
33import type ReadConfigResult from './ReadConfigResult';
34
35const log = getLogger('ConfigFile');
36
37export default class ConfigFile implements ConfigRepository {
38 readonly #userDataDir: string;
39
40 readonly #configFileName: string;
34 41
35export default class FileBasedConfigPersistence implements ConfigPersistence { 42 readonly #configFilePath: string;
36 private readonly configFilePath: string;
37 43
38 private writingConfig = false; 44 #writingConfig = false;
39 45
40 private timeLastWritten: Date | undefined; 46 #timeLastWritten: Date | undefined;
41 47
42 constructor( 48 constructor(userDataDir: string, configFileName = 'config.json5') {
43 private readonly userDataDir: string, 49 this.#userDataDir = userDataDir;
44 private readonly configFileName: string = 'config.json5', 50 this.#configFileName = configFileName;
45 ) { 51 this.#configFilePath = path.join(userDataDir, configFileName);
46 this.configFileName = configFileName;
47 this.configFilePath = path.join(this.userDataDir, this.configFileName);
48 } 52 }
49 53
50 async readConfig(): Promise<ReadConfigResult> { 54 async readConfig(): Promise<ReadConfigResult> {
51 let configStr: string; 55 let configStr: string;
52 try { 56 try {
53 configStr = await readFile(this.configFilePath, 'utf8'); 57 configStr = await readFile(this.#configFilePath, 'utf8');
54 } catch (error) { 58 } catch (error) {
55 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 59 if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
56 log.debug('Config file', this.configFilePath, 'was not found'); 60 log.debug('Config file', this.#configFilePath, 'was not found');
57 return { found: false }; 61 return { found: false };
58 } 62 }
59 throw error; 63 throw error;
60 } 64 }
61 log.info('Read config file', this.configFilePath); 65 log.info('Read config file', this.#configFilePath);
62 return { 66 return {
63 found: true, 67 found: true,
64 data: JSON5.parse(configStr), 68 data: JSON5.parse(configStr),
@@ -69,32 +73,32 @@ export default class FileBasedConfigPersistence implements ConfigPersistence {
69 const configJson = JSON5.stringify(configSnapshot, { 73 const configJson = JSON5.stringify(configSnapshot, {
70 space: 2, 74 space: 2,
71 }); 75 });
72 this.writingConfig = true; 76 this.#writingConfig = true;
73 try { 77 try {
74 await writeFile(this.configFilePath, configJson, 'utf8'); 78 await writeFile(this.#configFilePath, configJson, 'utf8');
75 const { mtime } = await stat(this.configFilePath); 79 const { mtime } = await stat(this.#configFilePath);
76 log.trace('Config file', this.configFilePath, 'last written at', mtime); 80 log.trace('Config file', this.#configFilePath, 'last written at', mtime);
77 this.timeLastWritten = mtime; 81 this.#timeLastWritten = mtime;
78 } finally { 82 } finally {
79 this.writingConfig = false; 83 this.#writingConfig = false;
80 } 84 }
81 log.info('Wrote config file', this.configFilePath); 85 log.info('Wrote config file', this.#configFilePath);
82 } 86 }
83 87
84 watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer { 88 watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer {
85 log.debug('Installing watcher for', this.userDataDir); 89 log.debug('Installing watcher for', this.#userDataDir);
86 90
87 const configChanged = throttle(async () => { 91 const configChanged = throttle(async () => {
88 let mtime: Date; 92 let mtime: Date;
89 try { 93 try {
90 const stats = await stat(this.configFilePath); 94 const stats = await stat(this.#configFilePath);
91 mtime = stats.mtime; 95 mtime = stats.mtime;
92 log.trace('Config file last modified at', mtime); 96 log.trace('Config file last modified at', mtime);
93 } catch (error) { 97 } catch (error) {
94 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 98 if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
95 log.debug( 99 log.debug(
96 'Config file', 100 'Config file',
97 this.configFilePath, 101 this.#configFilePath,
98 'was deleted after being changed', 102 'was deleted after being changed',
99 ); 103 );
100 return; 104 return;
@@ -102,27 +106,27 @@ export default class FileBasedConfigPersistence implements ConfigPersistence {
102 throw error; 106 throw error;
103 } 107 }
104 if ( 108 if (
105 !this.writingConfig && 109 !this.#writingConfig &&
106 (this.timeLastWritten === undefined || mtime > this.timeLastWritten) 110 (this.#timeLastWritten === undefined || mtime > this.#timeLastWritten)
107 ) { 111 ) {
108 log.debug( 112 log.debug(
109 'Found a config file modified at', 113 'Found a config file modified at',
110 mtime, 114 mtime,
111 'whish is newer than last written', 115 'whish is newer than last written',
112 this.timeLastWritten, 116 this.#timeLastWritten,
113 ); 117 );
114 await callback(); 118 await callback();
115 } 119 }
116 }, throttleMs); 120 }, throttleMs);
117 121
118 const watcher = watch(this.userDataDir, { 122 const watcher = watch(this.#userDataDir, {
119 persistent: false, 123 persistent: false,
120 }); 124 });
121 125
122 watcher.on('change', (eventType, filename) => { 126 watcher.on('change', (eventType, filename) => {
123 if ( 127 if (
124 eventType === 'change' && 128 eventType === 'change' &&
125 (filename === this.configFileName || filename === null) 129 (filename === this.#configFileName || filename === null)
126 ) { 130 ) {
127 configChanged()?.catch((err) => { 131 configChanged()?.catch((err) => {
128 log.error('Unhandled error while listening for config changes', err); 132 log.error('Unhandled error while listening for config changes', err);
@@ -131,7 +135,7 @@ export default class FileBasedConfigPersistence implements ConfigPersistence {
131 }); 135 });
132 136
133 return () => { 137 return () => {
134 log.trace('Removing watcher for', this.configFilePath); 138 log.trace('Removing watcher for', this.#configFilePath);
135 watcher.close(); 139 watcher.close();
136 }; 140 };
137 } 141 }
diff --git a/packages/main/src/infrastructure/ConfigPersistence.ts b/packages/main/src/infrastructure/config/ConfigRepository.ts
index 184fa8d..0ce7fc1 100644
--- a/packages/main/src/infrastructure/ConfigPersistence.ts
+++ b/packages/main/src/infrastructure/config/ConfigRepository.ts
@@ -18,8 +18,8 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { Config } from '../stores/SharedStore'; 21import type { Config } from '../../stores/SharedStore';
22import type Disposer from '../utils/Disposer'; 22import type Disposer from '../../utils/Disposer';
23 23
24export type ReadConfigResult = 24export type ReadConfigResult =
25 | { found: true; data: unknown } 25 | { found: true; data: unknown }
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 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21type ReadConfigResult = { found: true; data: unknown } | { found: false };
22
23export default ReadConfigResult;
diff --git a/packages/main/src/init.ts b/packages/main/src/initReactions.ts
index fd8dd94..50e561d 100644
--- a/packages/main/src/init.ts
+++ b/packages/main/src/initReactions.ts
@@ -20,21 +20,21 @@
20 20
21import { app } from 'electron'; 21import { app } from 'electron';
22 22
23import initConfig from './controllers/initConfig'; 23import ConfigFile from './infrastructure/config/ConfigFile';
24import initNativeTheme from './controllers/initNativeTheme'; 24import synchronizeConfig from './reactions/synchronizeConfig';
25import FileBasedConfigPersistence from './infrastructure/impl/FileBasedConfigPersistence'; 25import synchronizeNativeTheme from './reactions/synchronizeNativeTheme';
26import { MainStore } from './stores/MainStore'; 26import type MainStore from './stores/MainStore';
27import type Disposer from './utils/Disposer'; 27import type Disposer from './utils/Disposer';
28 28
29export default async function init(store: MainStore): Promise<Disposer> { 29export default async function initReactions(
30 const configPersistenceService = new FileBasedConfigPersistence( 30 store: MainStore,
31 app.getPath('userData'), 31): Promise<Disposer> {
32 ); 32 const configRepository = new ConfigFile(app.getPath('userData'));
33 const disposeConfigController = await initConfig( 33 const disposeConfigController = await synchronizeConfig(
34 store.shared, 34 store.shared,
35 configPersistenceService, 35 configRepository,
36 ); 36 );
37 const disposeNativeThemeController = initNativeTheme(store.shared); 37 const disposeNativeThemeController = synchronizeNativeTheme(store.shared);
38 38
39 return () => { 39 return () => {
40 disposeNativeThemeController(); 40 disposeNativeThemeController();
diff --git a/packages/main/src/controllers/__tests__/initConfig.spec.ts b/packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts
index fdd22c9..c145bf3 100644
--- a/packages/main/src/controllers/__tests__/initConfig.spec.ts
+++ b/packages/main/src/reactions/__tests__/synchronizeConfig.spec.ts
@@ -22,14 +22,14 @@ import { jest } from '@jest/globals';
22import { mocked } from 'jest-mock'; 22import { mocked } from 'jest-mock';
23import ms from 'ms'; 23import ms from 'ms';
24 24
25import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; 25import type ConfigRepository from '../../infrastructure/config/ConfigRepository';
26import { sharedStore, SharedStore } from '../../stores/SharedStore'; 26import SharedStore from '../../stores/SharedStore';
27import type Disposer from '../../utils/Disposer'; 27import type Disposer from '../../utils/Disposer';
28import { silenceLogger } from '../../utils/log'; 28import { silenceLogger } from '../../utils/log';
29import initConfig from '../initConfig'; 29import synchronizeConfig from '../synchronizeConfig';
30 30
31let store: SharedStore; 31let store: SharedStore;
32const persistenceService: ConfigPersistence = { 32const repository: ConfigRepository = {
33 readConfig: jest.fn(), 33 readConfig: jest.fn(),
34 writeConfig: jest.fn(), 34 writeConfig: jest.fn(),
35 watchConfig: jest.fn(), 35 watchConfig: jest.fn(),
@@ -43,35 +43,33 @@ beforeAll(() => {
43}); 43});
44 44
45beforeEach(() => { 45beforeEach(() => {
46 store = sharedStore.create(); 46 store = SharedStore.create();
47}); 47});
48 48
49describe('when initializing', () => { 49describe('when synchronizeializing', () => {
50 describe('when there is no config file', () => { 50 describe('when there is no config file', () => {
51 beforeEach(() => { 51 beforeEach(() => {
52 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 52 mocked(repository.readConfig).mockResolvedValueOnce({
53 found: false, 53 found: false,
54 }); 54 });
55 }); 55 });
56 56
57 it('should create a new config file', async () => { 57 it('should create a new config file', async () => {
58 await initConfig(store, persistenceService); 58 await synchronizeConfig(store, repository);
59 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 59 expect(repository.writeConfig).toHaveBeenCalledTimes(1);
60 }); 60 });
61 61
62 it('should bail if there is an an error creating the config file', async () => { 62 it('should bail if there is an an error creating the config file', async () => {
63 mocked(persistenceService.writeConfig).mockRejectedValue( 63 mocked(repository.writeConfig).mockRejectedValue(new Error('boo'));
64 new Error('boo'),
65 );
66 await expect(() => 64 await expect(() =>
67 initConfig(store, persistenceService), 65 synchronizeConfig(store, repository),
68 ).rejects.toBeInstanceOf(Error); 66 ).rejects.toBeInstanceOf(Error);
69 }); 67 });
70 }); 68 });
71 69
72 describe('when there is a valid config file', () => { 70 describe('when there is a valid config file', () => {
73 beforeEach(() => { 71 beforeEach(() => {
74 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 72 mocked(repository.readConfig).mockResolvedValueOnce({
75 found: true, 73 found: true,
76 data: { 74 data: {
77 // Use a default empty config file to not trigger config rewrite. 75 // Use a default empty config file to not trigger config rewrite.
@@ -82,23 +80,23 @@ describe('when initializing', () => {
82 }); 80 });
83 81
84 it('should read the existing config file is there is one', async () => { 82 it('should read the existing config file is there is one', async () => {
85 await initConfig(store, persistenceService); 83 await synchronizeConfig(store, repository);
86 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 84 expect(repository.writeConfig).not.toHaveBeenCalled();
87 expect(store.settings.themeSource).toBe('dark'); 85 expect(store.settings.themeSource).toBe('dark');
88 }); 86 });
89 87
90 it('should bail if it cannot set up a watcher', async () => { 88 it('should bail if it cannot set up a watcher', async () => {
91 mocked(persistenceService.watchConfig).mockImplementationOnce(() => { 89 mocked(repository.watchConfig).mockImplementationOnce(() => {
92 throw new Error('boo'); 90 throw new Error('boo');
93 }); 91 });
94 await expect(() => 92 await expect(() =>
95 initConfig(store, persistenceService), 93 synchronizeConfig(store, repository),
96 ).rejects.toBeInstanceOf(Error); 94 ).rejects.toBeInstanceOf(Error);
97 }); 95 });
98 }); 96 });
99 97
100 it('should update the config file if new details are added during read', async () => { 98 it('should update the config file if new details are added during read', async () => {
101 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 99 mocked(repository.readConfig).mockResolvedValueOnce({
102 found: true, 100 found: true,
103 data: { 101 data: {
104 themeSource: 'light', 102 themeSource: 'light',
@@ -107,26 +105,26 @@ describe('when initializing', () => {
107 }, 105 },
108 }, 106 },
109 }); 107 });
110 await initConfig(store, persistenceService); 108 await synchronizeConfig(store, repository);
111 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 109 expect(repository.writeConfig).toHaveBeenCalledTimes(1);
112 }); 110 });
113 111
114 it('should not apply an invalid config file but should not overwrite it', async () => { 112 it('should not apply an invalid config file but should not overwrite it', async () => {
115 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 113 mocked(repository.readConfig).mockResolvedValueOnce({
116 found: true, 114 found: true,
117 data: { 115 data: {
118 themeSource: -1, 116 themeSource: -1,
119 }, 117 },
120 }); 118 });
121 await initConfig(store, persistenceService); 119 await synchronizeConfig(store, repository);
122 expect(store.settings.themeSource).not.toBe(-1); 120 expect(store.settings.themeSource).not.toBe(-1);
123 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 121 expect(repository.writeConfig).not.toHaveBeenCalled();
124 }); 122 });
125 123
126 it('should bail if it cannot determine whether there is a config file', async () => { 124 it('should bail if it cannot determine whether there is a config file', async () => {
127 mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); 125 mocked(repository.readConfig).mockRejectedValue(new Error('boo'));
128 await expect(() => 126 await expect(() =>
129 initConfig(store, persistenceService), 127 synchronizeConfig(store, repository),
130 ).rejects.toBeInstanceOf(Error); 128 ).rejects.toBeInstanceOf(Error);
131 }); 129 });
132}); 130});
@@ -137,36 +135,34 @@ describe('when it has loaded the config', () => {
137 let configChangedCallback: () => Promise<void>; 135 let configChangedCallback: () => Promise<void>;
138 136
139 beforeEach(async () => { 137 beforeEach(async () => {
140 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 138 mocked(repository.readConfig).mockResolvedValueOnce({
141 found: true, 139 found: true,
142 data: store.config, 140 data: store.config,
143 }); 141 });
144 mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer); 142 mocked(repository.watchConfig).mockReturnValueOnce(watcherDisposer);
145 sutDisposer = await initConfig(store, persistenceService, throttleMs); 143 sutDisposer = await synchronizeConfig(store, repository, throttleMs);
146 [[configChangedCallback]] = mocked( 144 [[configChangedCallback]] = mocked(repository.watchConfig).mock.calls;
147 persistenceService.watchConfig,
148 ).mock.calls;
149 jest.resetAllMocks(); 145 jest.resetAllMocks();
150 }); 146 });
151 147
152 it('should throttle saving changes to the config file', () => { 148 it('should throttle saving changes to the config file', () => {
153 mocked(persistenceService.writeConfig).mockResolvedValue(); 149 mocked(repository.writeConfig).mockResolvedValue();
154 store.settings.setThemeSource('dark'); 150 store.settings.setThemeSource('dark');
155 jest.advanceTimersByTime(lessThanThrottleMs); 151 jest.advanceTimersByTime(lessThanThrottleMs);
156 store.settings.setThemeSource('light'); 152 store.settings.setThemeSource('light');
157 jest.advanceTimersByTime(throttleMs); 153 jest.advanceTimersByTime(throttleMs);
158 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 154 expect(repository.writeConfig).toHaveBeenCalledTimes(1);
159 }); 155 });
160 156
161 it('should handle config writing errors gracefully', () => { 157 it('should handle config writing errors gracefully', () => {
162 mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); 158 mocked(repository.writeConfig).mockRejectedValue(new Error('boo'));
163 store.settings.setThemeSource('dark'); 159 store.settings.setThemeSource('dark');
164 jest.advanceTimersByTime(throttleMs); 160 jest.advanceTimersByTime(throttleMs);
165 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 161 expect(repository.writeConfig).toHaveBeenCalledTimes(1);
166 }); 162 });
167 163
168 it('should read the config file when it has changed', async () => { 164 it('should read the config file when it has changed', async () => {
169 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 165 mocked(repository.readConfig).mockResolvedValueOnce({
170 found: true, 166 found: true,
171 data: { 167 data: {
172 // Use a default empty config file to not trigger config rewrite. 168 // Use a default empty config file to not trigger config rewrite.
@@ -176,12 +172,12 @@ describe('when it has loaded the config', () => {
176 }); 172 });
177 await configChangedCallback(); 173 await configChangedCallback();
178 // Do not write back the changes we have just read. 174 // Do not write back the changes we have just read.
179 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 175 expect(repository.writeConfig).not.toHaveBeenCalled();
180 expect(store.settings.themeSource).toBe('dark'); 176 expect(store.settings.themeSource).toBe('dark');
181 }); 177 });
182 178
183 it('should update the config file if new details are added', async () => { 179 it('should update the config file if new details are added', async () => {
184 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 180 mocked(repository.readConfig).mockResolvedValueOnce({
185 found: true, 181 found: true,
186 data: { 182 data: {
187 themeSource: 'light', 183 themeSource: 'light',
@@ -191,11 +187,11 @@ describe('when it has loaded the config', () => {
191 }, 187 },
192 }); 188 });
193 await configChangedCallback(); 189 await configChangedCallback();
194 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 190 expect(repository.writeConfig).toHaveBeenCalledTimes(1);
195 }); 191 });
196 192
197 it('should not apply an invalid config file when it has changed but should not overwrite it', async () => { 193 it('should not apply an invalid config file when it has changed but should not overwrite it', async () => {
198 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 194 mocked(repository.readConfig).mockResolvedValueOnce({
199 found: true, 195 found: true,
200 data: { 196 data: {
201 themeSource: -1, 197 themeSource: -1,
@@ -203,11 +199,11 @@ describe('when it has loaded the config', () => {
203 }); 199 });
204 await configChangedCallback(); 200 await configChangedCallback();
205 expect(store.settings.themeSource).not.toBe(-1); 201 expect(store.settings.themeSource).not.toBe(-1);
206 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 202 expect(repository.writeConfig).not.toHaveBeenCalled();
207 }); 203 });
208 204
209 it('should handle config reading errors gracefully', async () => { 205 it('should handle config reading errors gracefully', async () => {
210 mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); 206 mocked(repository.readConfig).mockRejectedValue(new Error('boo'));
211 await expect(configChangedCallback()).resolves.not.toThrow(); 207 await expect(configChangedCallback()).resolves.not.toThrow();
212 }); 208 });
213 209
@@ -223,7 +219,7 @@ describe('when it has loaded the config', () => {
223 it('should not listen to store changes any more', () => { 219 it('should not listen to store changes any more', () => {
224 store.settings.setThemeSource('dark'); 220 store.settings.setThemeSource('dark');
225 jest.advanceTimersByTime(2 * throttleMs); 221 jest.advanceTimersByTime(2 * throttleMs);
226 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 222 expect(repository.writeConfig).not.toHaveBeenCalled();
227 }); 223 });
228 }); 224 });
229}); 225});
diff --git a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts b/packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts
index 9107c78..cf37568 100644
--- a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts
+++ b/packages/main/src/reactions/__tests__/synchronizeNativeTheme.spec.ts
@@ -21,7 +21,7 @@
21import { jest } from '@jest/globals'; 21import { jest } from '@jest/globals';
22import { mocked } from 'jest-mock'; 22import { mocked } from 'jest-mock';
23 23
24import { sharedStore, SharedStore } from '../../stores/SharedStore'; 24import SharedStore from '../../stores/SharedStore';
25import type Disposer from '../../utils/Disposer'; 25import type Disposer from '../../utils/Disposer';
26 26
27let shouldUseDarkColors = false; 27let shouldUseDarkColors = false;
@@ -38,14 +38,16 @@ jest.unstable_mockModule('electron', () => ({
38})); 38}));
39 39
40const { nativeTheme } = await import('electron'); 40const { nativeTheme } = await import('electron');
41const { default: initNativeTheme } = await import('../initNativeTheme'); 41const { default: synchronizeNativeTheme } = await import(
42 '../synchronizeNativeTheme'
43);
42 44
43let store: SharedStore; 45let store: SharedStore;
44let disposeSut: Disposer; 46let disposeSut: Disposer;
45 47
46beforeEach(() => { 48beforeEach(() => {
47 store = sharedStore.create(); 49 store = SharedStore.create();
48 disposeSut = initNativeTheme(store); 50 disposeSut = synchronizeNativeTheme(store);
49}); 51});
50 52
51it('should register a nativeTheme updated listener', () => { 53it('should register a nativeTheme updated listener', () => {
diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/reactions/synchronizeConfig.ts
index 55bf6df..480cc1a 100644
--- a/packages/main/src/controllers/initConfig.ts
+++ b/packages/main/src/reactions/synchronizeConfig.ts
@@ -23,32 +23,31 @@ import { debounce } from 'lodash-es';
23import { reaction } from 'mobx'; 23import { reaction } from 'mobx';
24import ms from 'ms'; 24import ms from 'ms';
25 25
26import type ConfigPersistence from '../infrastructure/ConfigPersistence'; 26import type ConfigRepository from '../infrastructure/config/ConfigRepository';
27import { Config, SharedStore } from '../stores/SharedStore'; 27import type SharedStore from '../stores/SharedStore';
28import type { Config } from '../stores/SharedStore';
28import type Disposer from '../utils/Disposer'; 29import type Disposer from '../utils/Disposer';
29import { getLogger } from '../utils/log'; 30import { getLogger } from '../utils/log';
30 31
31const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); 32const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s');
32 33
33const log = getLogger('config'); 34const log = getLogger('synchronizeConfig');
34 35
35export default async function initConfig( 36export default async function synchronizeConfig(
36 sharedStore: SharedStore, 37 sharedStore: SharedStore,
37 persistenceService: ConfigPersistence, 38 repository: ConfigRepository,
38 debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, 39 debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME,
39): Promise<Disposer> { 40): Promise<Disposer> {
40 log.trace('Initializing config controller');
41
42 let lastConfigOnDisk: Config | undefined; 41 let lastConfigOnDisk: Config | undefined;
43 42
44 async function writeConfig(): Promise<void> { 43 async function writeConfig(): Promise<void> {
45 const { config } = sharedStore; 44 const { config } = sharedStore;
46 await persistenceService.writeConfig(config); 45 await repository.writeConfig(config);
47 lastConfigOnDisk = config; 46 lastConfigOnDisk = config;
48 } 47 }
49 48
50 async function readConfig(): Promise<boolean> { 49 async function readConfig(): Promise<boolean> {
51 const result = await persistenceService.readConfig(); 50 const result = await repository.readConfig();
52 if (result.found) { 51 if (result.found) {
53 try { 52 try {
54 // This cast is unsound if the config file is invalid, 53 // This cast is unsound if the config file is invalid,
@@ -84,7 +83,7 @@ export default async function initConfig(
84 }, debounceTime), 83 }, debounceTime),
85 ); 84 );
86 85
87 const disposeWatcher = persistenceService.watchConfig(async () => { 86 const disposeWatcher = repository.watchConfig(async () => {
88 try { 87 try {
89 await readConfig(); 88 await readConfig();
90 } catch (error) { 89 } catch (error) {
@@ -93,7 +92,6 @@ export default async function initConfig(
93 }, debounceTime); 92 }, debounceTime);
94 93
95 return () => { 94 return () => {
96 log.trace('Disposing config controller');
97 disposeWatcher(); 95 disposeWatcher();
98 disposeReaction(); 96 disposeReaction();
99 }; 97 };
diff --git a/packages/main/src/controllers/initNativeTheme.ts b/packages/main/src/reactions/synchronizeNativeTheme.ts
index ce7972a..8c4edb3 100644
--- a/packages/main/src/controllers/initNativeTheme.ts
+++ b/packages/main/src/reactions/synchronizeNativeTheme.ts
@@ -21,15 +21,13 @@
21import { nativeTheme } from 'electron'; 21import { nativeTheme } from 'electron';
22import { autorun } from 'mobx'; 22import { autorun } from 'mobx';
23 23
24import type { SharedStore } from '../stores/SharedStore'; 24import type SharedStore from '../stores/SharedStore';
25import type Disposer from '../utils/Disposer'; 25import type Disposer from '../utils/Disposer';
26import { getLogger } from '../utils/log'; 26import { getLogger } from '../utils/log';
27 27
28const log = getLogger('nativeTheme'); 28const log = getLogger('synchronizeNativeTheme');
29 29
30export default function initNativeTheme(store: SharedStore): Disposer { 30export default function initNativeTheme(store: SharedStore): Disposer {
31 log.trace('Initializing nativeTheme controller');
32
33 const disposeThemeSourceReaction = autorun(() => { 31 const disposeThemeSourceReaction = autorun(() => {
34 nativeTheme.themeSource = store.settings.themeSource; 32 nativeTheme.themeSource = store.settings.themeSource;
35 log.debug('Set theme source:', store.settings.themeSource); 33 log.debug('Set theme source:', store.settings.themeSource);
@@ -43,7 +41,6 @@ export default function initNativeTheme(store: SharedStore): Disposer {
43 nativeTheme.on('updated', shouldUseDarkColorsListener); 41 nativeTheme.on('updated', shouldUseDarkColorsListener);
44 42
45 return () => { 43 return () => {
46 log.trace('Disposing nativeTheme controller');
47 nativeTheme.off('updated', shouldUseDarkColorsListener); 44 nativeTheme.off('updated', shouldUseDarkColorsListener);
48 disposeThemeSourceReaction(); 45 disposeThemeSourceReaction();
49 }; 46 };
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 @@
19 */ 19 */
20 20
21import { 21import {
22 globalSettings as originalGlobalSettings, 22 GlobalSettings as GlobalSettingsBase,
23 ThemeSource, 23 ThemeSource,
24} from '@sophie/shared'; 24} from '@sophie/shared';
25import { Instance } from 'mobx-state-tree'; 25import { Instance } from 'mobx-state-tree';
26 26
27export const globalSettings = originalGlobalSettings.actions((self) => ({ 27const GlobalSettings = GlobalSettingsBase.actions((self) => ({
28 setThemeSource(mode: ThemeSource): void { 28 setThemeSource(mode: ThemeSource): void {
29 self.themeSource = mode; 29 self.themeSource = mode;
30 }, 30 },
31})); 31}));
32 32
33export interface GlobalSettings extends Instance<typeof globalSettings> {} 33/*
34 eslint-disable-next-line @typescript-eslint/no-redeclare --
35 Intentionally naming the type the same as the store definition.
36*/
37interface GlobalSettings extends Instance<typeof GlobalSettings> {}
38
39export default GlobalSettings;
34 40
35export type { 41export type {
36 GlobalSettingsSnapshotIn, 42 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 @@
21import type { BrowserViewBounds } from '@sophie/shared'; 21import type { BrowserViewBounds } from '@sophie/shared';
22import { applySnapshot, Instance, types } from 'mobx-state-tree'; 22import { applySnapshot, Instance, types } from 'mobx-state-tree';
23 23
24import { GlobalSettings } from './GlobalSettings'; 24import GlobalSettings from './GlobalSettings';
25import { Profile } from './Profile'; 25import Profile from './Profile';
26import { Service } from './Service'; 26import Service from './Service';
27import { sharedStore } from './SharedStore'; 27import SharedStore from './SharedStore';
28 28
29export const mainStore = types 29const MainStore = types
30 .model('MainStore', { 30 .model('MainStore', {
31 browserViewBounds: types.optional( 31 browserViewBounds: types.optional(
32 types.model('BrowserViewBounds', { 32 types.model('BrowserViewBounds', {
@@ -37,7 +37,7 @@ export const mainStore = types
37 }), 37 }),
38 {}, 38 {},
39 ), 39 ),
40 shared: types.optional(sharedStore, {}), 40 shared: types.optional(SharedStore, {}),
41 }) 41 })
42 .views((self) => ({ 42 .views((self) => ({
43 get settings(): GlobalSettings { 43 get settings(): GlobalSettings {
@@ -56,8 +56,14 @@ export const mainStore = types
56 }, 56 },
57 })); 57 }));
58 58
59export interface MainStore extends Instance<typeof mainStore> {} 59/*
60 eslint-disable-next-line @typescript-eslint/no-redeclare --
61 Intentionally naming the type the same as the store definition.
62*/
63interface MainStore extends Instance<typeof MainStore> {}
64
65export default MainStore;
60 66
61export function createMainStore(): MainStore { 67export function createMainStore(): MainStore {
62 return mainStore.create(); 68 return MainStore.create();
63} 69}
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 @@
19 */ 19 */
20 20
21import { 21import {
22 profile as originalProfile, 22 Profile as ProfileBase,
23 ProfileSettingsSnapshotIn, 23 ProfileSettingsSnapshotIn,
24} from '@sophie/shared'; 24} from '@sophie/shared';
25import { getSnapshot, Instance } from 'mobx-state-tree'; 25import { getSnapshot, Instance } from 'mobx-state-tree';
@@ -30,14 +30,20 @@ export interface ProfileConfig extends ProfileSettingsSnapshotIn {
30 id?: string | undefined; 30 id?: string | undefined;
31} 31}
32 32
33export const profile = originalProfile.views((self) => ({ 33const Profile = ProfileBase.views((self) => ({
34 get config(): ProfileConfig { 34 get config(): ProfileConfig {
35 const { id, settings } = self; 35 const { id, settings } = self;
36 return { ...getSnapshot(settings), id }; 36 return { ...getSnapshot(settings), id };
37 }, 37 },
38})); 38}));
39 39
40export interface Profile extends Instance<typeof profile> {} 40/*
41 eslint-disable-next-line @typescript-eslint/no-redeclare --
42 Intentionally naming the type the same as the store definition.
43*/
44interface Profile extends Instance<typeof Profile> {}
45
46export default Profile;
41 47
42export type ProfileSettingsSnapshotWithId = [string, ProfileSettingsSnapshotIn]; 48export type ProfileSettingsSnapshotWithId = [string, ProfileSettingsSnapshotIn];
43 49
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 @@
19 */ 19 */
20 20
21import type { UnreadCount } from '@sophie/service-shared'; 21import type { UnreadCount } from '@sophie/service-shared';
22import { service as originalService } from '@sophie/shared'; 22import { Service as ServiceBase } from '@sophie/shared';
23import { Instance, getSnapshot, ReferenceIdentifier } from 'mobx-state-tree'; 23import { Instance, getSnapshot, ReferenceIdentifier } from 'mobx-state-tree';
24 24
25import generateId from '../utils/generateId'; 25import generateId from '../utils/generateId';
26import overrideProps from '../utils/overrideProps'; 26import overrideProps from '../utils/overrideProps';
27 27
28import { ProfileSettingsSnapshotWithId } from './Profile'; 28import { ProfileSettingsSnapshotWithId } from './Profile';
29import { serviceSettings, ServiceSettingsSnapshotIn } from './ServiceSettings'; 29import ServiceSettings, { ServiceSettingsSnapshotIn } from './ServiceSettings';
30 30
31export interface ServiceConfig 31export interface ServiceConfig
32 extends Omit<ServiceSettingsSnapshotIn, 'profile'> { 32 extends Omit<ServiceSettingsSnapshotIn, 'profile'> {
@@ -35,8 +35,8 @@ export interface ServiceConfig
35 profile?: ReferenceIdentifier | undefined; 35 profile?: ReferenceIdentifier | undefined;
36} 36}
37 37
38export const service = overrideProps(originalService, { 38const Service = overrideProps(ServiceBase, {
39 settings: serviceSettings, 39 settings: ServiceSettings,
40}) 40})
41 .views((self) => ({ 41 .views((self) => ({
42 get config(): ServiceConfig { 42 get config(): ServiceConfig {
@@ -83,7 +83,13 @@ export const service = overrideProps(originalService, {
83 }, 83 },
84 })); 84 }));
85 85
86export interface Service extends Instance<typeof service> {} 86/*
87 eslint-disable-next-line @typescript-eslint/no-redeclare --
88 Intentionally naming the type the same as the store definition.
89*/
90interface Service extends Instance<typeof Service> {}
91
92export default Service;
87 93
88export type ServiceSettingsSnapshotWithId = [string, ServiceSettingsSnapshotIn]; 94export type ServiceSettingsSnapshotWithId = [string, ServiceSettingsSnapshotIn];
89 95
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 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { serviceSettings as originalServiceSettings } from '@sophie/shared'; 21import { ServiceSettings as ServiceSettingsBase } from '@sophie/shared';
22import { Instance, types } from 'mobx-state-tree'; 22import { Instance, types } from 'mobx-state-tree';
23 23
24import overrideProps from '../utils/overrideProps'; 24import overrideProps from '../utils/overrideProps';
25 25
26import { profile } from './Profile'; 26import Profile from './Profile';
27 27
28export const serviceSettings = overrideProps(originalServiceSettings, { 28const ServiceSettings = overrideProps(ServiceSettingsBase, {
29 profile: types.reference(profile), 29 profile: types.reference(Profile),
30}); 30});
31 31
32export interface ServiceSettings extends Instance<typeof serviceSettings> {} 32/*
33 eslint-disable-next-line @typescript-eslint/no-redeclare --
34 Intentionally naming the type the same as the store definition.
35*/
36interface ServiceSettings extends Instance<typeof ServiceSettings> {}
37
38export default ServiceSettings;
33 39
34export type { 40export type {
35 ServiceSettingsSnapshotIn, 41 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 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { sharedStore as originalSharedStore } from '@sophie/shared'; 21import { SharedStore as SharedStoreBase } from '@sophie/shared';
22import { 22import {
23 applySnapshot, 23 applySnapshot,
24 getSnapshot, 24 getSnapshot,
@@ -28,18 +28,16 @@ import {
28 IReferenceType, 28 IReferenceType,
29 IStateTreeNode, 29 IStateTreeNode,
30 IType, 30 IType,
31 resolveIdentifier,
32 types, 31 types,
33} from 'mobx-state-tree'; 32} from 'mobx-state-tree';
34 33
35import { getLogger } from '../utils/log'; 34import { getLogger } from '../utils/log';
36import overrideProps from '../utils/overrideProps'; 35import overrideProps from '../utils/overrideProps';
37 36
38import { globalSettings, GlobalSettingsSnapshotIn } from './GlobalSettings'; 37import GlobalSettings, { GlobalSettingsSnapshotIn } from './GlobalSettings';
39import { addMissingProfileIds, profile, ProfileConfig } from './Profile'; 38import Profile, { addMissingProfileIds, ProfileConfig } from './Profile';
40import { 39import Service, {
41 addMissingServiceIdsAndProfiles, 40 addMissingServiceIdsAndProfiles,
42 service,
43 ServiceConfig, 41 ServiceConfig,
44} from './Service'; 42} from './Service';
45 43
@@ -86,13 +84,13 @@ function applySettings<
86 current.push(...toApply.map(([id]) => id)); 84 current.push(...toApply.map(([id]) => id));
87} 85}
88 86
89export const sharedStore = overrideProps(originalSharedStore, { 87const SharedStore = overrideProps(SharedStoreBase, {
90 settings: types.optional(globalSettings, {}), 88 settings: types.optional(GlobalSettings, {}),
91 profilesById: types.map(profile), 89 profilesById: types.map(Profile),
92 profiles: types.array(types.reference(profile)), 90 profiles: types.array(types.reference(Profile)),
93 servicesById: types.map(service), 91 servicesById: types.map(Service),
94 services: types.array(types.reference(service)), 92 services: types.array(types.reference(Service)),
95 selectedService: types.safeReference(service), 93 selectedService: types.safeReference(Service),
96}) 94})
97 .views((self) => ({ 95 .views((self) => ({
98 get config(): Config { 96 get config(): Config {
@@ -142,7 +140,7 @@ export const sharedStore = overrideProps(originalSharedStore, {
142 self.shouldUseDarkColors = shouldUseDarkColors; 140 self.shouldUseDarkColors = shouldUseDarkColors;
143 }, 141 },
144 setSelectedServiceId(serviceId: string): void { 142 setSelectedServiceId(serviceId: string): void {
145 const serviceInstance = resolveIdentifier(service, self, serviceId); 143 const serviceInstance = self.servicesById.get(serviceId);
146 if (serviceInstance === undefined) { 144 if (serviceInstance === undefined) {
147 log.warn('Trying to select unknown service', serviceId); 145 log.warn('Trying to select unknown service', serviceId);
148 return; 146 return;
@@ -152,7 +150,13 @@ export const sharedStore = overrideProps(originalSharedStore, {
152 }, 150 },
153 })); 151 }));
154 152
155export interface SharedStore extends Instance<typeof sharedStore> {} 153/*
154 eslint-disable-next-line @typescript-eslint/no-redeclare --
155 Intentionally naming the type the same as the store definition.
156*/
157interface SharedStore extends Instance<typeof SharedStore> {}
158
159export default SharedStore;
156 160
157export type { 161export type {
158 SharedStoreSnapshotIn, 162 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 @@
20 20
21import type { ProfileConfig } from '../Profile'; 21import type { ProfileConfig } from '../Profile';
22import type { ServiceConfig } from '../Service'; 22import type { ServiceConfig } from '../Service';
23import { Config, sharedStore, SharedStore } from '../SharedStore'; 23import SharedStore, { Config } from '../SharedStore';
24 24
25const profileProps: ProfileConfig = { 25const profileProps: ProfileConfig = {
26 name: 'Test profile', 26 name: 'Test profile',
@@ -34,7 +34,7 @@ const serviceProps: ServiceConfig = {
34let sut: SharedStore; 34let sut: SharedStore;
35 35
36beforeEach(() => { 36beforeEach(() => {
37 sut = sharedStore.create(); 37 sut = SharedStore.create();
38}); 38});
39 39
40describe('loadConfig', () => { 40describe('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 @@
20 20
21import { 21import {
22 Action, 22 Action,
23 action,
24 MainToRendererIpcMessage, 23 MainToRendererIpcMessage,
25 RendererToMainIpcMessage, 24 RendererToMainIpcMessage,
26 SharedStoreListener, 25 SharedStoreListener,
@@ -32,30 +31,33 @@ import log from 'loglevel';
32import type { IJsonPatch } from 'mobx-state-tree'; 31import type { IJsonPatch } from 'mobx-state-tree';
33 32
34class SharedStoreConnector { 33class SharedStoreConnector {
35 private onSharedStoreChangeCalled = false; 34 readonly #allowReplaceListener: boolean;
36 35
37 private listener: SharedStoreListener | undefined; 36 #onSharedStoreChangeCalled = false;
38 37
39 constructor(private readonly allowReplaceListener: boolean) { 38 #listener: SharedStoreListener | undefined;
39
40 constructor(allowReplaceListener: boolean) {
41 this.#allowReplaceListener = allowReplaceListener;
40 ipcRenderer.on( 42 ipcRenderer.on(
41 MainToRendererIpcMessage.SharedStorePatch, 43 MainToRendererIpcMessage.SharedStorePatch,
42 (_event, patch) => { 44 (_event, patch) => {
43 try { 45 try {
44 // `mobx-state-tree` will validate the patch, so we can safely cast here. 46 // `mobx-state-tree` will validate the patch, so we can safely cast here.
45 this.listener?.onPatch(patch as IJsonPatch[]); 47 this.#listener?.onPatch(patch as IJsonPatch[]);
46 } catch (error) { 48 } catch (error) {
47 log.error('Shared store listener onPatch failed', error); 49 log.error('Shared store listener onPatch failed', error);
48 this.listener = undefined; 50 this.#listener = undefined;
49 } 51 }
50 }, 52 },
51 ); 53 );
52 } 54 }
53 55
54 async onSharedStoreChange(listener: SharedStoreListener): Promise<void> { 56 async onSharedStoreChange(listener: SharedStoreListener): Promise<void> {
55 if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { 57 if (this.#onSharedStoreChangeCalled && !this.#allowReplaceListener) {
56 throw new Error('Shared store change listener was already set'); 58 throw new Error('Shared store change listener was already set');
57 } 59 }
58 this.onSharedStoreChangeCalled = true; 60 this.#onSharedStoreChangeCalled = true;
59 let success = false; 61 let success = false;
60 let snapshot: unknown; 62 let snapshot: unknown;
61 try { 63 try {
@@ -71,14 +73,14 @@ class SharedStoreConnector {
71 } 73 }
72 // `mobx-state-tree` will validate the snapshot, so we can safely cast here. 74 // `mobx-state-tree` will validate the snapshot, so we can safely cast here.
73 listener.onSnapshot(snapshot as SharedStoreSnapshotIn); 75 listener.onSnapshot(snapshot as SharedStoreSnapshotIn);
74 this.listener = listener; 76 this.#listener = listener;
75 } 77 }
76} 78}
77 79
78function dispatchAction(actionToDispatch: Action): void { 80function dispatchAction(actionToDispatch: Action): void {
79 // Let the full zod parse error bubble up to the main world, 81 // Let the full zod parse error bubble up to the main world,
80 // since all data it may contain was provided from the main world. 82 // since all data it may contain was provided from the main world.
81 const parsedAction = action.parse(actionToDispatch); 83 const parsedAction = Action.parse(actionToDispatch);
82 try { 84 try {
83 ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); 85 ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction);
84 } catch (error) { 86 } 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 @@
20 20
21import React, { createContext, useContext } from 'react'; 21import React, { createContext, useContext } from 'react';
22 22
23import type { RendererStore } from '../stores/RendererStore'; 23import type RendererStore from '../stores/RendererStore';
24 24
25// eslint-disable-next-line unicorn/no-useless-undefined -- `createContext` expects 1 parameter.
26const StoreContext = createContext<RendererStore | undefined>(undefined); 25const StoreContext = createContext<RendererStore | undefined>(undefined);
27 26
28export function useStore(): RendererStore { 27export 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 @@
20 20
21import { 21import {
22 BrowserViewBounds, 22 BrowserViewBounds,
23 sharedStore, 23 SharedStore,
24 Service, 24 Service,
25 SophieRenderer, 25 SophieRenderer,
26 ThemeSource, 26 ThemeSource,
27} from '@sophie/shared'; 27} from '@sophie/shared';
28import { 28import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree';
29 applySnapshot,
30 applyPatch,
31 Instance,
32 types,
33 IJsonPatch,
34} from 'mobx-state-tree';
35 29
36import RendererEnv from '../env/RendererEnv'; 30import RendererEnv from '../env/RendererEnv';
37import getEnv from '../env/getEnv'; 31import getEnv from '../env/getEnv';
@@ -39,9 +33,9 @@ import { getLogger } from '../utils/log';
39 33
40const log = getLogger('RendererStore'); 34const log = getLogger('RendererStore');
41 35
42export const rendererStore = types 36const RendererStore = types
43 .model('RendererStore', { 37 .model('RendererStore', {
44 shared: types.optional(sharedStore, {}), 38 shared: types.optional(SharedStore, {}),
45 }) 39 })
46 .views((self) => ({ 40 .views((self) => ({
47 get services(): Service[] { 41 get services(): Service[] {
@@ -79,7 +73,13 @@ export const rendererStore = types
79 }, 73 },
80 })); 74 }));
81 75
82export interface RendererStore extends Instance<typeof rendererStore> {} 76/*
77 eslint-disable-next-line @typescript-eslint/no-redeclare --
78 Intentionally naming the type the same as the store definition.
79*/
80interface RendererStore extends Instance<typeof RendererStore> {}
81
82export default RendererStore;
83 83
84/** 84/**
85 * Creates a new `RootStore` with a new environment and connects it to `ipc`. 85 * Creates a new `RootStore` with a new environment and connects it to `ipc`.
@@ -95,7 +95,7 @@ export function createAndConnectRendererStore(
95 const env: RendererEnv = { 95 const env: RendererEnv = {
96 dispatchMainAction: ipc.dispatchAction, 96 dispatchMainAction: ipc.dispatchAction,
97 }; 97 };
98 const store = rendererStore.create({}, env); 98 const store = RendererStore.create({}, env);
99 99
100 ipc 100 ipc
101 .onSharedStoreChange({ 101 .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 {
48 optimizeDeps: { 48 optimizeDeps: {
49 exclude: ['@sophie/shared'], 49 exclude: ['@sophie/shared'],
50 }, 50 },
51 define: {
52 __DEV__: JSON.stringify(isDevelopment), // For mobx
53 },
51 build: { 54 build: {
52 target: chrome, 55 target: chrome,
53 assetsDir: '.', 56 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 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { ServiceToMainIpcMessage, webSource } from '@sophie/service-shared'; 21import { ServiceToMainIpcMessage, WebSource } from '@sophie/service-shared';
22import { ipcRenderer, webFrame } from 'electron'; 22import { ipcRenderer, webFrame } from 'electron';
23 23
24if (webFrame.parent === null) { 24if (webFrame.parent === null) {
@@ -52,7 +52,7 @@ async function fetchAndExecuteInjectScript(): Promise<void> {
52 const apiExposedResponse: unknown = await ipcRenderer.invoke( 52 const apiExposedResponse: unknown = await ipcRenderer.invoke(
53 ServiceToMainIpcMessage.ApiExposedInMainWorld, 53 ServiceToMainIpcMessage.ApiExposedInMainWorld,
54 ); 54 );
55 const injectSource = webSource.parse(apiExposedResponse); 55 const injectSource = WebSource.parse(apiExposedResponse);
56 // Isolated world 0 is the main world. 56 // Isolated world 0 is the main world.
57 await webFrame.executeJavaScriptInIsolatedWorld(0, [injectSource]); 57 await webFrame.executeJavaScriptInIsolatedWorld(0, [injectSource]);
58} 58}
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 @@
20 20
21export { MainToServiceIpcMessage, ServiceToMainIpcMessage } from './ipc'; 21export { MainToServiceIpcMessage, ServiceToMainIpcMessage } from './ipc';
22 22
23export type { UnreadCount, WebSource } from './schemas'; 23export { UnreadCount, WebSource } from './schemas';
24export { 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 @@
20 20
21import { z } from 'zod'; 21import { z } from 'zod';
22 22
23export const unreadCount = z.object({ 23export const UnreadCount = z.object({
24 direct: z.number().nonnegative().optional(), 24 direct: z.number().nonnegative().optional(),
25 indirect: z.number().nonnegative().optional(), 25 indirect: z.number().nonnegative().optional(),
26}); 26});
27 27
28export type UnreadCount = z.infer<typeof unreadCount>; 28/*
29 eslint-disable-next-line @typescript-eslint/no-redeclare --
30 Intentionally naming the type the same as the schema definition.
31*/
32export type UnreadCount = z.infer<typeof UnreadCount>;
29 33
30export const webSource = z.object({ 34export const WebSource = z.object({
31 code: z.string(), 35 code: z.string(),
32 url: z.string().nonempty(), 36 url: z.string().nonempty(),
33}); 37});
34 38
35export type WebSource = z.infer<typeof webSource>; 39/*
40 eslint-disable-next-line @typescript-eslint/no-redeclare --
41 Intentionally naming the type the same as the schema definition.
42*/
43export type WebSource = z.infer<typeof WebSource>;
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';
22 22
23export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc'; 23export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc';
24 24
25export type { Action, BrowserViewBounds, ThemeSource } from './schemas'; 25export { Action, BrowserViewBounds, ThemeSource } from './schemas';
26export { action, browserViewBounds, themeSource } from './schemas';
27 26
28export type { 27export type {
29 GlobalSettings,
30 GlobalSettingsSnapshotIn, 28 GlobalSettingsSnapshotIn,
31 GlobalSettingsSnapshotOut, 29 GlobalSettingsSnapshotOut,
32} from './stores/GlobalSettings'; 30} from './stores/GlobalSettings';
33export { globalSettings } from './stores/GlobalSettings'; 31export { default as GlobalSettings } from './stores/GlobalSettings';
34 32
35export type { Profile } from './stores/Profile'; 33export { default as Profile } from './stores/Profile';
36export { profile } from './stores/Profile';
37 34
38export type { 35export type {
39 ProfileSettings,
40 ProfileSettingsSnapshotIn, 36 ProfileSettingsSnapshotIn,
41 ProfileSettingsSnapshotOut, 37 ProfileSettingsSnapshotOut,
42} from './stores/ProfileSettings'; 38} from './stores/ProfileSettings';
43export { profileSettings } from './stores/ProfileSettings'; 39export { default as ProfileSettings } from './stores/ProfileSettings';
44 40
45export type { Service } from './stores/Service'; 41export { default as Service } from './stores/Service';
46export { service } from './stores/Service';
47 42
48export type { 43export type {
49 ServiceSettings,
50 ServiceSettingsSnapshotIn, 44 ServiceSettingsSnapshotIn,
51 ServiceSettingsSnapshotOut, 45 ServiceSettingsSnapshotOut,
52} from './stores/ServiceSettings'; 46} from './stores/ServiceSettings';
53export { serviceSettings } from './stores/ServiceSettings'; 47export { default as ServiceSettings } from './stores/ServiceSettings';
54 48
55export type { 49export type {
56 SharedStore,
57 SharedStoreListener, 50 SharedStoreListener,
58 SharedStoreSnapshotIn, 51 SharedStoreSnapshotIn,
59 SharedStoreSnapshotOut, 52 SharedStoreSnapshotOut,
60} from './stores/SharedStore'; 53} from './stores/SharedStore';
61export { sharedStore } from './stores/SharedStore'; 54export { 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 @@
20 20
21import { z } from 'zod'; 21import { z } from 'zod';
22 22
23const setSelectedServiceId = z.object({ 23const SetSelectedServiceId = z.object({
24 action: z.literal('set-selected-service-id'), 24 action: z.literal('set-selected-service-id'),
25 serviceId: z.string(), 25 serviceId: z.string(),
26}); 26});
27 27
28export const browserViewBounds = z.object({ 28export const BrowserViewBounds = z.object({
29 x: z.number().int().nonnegative(), 29 x: z.number().int().nonnegative(),
30 y: z.number().int().nonnegative(), 30 y: z.number().int().nonnegative(),
31 width: z.number().int().nonnegative(), 31 width: z.number().int().nonnegative(),
32 height: z.number().int().nonnegative(), 32 height: z.number().int().nonnegative(),
33}); 33});
34 34
35export type BrowserViewBounds = z.infer<typeof browserViewBounds>; 35/*
36 eslint-disable-next-line @typescript-eslint/no-redeclare --
37 Intentionally naming the type the same as the schema definition.
38*/
39export type BrowserViewBounds = z.infer<typeof BrowserViewBounds>;
36 40
37const setBrowserViewBoundsAction = z.object({ 41const SetBrowserViewBoundsAction = z.object({
38 action: z.literal('set-browser-view-bounds'), 42 action: z.literal('set-browser-view-bounds'),
39 browserViewBounds, 43 browserViewBounds: BrowserViewBounds,
40}); 44});
41 45
42export const themeSource = z.enum(['system', 'light', 'dark']); 46export const ThemeSource = z.enum(['system', 'light', 'dark']);
43 47
44export type ThemeSource = z.infer<typeof themeSource>; 48/*
49 eslint-disable-next-line @typescript-eslint/no-redeclare --
50 Intentionally naming the type the same as the schema definition.
51*/
52export type ThemeSource = z.infer<typeof ThemeSource>;
45 53
46const setThemeSourceAction = z.object({ 54const SetThemeSourceAction = z.object({
47 action: z.literal('set-theme-source'), 55 action: z.literal('set-theme-source'),
48 themeSource, 56 themeSource: ThemeSource,
49}); 57});
50 58
51const reloadAllServicesAction = z.object({ 59const ReloadAllServicesAction = z.object({
52 action: z.literal('reload-all-services'), 60 action: z.literal('reload-all-services'),
53}); 61});
54 62
55export const action = z.union([ 63export const Action = z.union([
56 setSelectedServiceId, 64 SetSelectedServiceId,
57 setBrowserViewBoundsAction, 65 SetBrowserViewBoundsAction,
58 setThemeSourceAction, 66 SetThemeSourceAction,
59 reloadAllServicesAction, 67 ReloadAllServicesAction,
60]); 68]);
61 69
62export type Action = z.infer<typeof action>; 70/*
71 eslint-disable-next-line @typescript-eslint/no-redeclare --
72 Intentionally naming the type the same as the schema definition.
73*/
74export type Action = z.infer<typeof Action>;
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 @@
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22 22
23import { themeSource } from '../schemas'; 23import { ThemeSource } from '../schemas';
24 24
25export const globalSettings = types.model('GlobalSettings', { 25const GlobalSettings = types.model('GlobalSettings', {
26 themeSource: types.optional(types.enumeration(themeSource.options), 'system'), 26 themeSource: types.optional(types.enumeration(ThemeSource.options), 'system'),
27}); 27});
28 28
29export interface GlobalSettings extends Instance<typeof globalSettings> {} 29/*
30 eslint-disable-next-line @typescript-eslint/no-redeclare --
31 Intentionally naming the type the same as the store definition.
32*/
33interface GlobalSettings extends Instance<typeof GlobalSettings> {}
34
35export default GlobalSettings;
30 36
31export interface GlobalSettingsSnapshotIn 37export interface GlobalSettingsSnapshotIn
32 extends SnapshotIn<typeof globalSettings> {} 38 extends SnapshotIn<typeof GlobalSettings> {}
33 39
34export interface GlobalSettingsSnapshotOut 40export interface GlobalSettingsSnapshotOut
35 extends SnapshotOut<typeof globalSettings> {} 41 extends SnapshotOut<typeof GlobalSettings> {}
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 @@
20 20
21import { Instance, types } from 'mobx-state-tree'; 21import { Instance, types } from 'mobx-state-tree';
22 22
23import { profileSettings } from './ProfileSettings'; 23import ProfileSettings from './ProfileSettings';
24 24
25export const profile = types.model('Profile', { 25const Profile = types.model('Profile', {
26 id: types.identifier, 26 id: types.identifier,
27 settings: profileSettings, 27 settings: ProfileSettings,
28}); 28});
29 29
30export interface Profile extends Instance<typeof profile> {} 30/*
31 eslint-disable-next-line @typescript-eslint/no-redeclare --
32 Intentionally naming the type the same as the store definition.
33*/
34interface Profile extends Instance<typeof Profile> {}
35
36export 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 @@
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22 22
23export const profileSettings = types.model('ProfileSettings', { 23const ProfileSettings = types.model('ProfileSettings', {
24 name: types.string, 24 name: types.string,
25}); 25});
26 26
27export interface ProfileSettings extends Instance<typeof profileSettings> {} 27/*
28 eslint-disable-next-line @typescript-eslint/no-redeclare --
29 Intentionally naming the type the same as the store definition.
30*/
31interface ProfileSettings extends Instance<typeof ProfileSettings> {}
32
33export default ProfileSettings;
28 34
29export interface ProfileSettingsSnapshotIn 35export interface ProfileSettingsSnapshotIn
30 extends SnapshotIn<typeof profileSettings> {} 36 extends SnapshotIn<typeof ProfileSettings> {}
31 37
32export interface ProfileSettingsSnapshotOut 38export interface ProfileSettingsSnapshotOut
33 extends SnapshotOut<typeof profileSettings> {} 39 extends SnapshotOut<typeof ProfileSettings> {}
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 @@
20 20
21import { Instance, types } from 'mobx-state-tree'; 21import { Instance, types } from 'mobx-state-tree';
22 22
23import { serviceSettings } from './ServiceSettings'; 23import ServiceSettings from './ServiceSettings';
24 24
25export const service = types.model('Service', { 25const Service = types.model('Service', {
26 id: types.identifier, 26 id: types.identifier,
27 settings: serviceSettings, 27 settings: ServiceSettings,
28 currentUrl: types.maybe(types.string), 28 currentUrl: types.maybe(types.string),
29 canGoBack: false, 29 canGoBack: false,
30 canGoForward: false, 30 canGoForward: false,
@@ -37,4 +37,10 @@ export const service = types.model('Service', {
37 indirectMessageCount: 0, 37 indirectMessageCount: 0,
38}); 38});
39 39
40export interface Service extends Instance<typeof service> {} 40/*
41 eslint-disable-next-line @typescript-eslint/no-redeclare --
42 Intentionally naming the type the same as the store definition.
43*/
44interface Service extends Instance<typeof Service> {}
45
46export 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 @@
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22 22
23import { profile } from './Profile'; 23import Profile from './Profile';
24 24
25export const serviceSettings = types.model('ServiceSettings', { 25const ServiceSettings = types.model('ServiceSettings', {
26 name: types.string, 26 name: types.string,
27 profile: types.reference(profile), 27 profile: types.reference(Profile),
28 // TODO: Remove this once recipes are added. 28 // TODO: Remove this once recipes are added.
29 url: types.string, 29 url: types.string,
30}); 30});
31 31
32export interface ServiceSettings extends Instance<typeof serviceSettings> {} 32/*
33 eslint-disable-next-line @typescript-eslint/no-redeclare --
34 Intentionally naming the type the same as the store definition.
35*/
36interface ServiceSettings extends Instance<typeof ServiceSettings> {}
37
38export default ServiceSettings;
33 39
34export interface ServiceSettingsSnapshotIn 40export interface ServiceSettingsSnapshotIn
35 extends SnapshotIn<typeof serviceSettings> {} 41 extends SnapshotIn<typeof ServiceSettings> {}
36 42
37export interface ServiceSettingsSnapshotOut 43export interface ServiceSettingsSnapshotOut
38 extends SnapshotOut<typeof serviceSettings> {} 44 extends SnapshotOut<typeof ServiceSettings> {}
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 {
26 SnapshotOut, 26 SnapshotOut,
27} from 'mobx-state-tree'; 27} from 'mobx-state-tree';
28 28
29import { globalSettings } from './GlobalSettings'; 29import GlobalSettings from './GlobalSettings';
30import { profile } from './Profile'; 30import Profile from './Profile';
31import { service } from './Service'; 31import Service from './Service';
32 32
33export const sharedStore = types.model('SharedStore', { 33const SharedStore = types.model('SharedStore', {
34 settings: types.optional(globalSettings, {}), 34 settings: types.optional(GlobalSettings, {}),
35 profilesById: types.map(profile), 35 profilesById: types.map(Profile),
36 profiles: types.array(types.reference(profile)), 36 profiles: types.array(types.reference(Profile)),
37 servicesById: types.map(service), 37 servicesById: types.map(Service),
38 services: types.array(types.reference(service)), 38 services: types.array(types.reference(Service)),
39 selectedService: types.safeReference(service), 39 selectedService: types.safeReference(Service),
40 shouldUseDarkColors: false, 40 shouldUseDarkColors: false,
41}); 41});
42 42
43export interface SharedStore extends Instance<typeof sharedStore> {} 43/*
44 eslint-disable-next-line @typescript-eslint/no-redeclare --
45 Intentionally naming the type the same as the store definition.
46*/
47interface SharedStore extends Instance<typeof SharedStore> {}
48
49export default SharedStore;
44 50
45export interface SharedStoreSnapshotIn extends SnapshotIn<typeof sharedStore> {} 51export interface SharedStoreSnapshotIn extends SnapshotIn<typeof SharedStore> {}
46 52
47export interface SharedStoreSnapshotOut 53export interface SharedStoreSnapshotOut
48 extends SnapshotOut<typeof sharedStore> {} 54 extends SnapshotOut<typeof SharedStore> {}
49 55
50export interface SharedStoreListener { 56export interface SharedStoreListener {
51 onSnapshot(snapshot: SharedStoreSnapshotIn): void; 57 onSnapshot(snapshot: SharedStoreSnapshotIn): void;