diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-01-23 17:12:47 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-02-08 21:43:17 +0100 |
commit | 044b2de8c7861504704468ba441d4a6a37eed8f7 (patch) | |
tree | 940d2b7946d07a0c69b5e3ad46c25c599cf2aca7 /packages/main | |
parent | feat: Add selected service field to SharedStore (diff) | |
download | sophie-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>
Diffstat (limited to 'packages/main')
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 | ||
21 | import { jest } from '@jest/globals'; | 21 | import { jest } from '@jest/globals'; |
22 | import { mocked } from 'jest-mock'; | 22 | import { mocked } from 'jest-mock'; |
23 | import { getSnapshot } from 'mobx-state-tree'; | ||
24 | import ms from 'ms'; | 23 | import ms from 'ms'; |
25 | 24 | ||
26 | import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; | 25 | import type ConfigPersistence from '../../infrastructure/ConfigPersistence'; |
27 | import { Config, config as configModel } from '../../stores/Config'; | 26 | import { sharedStore, SharedStore } from '../../stores/SharedStore'; |
28 | import type Disposer from '../../utils/Disposer'; | 27 | import type Disposer from '../../utils/Disposer'; |
29 | import { silenceLogger } from '../../utils/log'; | 28 | import { silenceLogger } from '../../utils/log'; |
30 | import initConfig from '../initConfig'; | 29 | import initConfig from '../initConfig'; |
31 | 30 | ||
32 | let config: Config; | 31 | let store: SharedStore; |
33 | const persistenceService: ConfigPersistence = { | 32 | const 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 | ||
46 | beforeEach(() => { | 45 | beforeEach(() => { |
47 | config = configModel.create(); | 46 | store = sharedStore.create(); |
48 | }); | 47 | }); |
49 | 48 | ||
50 | describe('when initializing', () => { | 49 | describe('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 @@ | |||
21 | import { jest } from '@jest/globals'; | 21 | import { jest } from '@jest/globals'; |
22 | import { mocked } from 'jest-mock'; | 22 | import { mocked } from 'jest-mock'; |
23 | 23 | ||
24 | import { createMainStore, MainStore } from '../../stores/MainStore'; | 24 | import { sharedStore, SharedStore } from '../../stores/SharedStore'; |
25 | import type Disposer from '../../utils/Disposer'; | 25 | import type Disposer from '../../utils/Disposer'; |
26 | 26 | ||
27 | let shouldUseDarkColors = false; | 27 | let shouldUseDarkColors = false; |
@@ -40,11 +40,11 @@ jest.unstable_mockModule('electron', () => ({ | |||
40 | const { nativeTheme } = await import('electron'); | 40 | const { nativeTheme } = await import('electron'); |
41 | const { default: initNativeTheme } = await import('../initNativeTheme'); | 41 | const { default: initNativeTheme } = await import('../initNativeTheme'); |
42 | 42 | ||
43 | let store: MainStore; | 43 | let store: SharedStore; |
44 | let disposeSut: Disposer; | 44 | let disposeSut: Disposer; |
45 | 45 | ||
46 | beforeEach(() => { | 46 | beforeEach(() => { |
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 | ||
55 | it('should synchronize themeSource changes to the nativeTheme', () => { | 55 | it('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 | ||
69 | it('should remove the listener on dispose', () => { | 69 | it('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 | ||
21 | import deepEqual from 'deep-equal'; | 21 | import deepEqual from 'deep-equal'; |
22 | import { debounce } from 'lodash-es'; | 22 | import { debounce } from 'lodash-es'; |
23 | import { getSnapshot, onSnapshot } from 'mobx-state-tree'; | 23 | import { reaction } from 'mobx'; |
24 | import ms from 'ms'; | 24 | import ms from 'ms'; |
25 | 25 | ||
26 | import type ConfigPersistence from '../infrastructure/ConfigPersistence'; | 26 | import type ConfigPersistence from '../infrastructure/ConfigPersistence'; |
27 | import { Config, ConfigFileIn, ConfigSnapshotOut } from '../stores/Config'; | 27 | import { Config, SharedStore } from '../stores/SharedStore'; |
28 | import type Disposer from '../utils/Disposer'; | 28 | import type Disposer from '../utils/Disposer'; |
29 | import { getLogger } from '../utils/log'; | 29 | import { getLogger } from '../utils/log'; |
30 | 30 | ||
@@ -33,18 +33,18 @@ const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); | |||
33 | const log = getLogger('config'); | 33 | const log = getLogger('config'); |
34 | 34 | ||
35 | export default async function initConfig( | 35 | export 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 @@ | |||
21 | import { nativeTheme } from 'electron'; | 21 | import { nativeTheme } from 'electron'; |
22 | import { autorun } from 'mobx'; | 22 | import { autorun } from 'mobx'; |
23 | 23 | ||
24 | import type { MainStore } from '../stores/MainStore'; | 24 | import type { SharedStore } from '../stores/SharedStore'; |
25 | import type Disposer from '../utils/Disposer'; | 25 | import type Disposer from '../utils/Disposer'; |
26 | import { getLogger } from '../utils/log'; | 26 | import { getLogger } from '../utils/log'; |
27 | 27 | ||
28 | const log = getLogger('nativeTheme'); | 28 | const log = getLogger('nativeTheme'); |
29 | 29 | ||
30 | export default function initNativeTheme(store: MainStore): Disposer { | 30 | export 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 | ||
21 | import type { ConfigSnapshotOut } from '../stores/Config'; | 21 | import type { Config } from '../stores/SharedStore'; |
22 | import type Disposer from '../utils/Disposer'; | 22 | import type Disposer from '../utils/Disposer'; |
23 | 23 | ||
24 | export type ReadConfigResult = | 24 | export type ReadConfigResult = |
@@ -28,7 +28,7 @@ export type ReadConfigResult = | |||
28 | export default interface ConfigPersistence { | 28 | export 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'; | |||
24 | import JSON5 from 'json5'; | 24 | import JSON5 from 'json5'; |
25 | import throttle from 'lodash-es/throttle'; | 25 | import throttle from 'lodash-es/throttle'; |
26 | 26 | ||
27 | import type { ConfigSnapshotOut } from '../../stores/Config'; | 27 | import type { Config } from '../../stores/SharedStore'; |
28 | import type Disposer from '../../utils/Disposer'; | 28 | import type Disposer from '../../utils/Disposer'; |
29 | import { getLogger } from '../../utils/log'; | 29 | import { getLogger } from '../../utils/log'; |
30 | import type ConfigPersistence from '../ConfigPersistence'; | 30 | import 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 | |||
21 | import { config as originalConfig, ThemeSource } from '@sophie/shared'; | ||
22 | import { applySnapshot, Instance, SnapshotIn } from 'mobx-state-tree'; | ||
23 | |||
24 | import { addMissingProfileIds, PartialProfileSnapshotIn } from './Profile'; | ||
25 | import { | ||
26 | addMissingServiceIdsAndProfiles, | ||
27 | PartialServiceSnapshotIn, | ||
28 | } from './Service'; | ||
29 | |||
30 | export 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 | |||
48 | export interface Config extends Instance<typeof config> {} | ||
49 | |||
50 | export interface ConfigSnapshotIn extends SnapshotIn<typeof config> {} | ||
51 | |||
52 | export interface ConfigFileIn | ||
53 | extends Omit<ConfigSnapshotIn, 'profiles' | 'services'> { | ||
54 | profiles?: PartialProfileSnapshotIn[] | undefined; | ||
55 | services?: PartialServiceSnapshotIn[] | undefined; | ||
56 | } | ||
57 | |||
58 | export 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 | |||
21 | import { | ||
22 | globalSettings as originalGlobalSettings, | ||
23 | ThemeSource, | ||
24 | } from '@sophie/shared'; | ||
25 | import { Instance } from 'mobx-state-tree'; | ||
26 | |||
27 | export const globalSettings = originalGlobalSettings.actions((self) => ({ | ||
28 | setThemeSource(mode: ThemeSource): void { | ||
29 | self.themeSource = mode; | ||
30 | }, | ||
31 | })); | ||
32 | |||
33 | export interface GlobalSettings extends Instance<typeof globalSettings> {} | ||
34 | |||
35 | export 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 | ||
21 | import { BrowserViewBounds, service } from '@sophie/shared'; | 21 | import type { BrowserViewBounds } from '@sophie/shared'; |
22 | import { | 22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; |
23 | applySnapshot, | ||
24 | Instance, | ||
25 | resolveIdentifier, | ||
26 | types, | ||
27 | } from 'mobx-state-tree'; | ||
28 | 23 | ||
29 | import { getLogger } from '../utils/log'; | 24 | import { GlobalSettings } from './GlobalSettings'; |
30 | 25 | import { Profile } from './Profile'; | |
31 | import type { Config } from './Config.js'; | 26 | import { Service } from './Service'; |
32 | import { sharedStore } from './SharedStore'; | 27 | import { sharedStore } from './SharedStore'; |
33 | 28 | ||
34 | const log = getLogger('mainStore'); | ||
35 | |||
36 | export const mainStore = types | 29 | export 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 | ||
72 | export interface MainStore extends Instance<typeof mainStore> {} | 59 | export 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 | ||
21 | import type { ProfileSnapshotIn } from '@sophie/shared'; | 21 | import { |
22 | profile as originalProfile, | ||
23 | ProfileSettingsSnapshotIn, | ||
24 | } from '@sophie/shared'; | ||
25 | import { getSnapshot, Instance } from 'mobx-state-tree'; | ||
22 | 26 | ||
27 | import SettingsWithId from '../utils/SettingsWithId'; | ||
23 | import generateId from '../utils/generateId'; | 28 | import generateId from '../utils/generateId'; |
24 | 29 | ||
25 | export interface PartialProfileSnapshotIn | 30 | export interface ProfileConfig extends ProfileSettingsSnapshotIn { |
26 | extends Omit<ProfileSnapshotIn, 'id'> { | ||
27 | id?: string | undefined; | 31 | id?: string | undefined; |
28 | } | 32 | } |
29 | 33 | ||
34 | export const profile = originalProfile.views((self) => ({ | ||
35 | get config(): ProfileConfig { | ||
36 | const { id, settings } = self; | ||
37 | return { ...getSnapshot(settings), id }; | ||
38 | }, | ||
39 | })); | ||
40 | |||
41 | export interface Profile extends Instance<typeof profile> {} | ||
42 | |||
43 | export type ProfileSettingsSnapshotWithId = | ||
44 | SettingsWithId<ProfileSettingsSnapshotIn>; | ||
45 | |||
30 | export function addMissingProfileIds( | 46 | export 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 | |||
46 | export type { | ||
47 | Profile, | ||
48 | ProfileSnapshotOut, | ||
49 | ProfileSnapshotIn, | ||
50 | } from '@sophie/shared'; | ||
51 | export { 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 | |||
21 | import { UnreadCount } from '@sophie/service-shared'; | ||
22 | import { runtimeService as originalRuntimeService } from '@sophie/shared'; | ||
23 | import { Instance } from 'mobx-state-tree'; | ||
24 | |||
25 | export 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 | |||
69 | export interface RuntimeService extends Instance<typeof runtimeService> {} | ||
70 | |||
71 | export 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 | ||
21 | import type { ServiceSnapshotIn } from '@sophie/shared'; | 21 | import type { UnreadCount } from '@sophie/service-shared'; |
22 | import { | ||
23 | service as originalService, | ||
24 | ServiceSettingsSnapshotIn, | ||
25 | } from '@sophie/shared'; | ||
26 | import { Instance, getSnapshot, ReferenceIdentifier } from 'mobx-state-tree'; | ||
22 | 27 | ||
28 | import SettingsWithId from '../utils/SettingsWithId'; | ||
23 | import generateId from '../utils/generateId'; | 29 | import generateId from '../utils/generateId'; |
30 | import overrideProps from '../utils/overrideProps'; | ||
24 | 31 | ||
25 | import type { ProfileSnapshotIn } from './Profile'; | 32 | import { ProfileSettingsSnapshotWithId } from './Profile'; |
33 | import { serviceSettings } from './ServiceSettings'; | ||
26 | 34 | ||
27 | export interface PartialServiceSnapshotIn | 35 | export 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 | ||
42 | export 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 | |||
90 | export interface Service extends Instance<typeof service> {} | ||
91 | |||
92 | export type ServiceSettingsSnapshotWithId = | ||
93 | SettingsWithId<ServiceSettingsSnapshotIn>; | ||
94 | |||
33 | export function addMissingServiceIdsAndProfiles( | 95 | export 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 | |||
58 | export type { | ||
59 | Service, | ||
60 | ServiceSnapshotOut, | ||
61 | ServiceSnapshotIn, | ||
62 | } from '@sophie/shared'; | ||
63 | export { 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 | |||
21 | import { serviceSettings as originalServiceSettings } from '@sophie/shared'; | ||
22 | import { Instance, types } from 'mobx-state-tree'; | ||
23 | |||
24 | import overrideProps from '../utils/overrideProps'; | ||
25 | |||
26 | import { profile } from './Profile'; | ||
27 | |||
28 | export const serviceSettings = overrideProps(originalServiceSettings, { | ||
29 | profile: types.reference(profile), | ||
30 | }); | ||
31 | |||
32 | export interface ServiceSettings extends Instance<typeof serviceSettings> {} | ||
33 | |||
34 | export 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 | ||
21 | import { sharedStore as originalSharedStore } from '@sophie/shared'; | 21 | import { sharedStore as originalSharedStore } from '@sophie/shared'; |
22 | import { Instance, types } from 'mobx-state-tree'; | 22 | import { |
23 | applySnapshot, | ||
24 | getSnapshot, | ||
25 | Instance, | ||
26 | resolveIdentifier, | ||
27 | types, | ||
28 | } from 'mobx-state-tree'; | ||
23 | 29 | ||
24 | import { config } from './Config'; | 30 | import SettingsWithId from '../utils/SettingsWithId'; |
25 | import { runtimeService } from './RuntimeService'; | 31 | import { getLogger } from '../utils/log'; |
32 | import overrideProps from '../utils/overrideProps'; | ||
33 | |||
34 | import { globalSettings, GlobalSettingsSnapshotIn } from './GlobalSettings'; | ||
35 | import { addMissingProfileIds, profile, ProfileConfig } from './Profile'; | ||
36 | import { | ||
37 | addMissingServiceIdsAndProfiles, | ||
38 | service, | ||
39 | ServiceConfig, | ||
40 | } from './Service'; | ||
41 | |||
42 | const log = getLogger('sharedStore'); | ||
43 | |||
44 | export interface Config extends GlobalSettingsSnapshotIn { | ||
45 | profiles?: ProfileConfig[] | undefined; | ||
46 | |||
47 | services?: ServiceConfig[] | undefined; | ||
48 | } | ||
49 | |||
50 | function getConfigs<T>(models: { config: T }[]): T[] | undefined { | ||
51 | return models.length === 0 ? undefined : models.map((model) => model.config); | ||
52 | } | ||
53 | |||
54 | function 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 | |||
71 | export 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 | |||
124 | export interface SharedStore extends Instance<typeof sharedStore> {} | ||
26 | 125 | ||
27 | export type { | 126 | export type { |
28 | SharedStoreSnapshotIn, | 127 | SharedStoreSnapshotIn, |
29 | SharedStoreSnapshotOut, | 128 | SharedStoreSnapshotOut, |
30 | } from '@sophie/shared'; | 129 | } from '@sophie/shared'; |
31 | |||
32 | export const sharedStore = originalSharedStore.props({ | ||
33 | config: types.optional(config, {}), | ||
34 | runtimeServices: types.map(runtimeService), | ||
35 | }); | ||
36 | |||
37 | export 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 | ||
21 | import { config, Config, ConfigFileIn } from '../Config'; | 21 | import type { ProfileConfig } from '../Profile'; |
22 | import type { PartialProfileSnapshotIn } from '../Profile'; | 22 | import type { ServiceConfig } from '../Service'; |
23 | import type { PartialServiceSnapshotIn } from '../Service'; | 23 | import { Config, sharedStore, SharedStore } from '../SharedStore'; |
24 | 24 | ||
25 | const profileProps: PartialProfileSnapshotIn = { | 25 | const profileProps: ProfileConfig = { |
26 | name: 'Test profile', | 26 | name: 'Test profile', |
27 | }; | 27 | }; |
28 | 28 | ||
29 | const serviceProps: PartialServiceSnapshotIn = { | 29 | const serviceProps: ServiceConfig = { |
30 | name: 'Test service', | 30 | name: 'Test service', |
31 | url: 'https://example.com', | 31 | url: 'https://example.com', |
32 | }; | 32 | }; |
33 | 33 | ||
34 | let sut: Config; | 34 | let sut: SharedStore; |
35 | 35 | ||
36 | beforeEach(() => { | 36 | beforeEach(() => { |
37 | sut = config.create(); | 37 | sut = sharedStore.create(); |
38 | }); | 38 | }); |
39 | 39 | ||
40 | describe('preprocessConfigFile', () => { | 40 | describe('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 | |||
21 | export 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 | |||
31 | import { | ||
32 | IAnyModelType, | ||
33 | IModelType, | ||
34 | ModelProperties, | ||
35 | SnapshotIn, | ||
36 | SnapshotOut, | ||
37 | } from 'mobx-state-tree'; | ||
38 | |||
39 | export 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 | |||
46 | export 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 | |||
57 | export 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 | } | ||