aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-01-23 17:12:47 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-02-08 21:43:17 +0100
commit044b2de8c7861504704468ba441d4a6a37eed8f7 (patch)
tree940d2b7946d07a0c69b5e3ad46c25c599cf2aca7
parentfeat: Add selected service field to SharedStore (diff)
downloadsophie-044b2de8c7861504704468ba441d4a6a37eed8f7.tar.gz
sophie-044b2de8c7861504704468ba441d4a6a37eed8f7.tar.zst
sophie-044b2de8c7861504704468ba441d4a6a37eed8f7.zip
refactor: Move runtime state into shared models
Now the runtime state lives inside the model (instead of being associated to the static settings via a map), which simplifies state management. Static settings are now located inside the runtime models, so we must create tests to make sure that the settings are being persisted correctly. The contents of the config file are now generated as a view of store (instead of a snapshot), which adds flexibility. Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/controllers/__tests__/initConfig.spec.ts45
-rw-r--r--packages/main/src/controllers/__tests__/initNativeTheme.spec.ts10
-rw-r--r--packages/main/src/controllers/initConfig.ts30
-rw-r--r--packages/main/src/controllers/initNativeTheme.ts8
-rw-r--r--packages/main/src/index.ts4
-rw-r--r--packages/main/src/infrastructure/ConfigPersistence.ts4
-rw-r--r--packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts4
-rw-r--r--packages/main/src/init.ts4
-rw-r--r--packages/main/src/stores/Config.ts58
-rw-r--r--packages/main/src/stores/GlobalSettings.ts (renamed from packages/renderer/src/stores/Service.ts)20
-rw-r--r--packages/main/src/stores/MainStore.ts39
-rw-r--r--packages/main/src/stores/Profile.ts45
-rw-r--r--packages/main/src/stores/RuntimeService.ts74
-rw-r--r--packages/main/src/stores/Service.ts109
-rw-r--r--packages/main/src/stores/ServiceSettings.ts (renamed from packages/renderer/src/stores/SharedStore.ts)18
-rw-r--r--packages/main/src/stores/SharedStore.ts112
-rw-r--r--packages/main/src/stores/__tests__/SharedStore.spec.ts (renamed from packages/main/src/stores/__tests__/Config.spec.ts)52
-rw-r--r--packages/main/src/utils/SettingsWithId.ts (renamed from packages/renderer/src/stores/Config.ts)15
-rw-r--r--packages/main/src/utils/overrideProps.ts62
-rw-r--r--packages/renderer/src/components/ServiceSwitcher.tsx4
-rw-r--r--packages/renderer/src/env/RendererEnv.ts4
-rw-r--r--packages/renderer/src/env/impl/RendererEnvImpl.ts55
-rw-r--r--packages/renderer/src/env/impl/__tests__/RendererEnvImpl.spec.ts87
-rw-r--r--packages/renderer/src/stores/RendererStore.ts24
-rw-r--r--packages/renderer/src/stores/__tests__/Service.spec.ts63
-rw-r--r--packages/shared/src/contextBridge/SophieRenderer.ts2
-rw-r--r--packages/shared/src/index.ts41
-rw-r--r--packages/shared/src/stores/GlobalSettings.ts (renamed from packages/shared/src/stores/Config.ts)17
-rw-r--r--packages/shared/src/stores/Profile.ts10
-rw-r--r--packages/shared/src/stores/ProfileSettings.ts33
-rw-r--r--packages/shared/src/stores/Service.ts23
-rw-r--r--packages/shared/src/stores/ServiceSettings.ts (renamed from packages/shared/src/stores/RuntimeService.ts)33
-rw-r--r--packages/shared/src/stores/SharedStore.ts9
33 files changed, 496 insertions, 622 deletions
diff --git a/packages/main/src/controllers/__tests__/initConfig.spec.ts b/packages/main/src/controllers/__tests__/initConfig.spec.ts
index dc00b9d..fdd22c9 100644
--- a/packages/main/src/controllers/__tests__/initConfig.spec.ts
+++ b/packages/main/src/controllers/__tests__/initConfig.spec.ts
@@ -20,16 +20,15 @@
20 20
21import { jest } from '@jest/globals'; 21import { jest } from '@jest/globals';
22import { mocked } from 'jest-mock'; 22import { mocked } from 'jest-mock';
23import { getSnapshot } from 'mobx-state-tree';
24import ms from 'ms'; 23import ms from 'ms';
25 24
26import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; 25import type ConfigPersistence from '../../infrastructure/ConfigPersistence';
27import { Config, config as configModel } from '../../stores/Config'; 26import { sharedStore, SharedStore } from '../../stores/SharedStore';
28import type Disposer from '../../utils/Disposer'; 27import type Disposer from '../../utils/Disposer';
29import { silenceLogger } from '../../utils/log'; 28import { silenceLogger } from '../../utils/log';
30import initConfig from '../initConfig'; 29import initConfig from '../initConfig';
31 30
32let config: Config; 31let store: SharedStore;
33const persistenceService: ConfigPersistence = { 32const persistenceService: ConfigPersistence = {
34 readConfig: jest.fn(), 33 readConfig: jest.fn(),
35 writeConfig: jest.fn(), 34 writeConfig: jest.fn(),
@@ -44,7 +43,7 @@ beforeAll(() => {
44}); 43});
45 44
46beforeEach(() => { 45beforeEach(() => {
47 config = configModel.create(); 46 store = sharedStore.create();
48}); 47});
49 48
50describe('when initializing', () => { 49describe('when initializing', () => {
@@ -56,7 +55,7 @@ describe('when initializing', () => {
56 }); 55 });
57 56
58 it('should create a new config file', async () => { 57 it('should create a new config file', async () => {
59 await initConfig(config, persistenceService); 58 await initConfig(store, persistenceService);
60 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 59 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1);
61 }); 60 });
62 61
@@ -65,7 +64,7 @@ describe('when initializing', () => {
65 new Error('boo'), 64 new Error('boo'),
66 ); 65 );
67 await expect(() => 66 await expect(() =>
68 initConfig(config, persistenceService), 67 initConfig(store, persistenceService),
69 ).rejects.toBeInstanceOf(Error); 68 ).rejects.toBeInstanceOf(Error);
70 }); 69 });
71 }); 70 });
@@ -76,16 +75,16 @@ describe('when initializing', () => {
76 found: true, 75 found: true,
77 data: { 76 data: {
78 // Use a default empty config file to not trigger config rewrite. 77 // Use a default empty config file to not trigger config rewrite.
79 ...getSnapshot(config), 78 ...store.config,
80 themeSource: 'dark', 79 themeSource: 'dark',
81 }, 80 },
82 }); 81 });
83 }); 82 });
84 83
85 it('should read the existing config file is there is one', async () => { 84 it('should read the existing config file is there is one', async () => {
86 await initConfig(config, persistenceService); 85 await initConfig(store, persistenceService);
87 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 86 expect(persistenceService.writeConfig).not.toHaveBeenCalled();
88 expect(config.themeSource).toBe('dark'); 87 expect(store.settings.themeSource).toBe('dark');
89 }); 88 });
90 89
91 it('should bail if it cannot set up a watcher', async () => { 90 it('should bail if it cannot set up a watcher', async () => {
@@ -93,7 +92,7 @@ describe('when initializing', () => {
93 throw new Error('boo'); 92 throw new Error('boo');
94 }); 93 });
95 await expect(() => 94 await expect(() =>
96 initConfig(config, persistenceService), 95 initConfig(store, persistenceService),
97 ).rejects.toBeInstanceOf(Error); 96 ).rejects.toBeInstanceOf(Error);
98 }); 97 });
99 }); 98 });
@@ -108,7 +107,7 @@ describe('when initializing', () => {
108 }, 107 },
109 }, 108 },
110 }); 109 });
111 await initConfig(config, persistenceService); 110 await initConfig(store, persistenceService);
112 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 111 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1);
113 }); 112 });
114 113
@@ -119,15 +118,15 @@ describe('when initializing', () => {
119 themeSource: -1, 118 themeSource: -1,
120 }, 119 },
121 }); 120 });
122 await initConfig(config, persistenceService); 121 await initConfig(store, persistenceService);
123 expect(config.themeSource).not.toBe(-1); 122 expect(store.settings.themeSource).not.toBe(-1);
124 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 123 expect(persistenceService.writeConfig).not.toHaveBeenCalled();
125 }); 124 });
126 125
127 it('should bail if it cannot determine whether there is a config file', async () => { 126 it('should bail if it cannot determine whether there is a config file', async () => {
128 mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); 127 mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo'));
129 await expect(() => 128 await expect(() =>
130 initConfig(config, persistenceService), 129 initConfig(store, persistenceService),
131 ).rejects.toBeInstanceOf(Error); 130 ).rejects.toBeInstanceOf(Error);
132 }); 131 });
133}); 132});
@@ -140,10 +139,10 @@ describe('when it has loaded the config', () => {
140 beforeEach(async () => { 139 beforeEach(async () => {
141 mocked(persistenceService.readConfig).mockResolvedValueOnce({ 140 mocked(persistenceService.readConfig).mockResolvedValueOnce({
142 found: true, 141 found: true,
143 data: getSnapshot(config), 142 data: store.config,
144 }); 143 });
145 mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer); 144 mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer);
146 sutDisposer = await initConfig(config, persistenceService, throttleMs); 145 sutDisposer = await initConfig(store, persistenceService, throttleMs);
147 [[configChangedCallback]] = mocked( 146 [[configChangedCallback]] = mocked(
148 persistenceService.watchConfig, 147 persistenceService.watchConfig,
149 ).mock.calls; 148 ).mock.calls;
@@ -152,16 +151,16 @@ describe('when it has loaded the config', () => {
152 151
153 it('should throttle saving changes to the config file', () => { 152 it('should throttle saving changes to the config file', () => {
154 mocked(persistenceService.writeConfig).mockResolvedValue(); 153 mocked(persistenceService.writeConfig).mockResolvedValue();
155 config.setThemeSource('dark'); 154 store.settings.setThemeSource('dark');
156 jest.advanceTimersByTime(lessThanThrottleMs); 155 jest.advanceTimersByTime(lessThanThrottleMs);
157 config.setThemeSource('light'); 156 store.settings.setThemeSource('light');
158 jest.advanceTimersByTime(throttleMs); 157 jest.advanceTimersByTime(throttleMs);
159 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 158 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1);
160 }); 159 });
161 160
162 it('should handle config writing errors gracefully', () => { 161 it('should handle config writing errors gracefully', () => {
163 mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); 162 mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo'));
164 config.setThemeSource('dark'); 163 store.settings.setThemeSource('dark');
165 jest.advanceTimersByTime(throttleMs); 164 jest.advanceTimersByTime(throttleMs);
166 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1); 165 expect(persistenceService.writeConfig).toHaveBeenCalledTimes(1);
167 }); 166 });
@@ -171,14 +170,14 @@ describe('when it has loaded the config', () => {
171 found: true, 170 found: true,
172 data: { 171 data: {
173 // Use a default empty config file to not trigger config rewrite. 172 // Use a default empty config file to not trigger config rewrite.
174 ...getSnapshot(config), 173 ...store.config,
175 themeSource: 'dark', 174 themeSource: 'dark',
176 }, 175 },
177 }); 176 });
178 await configChangedCallback(); 177 await configChangedCallback();
179 // Do not write back the changes we have just read. 178 // Do not write back the changes we have just read.
180 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 179 expect(persistenceService.writeConfig).not.toHaveBeenCalled();
181 expect(config.themeSource).toBe('dark'); 180 expect(store.settings.themeSource).toBe('dark');
182 }); 181 });
183 182
184 it('should update the config file if new details are added', async () => { 183 it('should update the config file if new details are added', async () => {
@@ -203,7 +202,7 @@ describe('when it has loaded the config', () => {
203 }, 202 },
204 }); 203 });
205 await configChangedCallback(); 204 await configChangedCallback();
206 expect(config.themeSource).not.toBe(-1); 205 expect(store.settings.themeSource).not.toBe(-1);
207 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 206 expect(persistenceService.writeConfig).not.toHaveBeenCalled();
208 }); 207 });
209 208
@@ -222,7 +221,7 @@ describe('when it has loaded the config', () => {
222 }); 221 });
223 222
224 it('should not listen to store changes any more', () => { 223 it('should not listen to store changes any more', () => {
225 config.setThemeSource('dark'); 224 store.settings.setThemeSource('dark');
226 jest.advanceTimersByTime(2 * throttleMs); 225 jest.advanceTimersByTime(2 * throttleMs);
227 expect(persistenceService.writeConfig).not.toHaveBeenCalled(); 226 expect(persistenceService.writeConfig).not.toHaveBeenCalled();
228 }); 227 });
diff --git a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts b/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts
index 16acca5..9107c78 100644
--- a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts
+++ b/packages/main/src/controllers/__tests__/initNativeTheme.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 { createMainStore, MainStore } from '../../stores/MainStore'; 24import { sharedStore, SharedStore } from '../../stores/SharedStore';
25import type Disposer from '../../utils/Disposer'; 25import type Disposer from '../../utils/Disposer';
26 26
27let shouldUseDarkColors = false; 27let shouldUseDarkColors = false;
@@ -40,11 +40,11 @@ jest.unstable_mockModule('electron', () => ({
40const { nativeTheme } = await import('electron'); 40const { nativeTheme } = await import('electron');
41const { default: initNativeTheme } = await import('../initNativeTheme'); 41const { default: initNativeTheme } = await import('../initNativeTheme');
42 42
43let store: MainStore; 43let store: SharedStore;
44let disposeSut: Disposer; 44let disposeSut: Disposer;
45 45
46beforeEach(() => { 46beforeEach(() => {
47 store = createMainStore(); 47 store = sharedStore.create();
48 disposeSut = initNativeTheme(store); 48 disposeSut = initNativeTheme(store);
49}); 49});
50 50
@@ -53,7 +53,7 @@ it('should register a nativeTheme updated listener', () => {
53}); 53});
54 54
55it('should synchronize themeSource changes to the nativeTheme', () => { 55it('should synchronize themeSource changes to the nativeTheme', () => {
56 store.config.setThemeSource('dark'); 56 store.settings.setThemeSource('dark');
57 expect(nativeTheme.themeSource).toBe('dark'); 57 expect(nativeTheme.themeSource).toBe('dark');
58}); 58});
59 59
@@ -63,7 +63,7 @@ it('should synchronize shouldUseDarkColors changes to the store', () => {
63 )![1]; 63 )![1];
64 shouldUseDarkColors = true; 64 shouldUseDarkColors = true;
65 listener(); 65 listener();
66 expect(store.shared.shouldUseDarkColors).toBe(true); 66 expect(store.shouldUseDarkColors).toBe(true);
67}); 67});
68 68
69it('should remove the listener on dispose', () => { 69it('should remove the listener on dispose', () => {
diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/controllers/initConfig.ts
index c8cd335..55bf6df 100644
--- a/packages/main/src/controllers/initConfig.ts
+++ b/packages/main/src/controllers/initConfig.ts
@@ -20,11 +20,11 @@
20 20
21import deepEqual from 'deep-equal'; 21import deepEqual from 'deep-equal';
22import { debounce } from 'lodash-es'; 22import { debounce } from 'lodash-es';
23import { getSnapshot, onSnapshot } from 'mobx-state-tree'; 23import { reaction } from 'mobx';
24import ms from 'ms'; 24import ms from 'ms';
25 25
26import type ConfigPersistence from '../infrastructure/ConfigPersistence'; 26import type ConfigPersistence from '../infrastructure/ConfigPersistence';
27import { Config, ConfigFileIn, ConfigSnapshotOut } from '../stores/Config'; 27import { Config, SharedStore } from '../stores/SharedStore';
28import type Disposer from '../utils/Disposer'; 28import type Disposer from '../utils/Disposer';
29import { getLogger } from '../utils/log'; 29import { getLogger } from '../utils/log';
30 30
@@ -33,18 +33,18 @@ const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s');
33const log = getLogger('config'); 33const log = getLogger('config');
34 34
35export default async function initConfig( 35export default async function initConfig(
36 config: Config, 36 sharedStore: SharedStore,
37 persistenceService: ConfigPersistence, 37 persistenceService: ConfigPersistence,
38 debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, 38 debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME,
39): Promise<Disposer> { 39): Promise<Disposer> {
40 log.trace('Initializing config controller'); 40 log.trace('Initializing config controller');
41 41
42 let lastSnapshotOnDisk: ConfigSnapshotOut | undefined; 42 let lastConfigOnDisk: Config | undefined;
43 43
44 async function writeConfig(): Promise<void> { 44 async function writeConfig(): Promise<void> {
45 const snapshot = getSnapshot(config); 45 const { config } = sharedStore;
46 await persistenceService.writeConfig(snapshot); 46 await persistenceService.writeConfig(config);
47 lastSnapshotOnDisk = snapshot; 47 lastConfigOnDisk = config;
48 } 48 }
49 49
50 async function readConfig(): Promise<boolean> { 50 async function readConfig(): Promise<boolean> {
@@ -53,13 +53,13 @@ export default async function initConfig(
53 try { 53 try {
54 // This cast is unsound if the config file is invalid, 54 // This cast is unsound if the config file is invalid,
55 // but we'll throw an error in the end anyways. 55 // but we'll throw an error in the end anyways.
56 config.loadFromConfigFile(result.data as ConfigFileIn); 56 sharedStore.loadConfig(result.data as Config);
57 } catch (error) { 57 } catch (error) {
58 log.error('Failed to apply config snapshot', result.data, error); 58 log.error('Failed to apply config snapshot', result.data, error);
59 return true; 59 return true;
60 } 60 }
61 lastSnapshotOnDisk = getSnapshot(config); 61 lastConfigOnDisk = sharedStore.config;
62 if (!deepEqual(result.data, lastSnapshotOnDisk, { strict: true })) { 62 if (!deepEqual(result.data, lastConfigOnDisk, { strict: true })) {
63 await writeConfig(); 63 await writeConfig();
64 } 64 }
65 } 65 }
@@ -72,11 +72,11 @@ export default async function initConfig(
72 log.info('Created config file'); 72 log.info('Created config file');
73 } 73 }
74 74
75 const disposeOnSnapshot = onSnapshot( 75 const disposeReaction = reaction(
76 config, 76 () => sharedStore.config,
77 debounce((snapshot) => { 77 debounce((config) => {
78 // We can compare snapshots by reference, since it is only recreated on store changes. 78 // We can compare snapshots by reference, since it is only recreated on store changes.
79 if (lastSnapshotOnDisk !== snapshot) { 79 if (lastConfigOnDisk !== config) {
80 writeConfig().catch((error) => { 80 writeConfig().catch((error) => {
81 log.error('Failed to write config on config change', error); 81 log.error('Failed to write config on config change', error);
82 }); 82 });
@@ -95,6 +95,6 @@ export default async function initConfig(
95 return () => { 95 return () => {
96 log.trace('Disposing config controller'); 96 log.trace('Disposing config controller');
97 disposeWatcher(); 97 disposeWatcher();
98 disposeOnSnapshot(); 98 disposeReaction();
99 }; 99 };
100} 100}
diff --git a/packages/main/src/controllers/initNativeTheme.ts b/packages/main/src/controllers/initNativeTheme.ts
index d2074ab..ce7972a 100644
--- a/packages/main/src/controllers/initNativeTheme.ts
+++ b/packages/main/src/controllers/initNativeTheme.ts
@@ -21,18 +21,18 @@
21import { nativeTheme } from 'electron'; 21import { nativeTheme } from 'electron';
22import { autorun } from 'mobx'; 22import { autorun } from 'mobx';
23 23
24import type { MainStore } from '../stores/MainStore'; 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('nativeTheme');
29 29
30export default function initNativeTheme(store: MainStore): Disposer { 30export default function initNativeTheme(store: SharedStore): Disposer {
31 log.trace('Initializing nativeTheme controller'); 31 log.trace('Initializing nativeTheme controller');
32 32
33 const disposeThemeSourceReaction = autorun(() => { 33 const disposeThemeSourceReaction = autorun(() => {
34 nativeTheme.themeSource = store.config.themeSource; 34 nativeTheme.themeSource = store.settings.themeSource;
35 log.debug('Set theme source:', store.config.themeSource); 35 log.debug('Set theme source:', store.settings.themeSource);
36 }); 36 });
37 37
38 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); 38 store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors);
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 66405ea..0978cd9 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -270,13 +270,13 @@ async function createWindow(): Promise<unknown> {
270 const actionToDispatch = action.parse(rawAction); 270 const actionToDispatch = action.parse(rawAction);
271 switch (actionToDispatch.action) { 271 switch (actionToDispatch.action) {
272 case 'set-selected-service-id': 272 case 'set-selected-service-id':
273 store.setSelectedServiceId(actionToDispatch.serviceId); 273 store.shared.setSelectedServiceId(actionToDispatch.serviceId);
274 break; 274 break;
275 case 'set-browser-view-bounds': 275 case 'set-browser-view-bounds':
276 store.setBrowserViewBounds(actionToDispatch.browserViewBounds); 276 store.setBrowserViewBounds(actionToDispatch.browserViewBounds);
277 break; 277 break;
278 case 'set-theme-source': 278 case 'set-theme-source':
279 store.config.setThemeSource(actionToDispatch.themeSource); 279 store.settings.setThemeSource(actionToDispatch.themeSource);
280 break; 280 break;
281 case 'reload-all-services': 281 case 'reload-all-services':
282 reloadServiceInject().catch((error) => { 282 reloadServiceInject().catch((error) => {
diff --git a/packages/main/src/infrastructure/ConfigPersistence.ts b/packages/main/src/infrastructure/ConfigPersistence.ts
index 4b96f01..184fa8d 100644
--- a/packages/main/src/infrastructure/ConfigPersistence.ts
+++ b/packages/main/src/infrastructure/ConfigPersistence.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 type { ConfigSnapshotOut } from '../stores/Config'; 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 =
@@ -28,7 +28,7 @@ export type ReadConfigResult =
28export default interface ConfigPersistence { 28export default interface ConfigPersistence {
29 readConfig(): Promise<ReadConfigResult>; 29 readConfig(): Promise<ReadConfigResult>;
30 30
31 writeConfig(configSnapshot: ConfigSnapshotOut): Promise<void>; 31 writeConfig(config: Config): Promise<void>;
32 32
33 watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer; 33 watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer;
34} 34}
diff --git a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts
index 06e3fab..88d8bf8 100644
--- a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts
+++ b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts
@@ -24,7 +24,7 @@ import path from 'node:path';
24import JSON5 from 'json5'; 24import JSON5 from 'json5';
25import throttle from 'lodash-es/throttle'; 25import throttle from 'lodash-es/throttle';
26 26
27import type { ConfigSnapshotOut } from '../../stores/Config'; 27import type { Config } from '../../stores/SharedStore';
28import type Disposer from '../../utils/Disposer'; 28import type Disposer from '../../utils/Disposer';
29import { getLogger } from '../../utils/log'; 29import { getLogger } from '../../utils/log';
30import type ConfigPersistence from '../ConfigPersistence'; 30import type ConfigPersistence from '../ConfigPersistence';
@@ -65,7 +65,7 @@ export default class FileBasedConfigPersistence implements ConfigPersistence {
65 }; 65 };
66 } 66 }
67 67
68 async writeConfig(configSnapshot: ConfigSnapshotOut): Promise<void> { 68 async writeConfig(configSnapshot: Config): Promise<void> {
69 const configJson = JSON5.stringify(configSnapshot, { 69 const configJson = JSON5.stringify(configSnapshot, {
70 space: 2, 70 space: 2,
71 }); 71 });
diff --git a/packages/main/src/init.ts b/packages/main/src/init.ts
index 236a075..fd8dd94 100644
--- a/packages/main/src/init.ts
+++ b/packages/main/src/init.ts
@@ -31,10 +31,10 @@ export default async function init(store: MainStore): Promise<Disposer> {
31 app.getPath('userData'), 31 app.getPath('userData'),
32 ); 32 );
33 const disposeConfigController = await initConfig( 33 const disposeConfigController = await initConfig(
34 store.config, 34 store.shared,
35 configPersistenceService, 35 configPersistenceService,
36 ); 36 );
37 const disposeNativeThemeController = initNativeTheme(store); 37 const disposeNativeThemeController = initNativeTheme(store.shared);
38 38
39 return () => { 39 return () => {
40 disposeNativeThemeController(); 40 disposeNativeThemeController();
diff --git a/packages/main/src/stores/Config.ts b/packages/main/src/stores/Config.ts
deleted file mode 100644
index e7fc360..0000000
--- a/packages/main/src/stores/Config.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1/*
2 * Copyright (C) 2021-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
21import { config as originalConfig, ThemeSource } from '@sophie/shared';
22import { applySnapshot, Instance, SnapshotIn } from 'mobx-state-tree';
23
24import { addMissingProfileIds, PartialProfileSnapshotIn } from './Profile';
25import {
26 addMissingServiceIdsAndProfiles,
27 PartialServiceSnapshotIn,
28} from './Service';
29
30export const config = originalConfig.actions((self) => ({
31 loadFromConfigFile(snapshot: ConfigFileIn): void {
32 const profiles = addMissingProfileIds(snapshot.profiles);
33 const services = addMissingServiceIdsAndProfiles(
34 snapshot.services,
35 profiles,
36 );
37 applySnapshot(self, {
38 ...snapshot,
39 profiles,
40 services,
41 });
42 },
43 setThemeSource(mode: ThemeSource): void {
44 self.themeSource = mode;
45 },
46}));
47
48export interface Config extends Instance<typeof config> {}
49
50export interface ConfigSnapshotIn extends SnapshotIn<typeof config> {}
51
52export interface ConfigFileIn
53 extends Omit<ConfigSnapshotIn, 'profiles' | 'services'> {
54 profiles?: PartialProfileSnapshotIn[] | undefined;
55 services?: PartialServiceSnapshotIn[] | undefined;
56}
57
58export type { ConfigSnapshotOut } from '@sophie/shared';
diff --git a/packages/renderer/src/stores/Service.ts b/packages/main/src/stores/GlobalSettings.ts
index 2f45106..1eb13b3 100644
--- a/packages/renderer/src/stores/Service.ts
+++ b/packages/main/src/stores/GlobalSettings.ts
@@ -19,20 +19,20 @@
19 */ 19 */
20 20
21import { 21import {
22 runtimeService, 22 globalSettings as originalGlobalSettings,
23 RuntimeService, 23 ThemeSource,
24 service as originalService,
25} from '@sophie/shared'; 24} from '@sophie/shared';
26import { Instance } from 'mobx-state-tree'; 25import { Instance } from 'mobx-state-tree';
27 26
28import getEnv from '../env/getEnv'; 27export const globalSettings = originalGlobalSettings.actions((self) => ({
29 28 setThemeSource(mode: ThemeSource): void {
30export const service = originalService.views((self) => ({ 29 self.themeSource = mode;
31 get runtime(): RuntimeService {
32 return getEnv(self).getRuntimeService(self.id) ?? runtimeService.create();
33 }, 30 },
34})); 31}));
35 32
36export interface Service extends Instance<typeof service> {} 33export interface GlobalSettings extends Instance<typeof globalSettings> {}
37 34
38export type { ServiceSnapshotIn, ServiceSnapshotOut } from '@sophie/shared'; 35export type {
36 GlobalSettingsSnapshotIn,
37 GlobalSettingsSnapshotOut,
38} from '@sophie/shared';
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index f0d6472..18f5bf9 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -18,21 +18,14 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { BrowserViewBounds, service } from '@sophie/shared'; 21import type { BrowserViewBounds } from '@sophie/shared';
22import { 22import { applySnapshot, Instance, types } from 'mobx-state-tree';
23 applySnapshot,
24 Instance,
25 resolveIdentifier,
26 types,
27} from 'mobx-state-tree';
28 23
29import { getLogger } from '../utils/log'; 24import { GlobalSettings } from './GlobalSettings';
30 25import { Profile } from './Profile';
31import type { Config } from './Config.js'; 26import { Service } from './Service';
32import { sharedStore } from './SharedStore'; 27import { sharedStore } from './SharedStore';
33 28
34const log = getLogger('mainStore');
35
36export const mainStore = types 29export const mainStore = types
37 .model('MainStore', { 30 .model('MainStore', {
38 browserViewBounds: types.optional( 31 browserViewBounds: types.optional(
@@ -47,26 +40,20 @@ export const mainStore = types
47 shared: types.optional(sharedStore, {}), 40 shared: types.optional(sharedStore, {}),
48 }) 41 })
49 .views((self) => ({ 42 .views((self) => ({
50 get config(): Config { 43 get settings(): GlobalSettings {
51 return self.shared.config; 44 return self.shared.settings;
45 },
46 get profiles(): Profile[] {
47 return self.shared.profiles;
48 },
49 get services(): Service[] {
50 return self.shared.services;
52 }, 51 },
53 })) 52 }))
54 .actions((self) => ({ 53 .actions((self) => ({
55 setSelectedServiceId(serviceId: string): void {
56 const serviceInstance = resolveIdentifier(service, self, serviceId);
57 if (serviceInstance === undefined) {
58 log.warn('Trying to select unknown service', serviceId);
59 return;
60 }
61 self.shared.selectedService = serviceInstance;
62 log.debug('Selected service', serviceId);
63 },
64 setBrowserViewBounds(bounds: BrowserViewBounds): void { 54 setBrowserViewBounds(bounds: BrowserViewBounds): void {
65 applySnapshot(self.browserViewBounds, bounds); 55 applySnapshot(self.browserViewBounds, bounds);
66 }, 56 },
67 setShouldUseDarkColors(shouldUseDarkColors: boolean): void {
68 self.shared.shouldUseDarkColors = shouldUseDarkColors;
69 },
70 })); 57 }));
71 58
72export interface MainStore extends Instance<typeof mainStore> {} 59export interface MainStore extends Instance<typeof mainStore> {}
diff --git a/packages/main/src/stores/Profile.ts b/packages/main/src/stores/Profile.ts
index 4705862..eaf23c4 100644
--- a/packages/main/src/stores/Profile.ts
+++ b/packages/main/src/stores/Profile.ts
@@ -18,34 +18,39 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { ProfileSnapshotIn } from '@sophie/shared'; 21import {
22 profile as originalProfile,
23 ProfileSettingsSnapshotIn,
24} from '@sophie/shared';
25import { getSnapshot, Instance } from 'mobx-state-tree';
22 26
27import SettingsWithId from '../utils/SettingsWithId';
23import generateId from '../utils/generateId'; 28import generateId from '../utils/generateId';
24 29
25export interface PartialProfileSnapshotIn 30export interface ProfileConfig extends ProfileSettingsSnapshotIn {
26 extends Omit<ProfileSnapshotIn, 'id'> {
27 id?: string | undefined; 31 id?: string | undefined;
28} 32}
29 33
34export const profile = originalProfile.views((self) => ({
35 get config(): ProfileConfig {
36 const { id, settings } = self;
37 return { ...getSnapshot(settings), id };
38 },
39}));
40
41export interface Profile extends Instance<typeof profile> {}
42
43export type ProfileSettingsSnapshotWithId =
44 SettingsWithId<ProfileSettingsSnapshotIn>;
45
30export function addMissingProfileIds( 46export function addMissingProfileIds(
31 partialProfiles: PartialProfileSnapshotIn[] | undefined, 47 profileConfigs: ProfileConfig[] | undefined,
32): ProfileSnapshotIn[] { 48): ProfileSettingsSnapshotWithId[] {
33 return (partialProfiles ?? []).map((profile) => { 49 return (profileConfigs ?? []).map((profileConfig) => {
34 const { name } = profile; 50 const { id, ...settings } = profileConfig;
35 let { id } = profile;
36 if (typeof id === 'undefined') {
37 id = generateId(name);
38 }
39 return { 51 return {
40 ...profile, 52 id: typeof id === 'undefined' ? generateId(settings.name) : id,
41 id, 53 settings,
42 }; 54 };
43 }); 55 });
44} 56}
45
46export type {
47 Profile,
48 ProfileSnapshotOut,
49 ProfileSnapshotIn,
50} from '@sophie/shared';
51export { profile } from '@sophie/shared';
diff --git a/packages/main/src/stores/RuntimeService.ts b/packages/main/src/stores/RuntimeService.ts
deleted file mode 100644
index ecb1942..0000000
--- a/packages/main/src/stores/RuntimeService.ts
+++ /dev/null
@@ -1,74 +0,0 @@
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
21import { UnreadCount } from '@sophie/service-shared';
22import { runtimeService as originalRuntimeService } from '@sophie/shared';
23import { Instance } from 'mobx-state-tree';
24
25export const runtimeService = originalRuntimeService.actions((self) => ({
26 setLocation({
27 url,
28 canGoBack,
29 canGoForward,
30 }: {
31 url: string;
32 canGoBack: boolean;
33 canGoForward: boolean;
34 }): void {
35 self.url = url;
36 self.canGoBack = canGoBack;
37 self.canGoForward = canGoForward;
38 },
39 setTitle(title: string): void {
40 self.title = title;
41 },
42 hibernated(): void {
43 self.canGoBack = false;
44 self.canGoForward = false;
45 self.state = 'hibernated';
46 },
47 startedLoading(): void {
48 self.state = 'loading';
49 },
50 finishedLoading(): void {
51 if (self.state === 'loading') {
52 // Do not overwrite crashed state if the service haven't been reloaded yet.
53 self.state = 'loaded';
54 }
55 },
56 crashed(): void {
57 self.state = 'crashed';
58 },
59 setUnreadCount({ direct, indirect }: UnreadCount): void {
60 if (direct !== undefined) {
61 self.directMessageCount = direct;
62 }
63 if (indirect !== undefined) {
64 self.indirectMessageCount = indirect;
65 }
66 },
67}));
68
69export interface RuntimeService extends Instance<typeof runtimeService> {}
70
71export type {
72 RuntimeServiceSnapshotIn,
73 RuntimeServiceSnapshotOut,
74} from '@sophie/shared';
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index 9bc6a43..78c57cb 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -18,46 +18,95 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { ServiceSnapshotIn } from '@sophie/shared'; 21import type { UnreadCount } from '@sophie/service-shared';
22import {
23 service as originalService,
24 ServiceSettingsSnapshotIn,
25} from '@sophie/shared';
26import { Instance, getSnapshot, ReferenceIdentifier } from 'mobx-state-tree';
22 27
28import SettingsWithId from '../utils/SettingsWithId';
23import generateId from '../utils/generateId'; 29import generateId from '../utils/generateId';
30import overrideProps from '../utils/overrideProps';
24 31
25import type { ProfileSnapshotIn } from './Profile'; 32import { ProfileSettingsSnapshotWithId } from './Profile';
33import { serviceSettings } from './ServiceSettings';
26 34
27export interface PartialServiceSnapshotIn 35export interface ServiceConfig
28 extends Omit<ServiceSnapshotIn, 'id' | 'profile'> { 36 extends Omit<ServiceSettingsSnapshotIn, 'profile'> {
29 id?: string | undefined; 37 id?: string | undefined;
30 profile?: string | undefined; 38
39 profile?: ReferenceIdentifier | undefined;
31} 40}
32 41
42export const service = overrideProps(originalService, {
43 settings: serviceSettings,
44})
45 .views((self) => ({
46 get config(): ServiceConfig {
47 const { id, settings } = self;
48 return { ...getSnapshot(settings), id };
49 },
50 }))
51 .actions((self) => ({
52 setLocation({
53 url,
54 canGoBack,
55 canGoForward,
56 }: {
57 url: string;
58 canGoBack: boolean;
59 canGoForward: boolean;
60 }): void {
61 self.currentUrl = url;
62 self.canGoBack = canGoBack;
63 self.canGoForward = canGoForward;
64 },
65 setTitle(title: string): void {
66 self.title = title;
67 },
68 startedLoading(): void {
69 self.state = 'loading';
70 },
71 finishedLoading(): void {
72 if (self.state === 'loading') {
73 // Do not overwrite crashed state if the service haven't been reloaded yet.
74 self.state = 'loaded';
75 }
76 },
77 crashed(): void {
78 self.state = 'crashed';
79 },
80 setUnreadCount({ direct, indirect }: UnreadCount): void {
81 if (direct !== undefined) {
82 self.directMessageCount = direct;
83 }
84 if (indirect !== undefined) {
85 self.indirectMessageCount = indirect;
86 }
87 },
88 }));
89
90export interface Service extends Instance<typeof service> {}
91
92export type ServiceSettingsSnapshotWithId =
93 SettingsWithId<ServiceSettingsSnapshotIn>;
94
33export function addMissingServiceIdsAndProfiles( 95export function addMissingServiceIdsAndProfiles(
34 partialServices: PartialServiceSnapshotIn[] | undefined, 96 serviceConfigs: ServiceConfig[] | undefined,
35 profiles: ProfileSnapshotIn[], 97 profiles: ProfileSettingsSnapshotWithId[],
36): ServiceSnapshotIn[] { 98): ServiceSettingsSnapshotWithId[] {
37 return (partialServices ?? []).map((service) => { 99 return (serviceConfigs ?? []).map((serviceConfig) => {
38 const { name } = service; 100 const { id, ...settings } = serviceConfig;
39 let { id, profile } = service; 101 const { name } = settings;
40 if (typeof id === 'undefined') { 102 let { profile: profileId } = settings;
41 id = generateId(name); 103 if (profileId === undefined) {
42 } 104 profileId = generateId(name);
43 if (typeof profile === 'undefined') { 105 profiles.push({ id: profileId, settings: { name } });
44 profile = generateId(name);
45 profiles.push({
46 id: profile,
47 name: service.name,
48 });
49 } 106 }
50 return { 107 return {
51 ...service, 108 id: id === undefined ? generateId(name) : id,
52 id, 109 settings: { ...settings, profile: profileId },
53 profile,
54 }; 110 };
55 }); 111 });
56} 112}
57
58export type {
59 Service,
60 ServiceSnapshotOut,
61 ServiceSnapshotIn,
62} from '@sophie/shared';
63export { service } from '@sophie/shared';
diff --git a/packages/renderer/src/stores/SharedStore.ts b/packages/main/src/stores/ServiceSettings.ts
index 962f7e2..960de9b 100644
--- a/packages/renderer/src/stores/SharedStore.ts
+++ b/packages/main/src/stores/ServiceSettings.ts
@@ -18,20 +18,20 @@
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 { serviceSettings as originalServiceSettings } from '@sophie/shared';
22import { Instance, types } from 'mobx-state-tree'; 22import { Instance, types } from 'mobx-state-tree';
23 23
24import { config } from './Config'; 24import overrideProps from '../utils/overrideProps';
25import { service } from './Service';
26 25
27export const sharedStore = originalSharedStore.props({ 26import { profile } from './Profile';
28 config: types.optional(config, {}), 27
29 selectedService: types.safeReference(service), 28export const serviceSettings = overrideProps(originalServiceSettings, {
29 profile: types.reference(profile),
30}); 30});
31 31
32export interface SharedStore extends Instance<typeof sharedStore> {} 32export interface ServiceSettings extends Instance<typeof serviceSettings> {}
33 33
34export type { 34export type {
35 SharedStoreSnapshotIn, 35 ServiceSettingsSnapshotIn,
36 SharedStoreSnapshotOut, 36 ServiceSettingsSnapshotOut,
37} from '@sophie/shared'; 37} from '@sophie/shared';
diff --git a/packages/main/src/stores/SharedStore.ts b/packages/main/src/stores/SharedStore.ts
index 76ce05c..861c8ce 100644
--- a/packages/main/src/stores/SharedStore.ts
+++ b/packages/main/src/stores/SharedStore.ts
@@ -19,19 +19,111 @@
19 */ 19 */
20 20
21import { sharedStore as originalSharedStore } from '@sophie/shared'; 21import { sharedStore as originalSharedStore } from '@sophie/shared';
22import { Instance, types } from 'mobx-state-tree'; 22import {
23 applySnapshot,
24 getSnapshot,
25 Instance,
26 resolveIdentifier,
27 types,
28} from 'mobx-state-tree';
23 29
24import { config } from './Config'; 30import SettingsWithId from '../utils/SettingsWithId';
25import { runtimeService } from './RuntimeService'; 31import { getLogger } from '../utils/log';
32import overrideProps from '../utils/overrideProps';
33
34import { globalSettings, GlobalSettingsSnapshotIn } from './GlobalSettings';
35import { addMissingProfileIds, profile, ProfileConfig } from './Profile';
36import {
37 addMissingServiceIdsAndProfiles,
38 service,
39 ServiceConfig,
40} from './Service';
41
42const log = getLogger('sharedStore');
43
44export interface Config extends GlobalSettingsSnapshotIn {
45 profiles?: ProfileConfig[] | undefined;
46
47 services?: ServiceConfig[] | undefined;
48}
49
50function getConfigs<T>(models: { config: T }[]): T[] | undefined {
51 return models.length === 0 ? undefined : models.map((model) => model.config);
52}
53
54function reconcileSettings<T>(
55 originalSnapshots: SettingsWithId<T>[],
56 settingsSnapshotsWithId: SettingsWithId<T>[],
57): SettingsWithId<T>[] {
58 const idToOriginalSnapshots = new Map(
59 originalSnapshots.map((originalSnapshot) => [
60 originalSnapshot.id,
61 originalSnapshot,
62 ]),
63 );
64 return settingsSnapshotsWithId.map(({ id, settings }) => ({
65 ...idToOriginalSnapshots.get(id),
66 id,
67 settings,
68 }));
69}
70
71export const sharedStore = overrideProps(originalSharedStore, {
72 settings: types.optional(globalSettings, {}),
73 profiles: types.array(profile),
74 services: types.array(service),
75 selectedService: types.safeReference(service),
76})
77 .views((self) => ({
78 get config(): Config {
79 const { settings, profiles, services } = self;
80 const globalSettingsConfig = getSnapshot(settings);
81 return {
82 ...globalSettingsConfig,
83 profiles: getConfigs(profiles),
84 services: getConfigs(services),
85 };
86 },
87 }))
88 .actions((self) => ({
89 loadConfig(config: Config): void {
90 const snapshot = getSnapshot(self);
91 const { profiles, services, ...settings } = config;
92 const profileSettingsSnapshots = addMissingProfileIds(profiles);
93 const serviceSettingsSnapshots = addMissingServiceIdsAndProfiles(
94 services,
95 profileSettingsSnapshots,
96 );
97 applySnapshot(self, {
98 ...snapshot,
99 settings,
100 profiles: reconcileSettings(
101 snapshot.profiles,
102 profileSettingsSnapshots,
103 ),
104 services: reconcileSettings(
105 snapshot.services,
106 serviceSettingsSnapshots,
107 ),
108 });
109 },
110 setShouldUseDarkColors(shouldUseDarkColors: boolean): void {
111 self.shouldUseDarkColors = shouldUseDarkColors;
112 },
113 setSelectedServiceId(serviceId: string): void {
114 const serviceInstance = resolveIdentifier(service, self, serviceId);
115 if (serviceInstance === undefined) {
116 log.warn('Trying to select unknown service', serviceId);
117 return;
118 }
119 self.selectedService = serviceInstance;
120 log.debug('Selected service', serviceId);
121 },
122 }));
123
124export interface SharedStore extends Instance<typeof sharedStore> {}
26 125
27export type { 126export type {
28 SharedStoreSnapshotIn, 127 SharedStoreSnapshotIn,
29 SharedStoreSnapshotOut, 128 SharedStoreSnapshotOut,
30} from '@sophie/shared'; 129} from '@sophie/shared';
31
32export const sharedStore = originalSharedStore.props({
33 config: types.optional(config, {}),
34 runtimeServices: types.map(runtimeService),
35});
36
37export interface SharedStore extends Instance<typeof sharedStore> {}
diff --git a/packages/main/src/stores/__tests__/Config.spec.ts b/packages/main/src/stores/__tests__/SharedStore.spec.ts
index 22ccbc7..3ea187c 100644
--- a/packages/main/src/stores/__tests__/Config.spec.ts
+++ b/packages/main/src/stores/__tests__/SharedStore.spec.ts
@@ -18,28 +18,28 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { config, Config, ConfigFileIn } from '../Config'; 21import type { ProfileConfig } from '../Profile';
22import type { PartialProfileSnapshotIn } from '../Profile'; 22import type { ServiceConfig } from '../Service';
23import type { PartialServiceSnapshotIn } from '../Service'; 23import { Config, sharedStore, SharedStore } from '../SharedStore';
24 24
25const profileProps: PartialProfileSnapshotIn = { 25const profileProps: ProfileConfig = {
26 name: 'Test profile', 26 name: 'Test profile',
27}; 27};
28 28
29const serviceProps: PartialServiceSnapshotIn = { 29const serviceProps: ServiceConfig = {
30 name: 'Test service', 30 name: 'Test service',
31 url: 'https://example.com', 31 url: 'https://example.com',
32}; 32};
33 33
34let sut: Config; 34let sut: SharedStore;
35 35
36beforeEach(() => { 36beforeEach(() => {
37 sut = config.create(); 37 sut = sharedStore.create();
38}); 38});
39 39
40describe('preprocessConfigFile', () => { 40describe('loadConfig', () => {
41 it('should load profiles with an ID', () => { 41 it('should load profiles with an ID', () => {
42 sut.loadFromConfigFile({ 42 sut.loadConfig({
43 profiles: [ 43 profiles: [
44 { 44 {
45 id: 'someId', 45 id: 'someId',
@@ -51,14 +51,14 @@ describe('preprocessConfigFile', () => {
51 }); 51 });
52 52
53 it('should generate an ID for profiles without and ID', () => { 53 it('should generate an ID for profiles without and ID', () => {
54 sut.loadFromConfigFile({ 54 sut.loadConfig({
55 profiles: [profileProps], 55 profiles: [profileProps],
56 }); 56 });
57 expect(sut.profiles[0].id).toBeDefined(); 57 expect(sut.profiles[0].id).toBeDefined();
58 }); 58 });
59 59
60 it('should load services with an ID and a profile', () => { 60 it('should load services with an ID and a profile', () => {
61 sut.loadFromConfigFile({ 61 sut.loadConfig({
62 profiles: [ 62 profiles: [
63 { 63 {
64 id: 'someProfileId', 64 id: 'someProfileId',
@@ -74,12 +74,12 @@ describe('preprocessConfigFile', () => {
74 ], 74 ],
75 }); 75 });
76 expect(sut.services[0].id).toBe('someServiceId'); 76 expect(sut.services[0].id).toBe('someServiceId');
77 expect(sut.services[0].profile).toBe(sut.profiles[0]); 77 expect(sut.services[0].settings.profile).toBe(sut.profiles[0]);
78 }); 78 });
79 79
80 it('should refuse to load a profile without a name', () => { 80 it('should refuse to load a profile without a name', () => {
81 expect(() => { 81 expect(() => {
82 sut.loadFromConfigFile({ 82 sut.loadConfig({
83 profiles: [ 83 profiles: [
84 { 84 {
85 id: 'someProfileId', 85 id: 'someProfileId',
@@ -87,13 +87,13 @@ describe('preprocessConfigFile', () => {
87 name: undefined, 87 name: undefined,
88 }, 88 },
89 ], 89 ],
90 } as unknown as ConfigFileIn); 90 } as unknown as Config);
91 }).toThrow(); 91 }).toThrow();
92 expect(sut.profiles).toHaveLength(0); 92 expect(sut.profiles).toHaveLength(0);
93 }); 93 });
94 94
95 it('should load services without an ID but with a profile', () => { 95 it('should load services without an ID but with a profile', () => {
96 sut.loadFromConfigFile({ 96 sut.loadConfig({
97 profiles: [ 97 profiles: [
98 { 98 {
99 id: 'someProfileId', 99 id: 'someProfileId',
@@ -108,11 +108,11 @@ describe('preprocessConfigFile', () => {
108 ], 108 ],
109 }); 109 });
110 expect(sut.services[0].id).toBeDefined(); 110 expect(sut.services[0].id).toBeDefined();
111 expect(sut.services[0].profile).toBe(sut.profiles[0]); 111 expect(sut.services[0].settings.profile).toBe(sut.profiles[0]);
112 }); 112 });
113 113
114 it('should create a profile for a service with an ID but no profile', () => { 114 it('should create a profile for a service with an ID but no profile', () => {
115 sut.loadFromConfigFile({ 115 sut.loadConfig({
116 services: [ 116 services: [
117 { 117 {
118 id: 'someServiceId', 118 id: 'someServiceId',
@@ -121,12 +121,14 @@ describe('preprocessConfigFile', () => {
121 ], 121 ],
122 }); 122 });
123 expect(sut.services[0].id).toBe('someServiceId'); 123 expect(sut.services[0].id).toBe('someServiceId');
124 expect(sut.services[0].profile).toBeDefined(); 124 expect(sut.services[0].settings.profile).toBeDefined();
125 expect(sut.services[0].profile.name).toBe(serviceProps.name); 125 expect(sut.services[0].settings.profile.settings.name).toBe(
126 serviceProps.name,
127 );
126 }); 128 });
127 129
128 it('should create a profile for a service without an ID or profile', () => { 130 it('should create a profile for a service without an ID or profile', () => {
129 sut.loadFromConfigFile({ 131 sut.loadConfig({
130 services: [ 132 services: [
131 { 133 {
132 ...serviceProps, 134 ...serviceProps,
@@ -134,13 +136,15 @@ describe('preprocessConfigFile', () => {
134 ], 136 ],
135 }); 137 });
136 expect(sut.services[0].id).toBeDefined(); 138 expect(sut.services[0].id).toBeDefined();
137 expect(sut.services[0].profile).toBeDefined(); 139 expect(sut.services[0].settings.profile).toBeDefined();
138 expect(sut.services[0].profile.name).toBe(serviceProps.name); 140 expect(sut.services[0].settings.profile.settings.name).toBe(
141 serviceProps.name,
142 );
139 }); 143 });
140 144
141 it('should refuse to load a service without a name', () => { 145 it('should refuse to load a service without a name', () => {
142 expect(() => { 146 expect(() => {
143 sut.loadFromConfigFile({ 147 sut.loadConfig({
144 services: [ 148 services: [
145 { 149 {
146 id: 'someServiceId', 150 id: 'someServiceId',
@@ -148,7 +152,7 @@ describe('preprocessConfigFile', () => {
148 name: undefined, 152 name: undefined,
149 }, 153 },
150 ], 154 ],
151 } as unknown as ConfigFileIn); 155 } as unknown as Config);
152 }).toThrow(); 156 }).toThrow();
153 expect(sut.profiles).toHaveLength(0); 157 expect(sut.profiles).toHaveLength(0);
154 expect(sut.services).toHaveLength(0); 158 expect(sut.services).toHaveLength(0);
diff --git a/packages/renderer/src/stores/Config.ts b/packages/main/src/utils/SettingsWithId.ts
index 070c4ec..fde3e86 100644
--- a/packages/renderer/src/stores/Config.ts
+++ b/packages/main/src/utils/SettingsWithId.ts
@@ -18,15 +18,8 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { config as originalConfig } from '@sophie/shared'; 21export default interface SettingsWithId<T> {
22import { Instance, types } from 'mobx-state-tree'; 22 id: string;
23 23
24import { service } from './Service'; 24 settings: T;
25 25}
26export const config = originalConfig.props({
27 services: types.array(service),
28});
29
30export interface Config extends Instance<typeof config> {}
31
32export type { ConfigSnapshotIn, ConfigSnapshotOut } from '@sophie/shared';
diff --git a/packages/main/src/utils/overrideProps.ts b/packages/main/src/utils/overrideProps.ts
new file mode 100644
index 0000000..c626408
--- /dev/null
+++ b/packages/main/src/utils/overrideProps.ts
@@ -0,0 +1,62 @@
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
21/**
22 * @file This file implements a technique to force-override properties of a model.
23 *
24 * The overridden properties must conform to the SnapshotIt and SnapshotOut format
25 * of the original model. Essentially, this means that only views and actions can
26 * be added safely.
27 *
28 * @see https://github.com/mobxjs/mobx-state-tree/issues/1403#issuecomment-940843087
29 */
30
31import {
32 IAnyModelType,
33 IModelType,
34 ModelProperties,
35 SnapshotIn,
36 SnapshotOut,
37} from 'mobx-state-tree';
38
39export type IUnsafeOverriddenModelType<
40 BASE extends IAnyModelType,
41 PROPS extends ModelProperties,
42> = BASE extends IModelType<infer P, infer O, infer CC, infer CS>
43 ? IModelType<Omit<P, keyof PROPS> & PROPS, O, CC, CS>
44 : never;
45
46export type IOverriddenModelType<
47 BASE extends IAnyModelType,
48 PROPS extends ModelProperties,
49> = SnapshotIn<BASE> extends SnapshotIn<IUnsafeOverriddenModelType<BASE, PROPS>>
50 ? SnapshotOut<
51 IUnsafeOverriddenModelType<BASE, PROPS>
52 > extends SnapshotOut<BASE>
53 ? IUnsafeOverriddenModelType<BASE, PROPS>
54 : never
55 : never;
56
57export default function overrideProps<
58 BASE extends IAnyModelType,
59 PROPS extends ModelProperties,
60>(base: BASE, props: PROPS): IOverriddenModelType<BASE, PROPS> {
61 return base.props(props) as IOverriddenModelType<BASE, PROPS>;
62}
diff --git a/packages/renderer/src/components/ServiceSwitcher.tsx b/packages/renderer/src/components/ServiceSwitcher.tsx
index 0786b71..167153f 100644
--- a/packages/renderer/src/components/ServiceSwitcher.tsx
+++ b/packages/renderer/src/components/ServiceSwitcher.tsx
@@ -78,8 +78,8 @@ export default observer(() => {
78 <ServiceSwitcherTab 78 <ServiceSwitcherTab
79 key={service.id} 79 key={service.id}
80 value={service.id} 80 value={service.id}
81 icon={<ServiceIcon name={service.name} />} 81 icon={<ServiceIcon name={service.settings.name} />}
82 aria-label={service.name} 82 aria-label={service.settings.name}
83 /> 83 />
84 ))} 84 ))}
85 </ServiceSwitcherRoot> 85 </ServiceSwitcherRoot>
diff --git a/packages/renderer/src/env/RendererEnv.ts b/packages/renderer/src/env/RendererEnv.ts
index 5ca2978..ba4c43e 100644
--- a/packages/renderer/src/env/RendererEnv.ts
+++ b/packages/renderer/src/env/RendererEnv.ts
@@ -18,10 +18,8 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { Action, RuntimeService } from '@sophie/shared'; 21import type { Action } from '@sophie/shared';
22 22
23export default interface RendererEnv { 23export default interface RendererEnv {
24 dispatchMainAction(action: Action): void; 24 dispatchMainAction(action: Action): void;
25
26 getRuntimeService(serviceId: string): RuntimeService | undefined;
27} 25}
diff --git a/packages/renderer/src/env/impl/RendererEnvImpl.ts b/packages/renderer/src/env/impl/RendererEnvImpl.ts
deleted file mode 100644
index 184d31b..0000000
--- a/packages/renderer/src/env/impl/RendererEnvImpl.ts
+++ /dev/null
@@ -1,55 +0,0 @@
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
21import {
22 Action,
23 runtimeService,
24 RuntimeService,
25 SophieRenderer,
26} from '@sophie/shared';
27import type { IMSTMap } from 'mobx-state-tree';
28
29import type { RendererStore } from '../../stores/RendererStore';
30import type RendererEnv from '../RendererEnv';
31
32export default class RendererEnvImpl implements RendererEnv {
33 readonly #ipc: SophieRenderer;
34
35 #runtimeServices: IMSTMap<typeof runtimeService> | undefined;
36
37 constructor(ipc: SophieRenderer) {
38 this.#ipc = ipc;
39 }
40
41 setStore(store: RendererStore): void {
42 this.#runtimeServices = store.shared.runtimeServices;
43 }
44
45 dispatchMainAction(action: Action): void {
46 this.#ipc.dispatchAction(action);
47 }
48
49 getRuntimeService(serviceId: string): RuntimeService | undefined {
50 if (this.#runtimeServices === undefined) {
51 throw new Error('runtime services map is not yet set');
52 }
53 return this.#runtimeServices.get(serviceId);
54 }
55}
diff --git a/packages/renderer/src/env/impl/__tests__/RendererEnvImpl.spec.ts b/packages/renderer/src/env/impl/__tests__/RendererEnvImpl.spec.ts
deleted file mode 100644
index d36462c..0000000
--- a/packages/renderer/src/env/impl/__tests__/RendererEnvImpl.spec.ts
+++ /dev/null
@@ -1,87 +0,0 @@
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
21import { jest } from '@jest/globals';
22import {
23 Action,
24 runtimeService,
25 RuntimeService,
26 SophieRenderer,
27} from '@sophie/shared';
28
29import { rendererStore } from '../../../stores/RendererStore';
30import RendererEnvImpl from '../RendererEnvImpl';
31
32const ipc: SophieRenderer = {
33 dispatchAction: jest.fn(),
34 onSharedStoreChange: jest.fn(),
35};
36let sut: RendererEnvImpl;
37
38beforeEach(() => {
39 sut = new RendererEnvImpl(ipc);
40});
41
42describe('dispatchMainAction', () => {
43 it('should dispatch actions via the IPC', () => {
44 const action: Action = {
45 action: 'set-theme-source',
46 themeSource: 'dark',
47 };
48 sut.dispatchMainAction(action);
49 expect(ipc.dispatchAction).toHaveBeenCalledWith(action);
50 });
51});
52
53describe('getRuntimeService', () => {
54 describe('when no store was set', () => {
55 it('should throw an error', () => {
56 expect(() => sut.getRuntimeService('someId')).toThrow();
57 });
58 });
59
60 describe('when the store was set', () => {
61 let runtimeServiceStore: RuntimeService;
62
63 beforeEach(() => {
64 runtimeServiceStore = runtimeService.create({
65 state: 'loaded',
66 });
67 const store = rendererStore.create({
68 shared: {
69 runtimeServices: {
70 someId: runtimeServiceStore,
71 },
72 },
73 });
74 sut.setStore(store);
75 });
76
77 it('should return the runtime service for the given ID', () => {
78 const returnedStore = sut.getRuntimeService('someId');
79 expect(returnedStore).toBe(runtimeServiceStore);
80 });
81
82 it('should return undefined for an unknown ID', () => {
83 const returnedStore = sut.getRuntimeService('unknownId');
84 expect(returnedStore).toBeUndefined();
85 });
86 });
87});
diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts
index 731ca28..a4e6197 100644
--- a/packages/renderer/src/stores/RendererStore.ts
+++ b/packages/renderer/src/stores/RendererStore.ts
@@ -18,17 +18,19 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { BrowserViewBounds, SophieRenderer, ThemeSource } from '@sophie/shared'; 21import {
22 BrowserViewBounds,
23 sharedStore,
24 Service,
25 SophieRenderer,
26 ThemeSource,
27} from '@sophie/shared';
22import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree'; 28import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree';
23 29
30import RendererEnv from '../env/RendererEnv';
24import getEnv from '../env/getEnv'; 31import getEnv from '../env/getEnv';
25import RendererEnvImpl from '../env/impl/RendererEnvImpl';
26import { getLogger } from '../utils/log'; 32import { getLogger } from '../utils/log';
27 33
28import type { Config } from './Config';
29import type { Service } from './Service';
30import { sharedStore } from './SharedStore';
31
32const log = getLogger('RendererStore'); 34const log = getLogger('RendererStore');
33 35
34export const rendererStore = types 36export const rendererStore = types
@@ -36,11 +38,8 @@ export const rendererStore = types
36 shared: types.optional(sharedStore, {}), 38 shared: types.optional(sharedStore, {}),
37 }) 39 })
38 .views((self) => ({ 40 .views((self) => ({
39 get config(): Config {
40 return self.shared.config;
41 },
42 get services(): Service[] { 41 get services(): Service[] {
43 return this.config.services; 42 return self.shared.services;
44 }, 43 },
45 get selectedService(): Service | undefined { 44 get selectedService(): Service | undefined {
46 return self.shared.selectedService; 45 return self.shared.selectedService;
@@ -87,9 +86,10 @@ export interface RendererStore extends Instance<typeof rendererStore> {}
87export function createAndConnectRendererStore( 86export function createAndConnectRendererStore(
88 ipc: SophieRenderer, 87 ipc: SophieRenderer,
89): RendererStore { 88): RendererStore {
90 const env = new RendererEnvImpl(ipc); 89 const env: RendererEnv = {
90 dispatchMainAction: ipc.dispatchAction,
91 };
91 const store = rendererStore.create({}, env); 92 const store = rendererStore.create({}, env);
92 env.setStore(store);
93 93
94 ipc 94 ipc
95 .onSharedStoreChange({ 95 .onSharedStoreChange({
diff --git a/packages/renderer/src/stores/__tests__/Service.spec.ts b/packages/renderer/src/stores/__tests__/Service.spec.ts
deleted file mode 100644
index f835d41..0000000
--- a/packages/renderer/src/stores/__tests__/Service.spec.ts
+++ /dev/null
@@ -1,63 +0,0 @@
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
21import { jest } from '@jest/globals';
22import { runtimeService } from '@sophie/shared';
23import { mocked } from 'jest-mock';
24
25import type RendererEnv from '../../env/RendererEnv';
26import { service, Service } from '../Service';
27
28const env: RendererEnv = {
29 dispatchMainAction: jest.fn(),
30 getRuntimeService: jest.fn(),
31};
32let sut: Service;
33
34beforeEach(() => {
35 sut = service.create(
36 {
37 id: 'serviceId',
38 name: 'Foo',
39 url: 'https://example.com',
40 profile: 'profileId',
41 },
42 env,
43 );
44});
45
46describe('runtime', () => {
47 it('should return the runtime service with for the service ID', () => {
48 const runtimeServiceStore = runtimeService.create({}, env);
49 mocked(env.getRuntimeService).mockReturnValueOnce(runtimeServiceStore);
50 const returnedStore = sut.runtime;
51 expect(env.getRuntimeService).toHaveBeenCalledWith('serviceId');
52 expect(returnedStore).toBe(runtimeServiceStore);
53 });
54
55 it('should return a valid runtime service even if none exists in the environment', () => {
56 /*
57 eslint-disable-next-line unicorn/no-useless-undefined --
58 `mockReturnValueOnce` expects 1 parameter.
59 */
60 mocked(env.getRuntimeService).mockReturnValueOnce(undefined);
61 expect(sut.runtime).toHaveProperty('state', 'hibernated');
62 });
63});
diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts
index 9858aa9..28dc0b7 100644
--- a/packages/shared/src/contextBridge/SophieRenderer.ts
+++ b/packages/shared/src/contextBridge/SophieRenderer.ts
@@ -21,7 +21,7 @@
21import { Action } from '../schemas'; 21import { Action } from '../schemas';
22import { SharedStoreListener } from '../stores/SharedStore'; 22import { SharedStoreListener } from '../stores/SharedStore';
23 23
24export interface SophieRenderer { 24export default interface SophieRenderer {
25 onSharedStoreChange(this: void, listener: SharedStoreListener): Promise<void>; 25 onSharedStoreChange(this: void, listener: SharedStoreListener): Promise<void>;
26 26
27 dispatchAction(this: void, action: Action): void; 27 dispatchAction(this: void, action: Action): void;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index df02854..55cf5ce 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/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
21export type { SophieRenderer } from './contextBridge/SophieRenderer'; 21export type { default as SophieRenderer } from './contextBridge/SophieRenderer';
22 22
23export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc'; 23export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc';
24 24
@@ -26,34 +26,33 @@ export type { Action, BrowserViewBounds, ThemeSource } from './schemas';
26export { action, browserViewBounds, themeSource } from './schemas'; 26export { action, browserViewBounds, themeSource } from './schemas';
27 27
28export type { 28export type {
29 Config, 29 GlobalSettings,
30 ConfigSnapshotIn, 30 GlobalSettingsSnapshotIn,
31 ConfigSnapshotOut, 31 GlobalSettingsSnapshotOut,
32} from './stores/Config'; 32} from './stores/GlobalSettings';
33export { config } from './stores/Config'; 33export { globalSettings } from './stores/GlobalSettings';
34 34
35export type { 35export type { Profile } from './stores/Profile';
36 Profile,
37 ProfileSnapshotIn,
38 ProfileSnapshotOut,
39} from './stores/Profile';
40export { profile } from './stores/Profile'; 36export { profile } from './stores/Profile';
41 37
42export type { 38export type {
43 RuntimeService, 39 ProfileSettings,
44 RuntimeServiceSnapshotIn, 40 ProfileSettingsSnapshotIn,
45 RuntimeServiceSnapshotOut, 41 ProfileSettingsSnapshotOut,
46} from './stores/RuntimeService'; 42} from './stores/ProfileSettings';
47export { runtimeService } from './stores/RuntimeService'; 43export { profileSettings } from './stores/ProfileSettings';
48 44
49export type { 45export type { Service } from './stores/Service';
50 Service,
51 ServiceSnapshotIn,
52 ServiceSnapshotOut,
53} from './stores/Service';
54export { service } from './stores/Service'; 46export { service } from './stores/Service';
55 47
56export type { 48export type {
49 ServiceSettings,
50 ServiceSettingsSnapshotIn,
51 ServiceSettingsSnapshotOut,
52} from './stores/ServiceSettings';
53export { serviceSettings } from './stores/ServiceSettings';
54
55export type {
57 SharedStore, 56 SharedStore,
58 SharedStoreListener, 57 SharedStoreListener,
59 SharedStoreSnapshotIn, 58 SharedStoreSnapshotIn,
diff --git a/packages/shared/src/stores/Config.ts b/packages/shared/src/stores/GlobalSettings.ts
index 1d97e7d..bd0155a 100644
--- a/packages/shared/src/stores/Config.ts
+++ b/packages/shared/src/stores/GlobalSettings.ts
@@ -1,5 +1,5 @@
1/* 1/*
2 * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com> 2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 * 3 *
4 * This file is part of Sophie. 4 * This file is part of Sophie.
5 * 5 *
@@ -22,17 +22,14 @@ import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22 22
23import { themeSource } from '../schemas'; 23import { themeSource } from '../schemas';
24 24
25import { profile } from './Profile'; 25export const globalSettings = types.model('GlobalSettings', {
26import { service } from './Service';
27
28export const config = types.model('Config', {
29 profiles: types.array(profile),
30 services: types.array(service),
31 themeSource: types.optional(types.enumeration(themeSource.options), 'system'), 26 themeSource: types.optional(types.enumeration(themeSource.options), 'system'),
32}); 27});
33 28
34export interface Config extends Instance<typeof config> {} 29export interface GlobalSettings extends Instance<typeof globalSettings> {}
35 30
36export interface ConfigSnapshotIn extends SnapshotIn<typeof config> {} 31export interface GlobalSettingsSnapshotIn
32 extends SnapshotIn<typeof globalSettings> {}
37 33
38export interface ConfigSnapshotOut extends SnapshotOut<typeof config> {} 34export interface GlobalSettingsSnapshotOut
35 extends SnapshotOut<typeof globalSettings> {}
diff --git a/packages/shared/src/stores/Profile.ts b/packages/shared/src/stores/Profile.ts
index 88a0f4d..bb058f6 100644
--- a/packages/shared/src/stores/Profile.ts
+++ b/packages/shared/src/stores/Profile.ts
@@ -18,15 +18,13 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types } from 'mobx-state-tree';
22
23import { profileSettings } from './ProfileSettings';
22 24
23export const profile = types.model('Profile', { 25export const profile = types.model('Profile', {
24 id: types.identifier, 26 id: types.identifier,
25 name: types.string, 27 settings: profileSettings,
26}); 28});
27 29
28export interface Profile extends Instance<typeof profile> {} 30export interface Profile extends Instance<typeof profile> {}
29
30export interface ProfileSnapshotIn extends SnapshotIn<typeof profile> {}
31
32export interface ProfileSnapshotOut extends SnapshotOut<typeof profile> {}
diff --git a/packages/shared/src/stores/ProfileSettings.ts b/packages/shared/src/stores/ProfileSettings.ts
new file mode 100644
index 0000000..ec8da5f
--- /dev/null
+++ b/packages/shared/src/stores/ProfileSettings.ts
@@ -0,0 +1,33 @@
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
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22
23export const profileSettings = types.model('ProfileSettings', {
24 name: types.string,
25});
26
27export interface ProfileSettings extends Instance<typeof profileSettings> {}
28
29export interface ProfileSettingsSnapshotIn
30 extends SnapshotIn<typeof profileSettings> {}
31
32export interface ProfileSettingsSnapshotOut
33 extends SnapshotOut<typeof profileSettings> {}
diff --git a/packages/shared/src/stores/Service.ts b/packages/shared/src/stores/Service.ts
index ed2cd9a..36acd3d 100644
--- a/packages/shared/src/stores/Service.ts
+++ b/packages/shared/src/stores/Service.ts
@@ -18,20 +18,23 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types } from 'mobx-state-tree';
22 22
23import { profile } from './Profile'; 23import { serviceSettings } from './ServiceSettings';
24 24
25export const service = types.model('Service', { 25export const service = types.model('Service', {
26 id: types.identifier, 26 id: types.identifier,
27 name: types.string, 27 settings: serviceSettings,
28 profile: types.reference(profile), 28 currentUrl: types.maybe(types.string),
29 // TODO: Remove this once recipes are added. 29 canGoBack: false,
30 url: types.string, 30 canGoForward: false,
31 title: types.maybe(types.string),
32 state: types.optional(
33 types.enumeration('ServiceState', ['loading', 'loaded', 'crashed']),
34 'loading',
35 ),
36 directMessageCount: 0,
37 indirectMessageCount: 0,
31}); 38});
32 39
33export interface Service extends Instance<typeof service> {} 40export interface Service extends Instance<typeof service> {}
34
35export interface ServiceSnapshotIn extends SnapshotIn<typeof service> {}
36
37export interface ServiceSnapshotOut extends SnapshotOut<typeof service> {}
diff --git a/packages/shared/src/stores/RuntimeService.ts b/packages/shared/src/stores/ServiceSettings.ts
index c5b9031..54cd7eb 100644
--- a/packages/shared/src/stores/RuntimeService.ts
+++ b/packages/shared/src/stores/ServiceSettings.ts
@@ -20,28 +20,19 @@
20 20
21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; 21import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree';
22 22
23export const runtimeService = types.model({ 23import { profile } from './Profile';
24 url: types.maybe(types.string), 24
25 canGoBack: false, 25export const serviceSettings = types.model('ServiceSettings', {
26 canGoForward: false, 26 name: types.string,
27 title: types.maybe(types.string), 27 profile: types.reference(profile),
28 state: types.optional( 28 // TODO: Remove this once recipes are added.
29 types.enumeration('ServiceState', [ 29 url: types.string,
30 'hibernated',
31 'loading',
32 'loaded',
33 'crashed',
34 ]),
35 'hibernated',
36 ),
37 directMessageCount: 0,
38 indirectMessageCount: 0,
39}); 30});
40 31
41export interface RuntimeService extends Instance<typeof runtimeService> {} 32export interface ServiceSettings extends Instance<typeof serviceSettings> {}
42 33
43export interface RuntimeServiceSnapshotIn 34export interface ServiceSettingsSnapshotIn
44 extends SnapshotIn<typeof runtimeService> {} 35 extends SnapshotIn<typeof serviceSettings> {}
45 36
46export interface RuntimeServiceSnapshotOut 37export interface ServiceSettingsSnapshotOut
47 extends SnapshotOut<typeof runtimeService> {} 38 extends SnapshotOut<typeof serviceSettings> {}
diff --git a/packages/shared/src/stores/SharedStore.ts b/packages/shared/src/stores/SharedStore.ts
index e6e2cad..a04f4bf 100644
--- a/packages/shared/src/stores/SharedStore.ts
+++ b/packages/shared/src/stores/SharedStore.ts
@@ -26,13 +26,14 @@ import {
26 SnapshotOut, 26 SnapshotOut,
27} from 'mobx-state-tree'; 27} from 'mobx-state-tree';
28 28
29import { config } from './Config'; 29import { globalSettings } from './GlobalSettings';
30import { runtimeService } from './RuntimeService'; 30import { profile } from './Profile';
31import { service } from './Service'; 31import { service } from './Service';
32 32
33export const sharedStore = types.model('SharedStore', { 33export const sharedStore = types.model('SharedStore', {
34 config: types.optional(config, {}), 34 settings: types.optional(globalSettings, {}),
35 runtimeServices: types.map(runtimeService), 35 profiles: types.array(profile),
36 services: types.array(service),
36 selectedService: types.safeReference(service), 37 selectedService: types.safeReference(service),
37 shouldUseDarkColors: false, 38 shouldUseDarkColors: false,
38}); 39});