aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main
diff options
context:
space:
mode:
Diffstat (limited to 'packages/main')
-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.ts38
-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.ts37
-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.ts25
-rw-r--r--packages/main/src/utils/overrideProps.ts62
19 files changed, 463 insertions, 297 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/main/src/stores/GlobalSettings.ts b/packages/main/src/stores/GlobalSettings.ts
new file mode 100644
index 0000000..1eb13b3
--- /dev/null
+++ b/packages/main/src/stores/GlobalSettings.ts
@@ -0,0 +1,38 @@
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 globalSettings as originalGlobalSettings,
23 ThemeSource,
24} from '@sophie/shared';
25import { Instance } from 'mobx-state-tree';
26
27export const globalSettings = originalGlobalSettings.actions((self) => ({
28 setThemeSource(mode: ThemeSource): void {
29 self.themeSource = mode;
30 },
31}));
32
33export interface GlobalSettings extends Instance<typeof globalSettings> {}
34
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/main/src/stores/ServiceSettings.ts b/packages/main/src/stores/ServiceSettings.ts
new file mode 100644
index 0000000..960de9b
--- /dev/null
+++ b/packages/main/src/stores/ServiceSettings.ts
@@ -0,0 +1,37 @@
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 { serviceSettings as originalServiceSettings } from '@sophie/shared';
22import { Instance, types } from 'mobx-state-tree';
23
24import overrideProps from '../utils/overrideProps';
25
26import { profile } from './Profile';
27
28export const serviceSettings = overrideProps(originalServiceSettings, {
29 profile: types.reference(profile),
30});
31
32export interface ServiceSettings extends Instance<typeof serviceSettings> {}
33
34export type {
35 ServiceSettingsSnapshotIn,
36 ServiceSettingsSnapshotOut,
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/main/src/utils/SettingsWithId.ts b/packages/main/src/utils/SettingsWithId.ts
new file mode 100644
index 0000000..fde3e86
--- /dev/null
+++ b/packages/main/src/utils/SettingsWithId.ts
@@ -0,0 +1,25 @@
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
21export default interface SettingsWithId<T> {
22 id: string;
23
24 settings: T;
25}
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}