diff options
Diffstat (limited to 'packages/preload/src')
-rw-r--r-- | packages/preload/src/contextBridge/SophieRendererImpl.ts | 48 | ||||
-rw-r--r-- | packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts | 211 | ||||
-rw-r--r-- | packages/preload/src/index.ts | 3 |
3 files changed, 233 insertions, 29 deletions
diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts index 153fdd1..61b01e9 100644 --- a/packages/preload/src/contextBridge/SophieRendererImpl.ts +++ b/packages/preload/src/contextBridge/SophieRendererImpl.ts | |||
@@ -30,52 +30,46 @@ import { | |||
30 | import { RendererToMainIpcService } from '../services/RendererToMainIpcService'; | 30 | import { RendererToMainIpcService } from '../services/RendererToMainIpcService'; |
31 | 31 | ||
32 | class SophieRendererImpl implements SophieRenderer { | 32 | class SophieRendererImpl implements SophieRenderer { |
33 | readonly #ipcService: RendererToMainIpcService; | 33 | readonly #ipcService = new RendererToMainIpcService(); |
34 | 34 | ||
35 | #onSharedStoreChangeCalled = false; | 35 | #onSharedStoreChangeCalled: boolean = false; |
36 | 36 | ||
37 | #listener: SharedStoreListener | null = null; | 37 | #listener: SharedStoreListener | null = null; |
38 | 38 | ||
39 | constructor(ipcService: RendererToMainIpcService) { | 39 | constructor() { |
40 | this.#ipcService = ipcService; | 40 | this.#ipcService.onSharedStorePatch((patch) => { |
41 | ipcService.onSharedStorePatch((patch) => { | ||
42 | try { | 41 | try { |
43 | // `mobx-state-tree` will validate the patch, so we can safely cast here. | 42 | // `mobx-state-tree` will validate the patch, so we can safely cast here. |
44 | this.#listener?.onPatch(patch as IJsonPatch); | 43 | this.#listener?.onPatch(patch as IJsonPatch); |
45 | } catch (err) { | 44 | } catch (err) { |
46 | console.log('Shared store listener onPatch failed', err); | 45 | console.error('Shared store listener onPatch failed', err); |
46 | this.#listener = null; | ||
47 | } | 47 | } |
48 | }); | 48 | }); |
49 | } | 49 | } |
50 | 50 | ||
51 | async #setSharedStoreChangeListener(listener: SharedStoreListener): Promise<void> { | 51 | async onSharedStoreChange(listener: SharedStoreListener): Promise<void> { |
52 | let snapshot: unknown; | 52 | if (this.#onSharedStoreChangeCalled) { |
53 | throw new Error('Shared store change listener was already set'); | ||
54 | } | ||
55 | this.#onSharedStoreChangeCalled = true; | ||
56 | let success = false; | ||
57 | let snapshot: unknown | null = null; | ||
53 | try { | 58 | try { |
54 | snapshot = await this.#ipcService.getSharedStoreSnapshot(); | 59 | snapshot = await this.#ipcService.getSharedStoreSnapshot(); |
60 | success = true; | ||
55 | } catch (err) { | 61 | } catch (err) { |
56 | console.error('Failed to get initial shared store snapshot', err); | 62 | console.error('Failed to get initial shared store snapshot', err); |
57 | return; | ||
58 | } | 63 | } |
59 | if (sharedStore.is(snapshot)) { | 64 | if (success) { |
60 | try { | 65 | if (sharedStore.is(snapshot)) { |
61 | listener.onSnapshot(snapshot); | 66 | listener.onSnapshot(snapshot); |
62 | } catch (err) { | 67 | this.#listener = listener; |
63 | console.error('Shared store listener onSnapshot failed', err); | ||
64 | return; | 68 | return; |
65 | } | 69 | } |
66 | this.#listener = listener; | 70 | console.error('Got invalid initial shared store snapshot', snapshot); |
67 | return; | ||
68 | } | 71 | } |
69 | console.error('Got invalid initial shared store snapshot', snapshot); | 72 | throw new Error('Failed to connect to shared store'); |
70 | } | ||
71 | |||
72 | onSharedStoreChange(listener: SharedStoreListener): void { | ||
73 | if (this.#onSharedStoreChangeCalled) { | ||
74 | throw new Error('Shared store change listener was already set'); | ||
75 | } | ||
76 | this.#setSharedStoreChangeListener(listener).catch((err) => { | ||
77 | console.log('Failed to set shared store change listener', err); | ||
78 | }); | ||
79 | } | 73 | } |
80 | 74 | ||
81 | dispatchAction(actionToDispatch: Action): void { | 75 | dispatchAction(actionToDispatch: Action): void { |
@@ -93,8 +87,8 @@ class SophieRendererImpl implements SophieRenderer { | |||
93 | } | 87 | } |
94 | } | 88 | } |
95 | 89 | ||
96 | export function createSophieRenderer(ipcService: RendererToMainIpcService): SophieRenderer { | 90 | export function createSophieRenderer(): SophieRenderer { |
97 | const impl = new SophieRendererImpl(ipcService); | 91 | const impl = new SophieRendererImpl(); |
98 | return { | 92 | return { |
99 | onSharedStoreChange: impl.onSharedStoreChange.bind(impl), | 93 | onSharedStoreChange: impl.onSharedStoreChange.bind(impl), |
100 | dispatchAction: impl.dispatchAction.bind(impl), | 94 | dispatchAction: impl.dispatchAction.bind(impl), |
diff --git a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts new file mode 100644 index 0000000..c0e5ec2 --- /dev/null +++ b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts | |||
@@ -0,0 +1,211 @@ | |||
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 { mocked } from 'jest-mock'; | ||
22 | import type { IJsonPatch } from 'mobx-state-tree'; | ||
23 | import type { Action, SharedStoreSnapshotIn, SophieRenderer } from '@sophie/shared'; | ||
24 | |||
25 | import { RendererToMainIpcService } from '../../services/RendererToMainIpcService'; | ||
26 | import { createSophieRenderer } from '../SophieRendererImpl'; | ||
27 | |||
28 | jest.mock('../../services/RendererToMainIpcService'); | ||
29 | |||
30 | const snapshot: SharedStoreSnapshotIn = { | ||
31 | shouldUseDarkColors: true, | ||
32 | }; | ||
33 | |||
34 | const invalidSnapshot = { | ||
35 | shouldUseDarkColors: -1, | ||
36 | } as unknown as SharedStoreSnapshotIn; | ||
37 | |||
38 | const patch: IJsonPatch = { | ||
39 | op: 'replace', | ||
40 | path: 'foo', | ||
41 | value: 'bar', | ||
42 | }; | ||
43 | |||
44 | const action: Action = { | ||
45 | action: 'set-theme-source', | ||
46 | themeSource: 'dark', | ||
47 | }; | ||
48 | |||
49 | const invalidAction = { | ||
50 | action: 'not-a-valid-action', | ||
51 | } as unknown as Action; | ||
52 | |||
53 | describe('constructor', () => { | ||
54 | it('registers a shared store patch listener', () => { | ||
55 | createSophieRenderer(); | ||
56 | expect(RendererToMainIpcService).toHaveBeenCalledTimes(1); | ||
57 | const service = mocked(RendererToMainIpcService).mock.instances[0]; | ||
58 | expect(service.onSharedStorePatch).toHaveBeenCalledTimes(1); | ||
59 | }); | ||
60 | }); | ||
61 | |||
62 | describe('instance', () => { | ||
63 | let sut: SophieRenderer; | ||
64 | let service: RendererToMainIpcService; | ||
65 | let onSharedStorePatch: (patch: unknown) => void; | ||
66 | let listener = { | ||
67 | onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), | ||
68 | onPatch: jest.fn((_patch: IJsonPatch) => {}), | ||
69 | }; | ||
70 | |||
71 | beforeEach(() => { | ||
72 | sut = createSophieRenderer(); | ||
73 | service = mocked(RendererToMainIpcService).mock.instances[0]; | ||
74 | onSharedStorePatch = mocked(service.onSharedStorePatch).mock.calls[0][0]; | ||
75 | }); | ||
76 | |||
77 | describe('onSharedStoreChange', () => { | ||
78 | it('requests a snapshot from the service', async () => { | ||
79 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); | ||
80 | await sut.onSharedStoreChange(listener); | ||
81 | expect(service.getSharedStoreSnapshot).toBeCalledTimes(1); | ||
82 | }); | ||
83 | |||
84 | it('passes the snapshot to the listener', async () => { | ||
85 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); | ||
86 | await sut.onSharedStoreChange(listener); | ||
87 | expect(listener.onSnapshot).toBeCalledWith(snapshot); | ||
88 | }); | ||
89 | |||
90 | it('catches service errors without exposing them', async () => { | ||
91 | mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error('s3cr3t')); | ||
92 | await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( | ||
93 | 'message', | ||
94 | expect.stringMatching(/s3cr3t/), | ||
95 | ); | ||
96 | expect(listener.onSnapshot).toBeCalledTimes(0); | ||
97 | }); | ||
98 | |||
99 | it('does not pass on invalid snapshots', async () => { | ||
100 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); | ||
101 | await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); | ||
102 | expect(listener.onSnapshot).toBeCalledTimes(0); | ||
103 | }); | ||
104 | }); | ||
105 | |||
106 | describe('dispatchAction', () => { | ||
107 | it('dispatched valid actions', () => { | ||
108 | sut.dispatchAction(action); | ||
109 | expect(service.dispatchAction).toBeCalledWith(action); | ||
110 | }); | ||
111 | |||
112 | it('does not dispatch invalid actions', () => { | ||
113 | expect(() => sut.dispatchAction(invalidAction)).toThrowError(); | ||
114 | expect(service.dispatchAction).toBeCalledTimes(0); | ||
115 | }); | ||
116 | }); | ||
117 | |||
118 | describe('when no listener is registered', () => { | ||
119 | it('discards the received patch', () => { | ||
120 | onSharedStorePatch(patch); | ||
121 | }); | ||
122 | }); | ||
123 | |||
124 | function itRefusesToRegisterAnotherListener() { | ||
125 | it('refuses to register another listener', async () => { | ||
126 | await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); | ||
127 | }); | ||
128 | } | ||
129 | |||
130 | function itDoesNotPassPatchesToTheListener() { | ||
131 | it('does not pass patches to the listener', () => { | ||
132 | onSharedStorePatch(patch); | ||
133 | expect(listener.onPatch).toBeCalledTimes(0); | ||
134 | }); | ||
135 | } | ||
136 | |||
137 | describe('when a listener registered successfully', () => { | ||
138 | beforeEach(async () => { | ||
139 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); | ||
140 | await sut.onSharedStoreChange(listener); | ||
141 | }); | ||
142 | |||
143 | it('passes patches to the listener', () => { | ||
144 | onSharedStorePatch(patch); | ||
145 | expect(listener.onPatch).toBeCalledWith(patch); | ||
146 | }); | ||
147 | |||
148 | it('catches listener errors', () => { | ||
149 | mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); | ||
150 | onSharedStorePatch(patch); | ||
151 | }); | ||
152 | |||
153 | itRefusesToRegisterAnotherListener(); | ||
154 | |||
155 | describe('that later threw an error', () => { | ||
156 | beforeEach(() => { | ||
157 | mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); | ||
158 | onSharedStorePatch(patch); | ||
159 | listener.onPatch.mockRestore(); | ||
160 | }); | ||
161 | |||
162 | itDoesNotPassPatchesToTheListener(); | ||
163 | }); | ||
164 | }); | ||
165 | |||
166 | describe('when a listener failed to register due to service error', () => { | ||
167 | beforeEach(async () => { | ||
168 | mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error()); | ||
169 | try { | ||
170 | await sut.onSharedStoreChange(listener); | ||
171 | } catch { | ||
172 | // Ignore error. | ||
173 | } | ||
174 | }); | ||
175 | |||
176 | itRefusesToRegisterAnotherListener(); | ||
177 | |||
178 | itDoesNotPassPatchesToTheListener(); | ||
179 | }); | ||
180 | |||
181 | describe('when a listener failed to register due to an invalid snapshot', () => { | ||
182 | beforeEach(async () => { | ||
183 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); | ||
184 | try { | ||
185 | await sut.onSharedStoreChange(listener); | ||
186 | } catch { | ||
187 | // Ignore error. | ||
188 | } | ||
189 | }); | ||
190 | |||
191 | itRefusesToRegisterAnotherListener(); | ||
192 | |||
193 | itDoesNotPassPatchesToTheListener(); | ||
194 | }); | ||
195 | |||
196 | describe('when a listener failed to register due to listener error', () => { | ||
197 | beforeEach(async () => { | ||
198 | mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); | ||
199 | mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); | ||
200 | try { | ||
201 | await sut.onSharedStoreChange(listener); | ||
202 | } catch { | ||
203 | // Ignore error. | ||
204 | } | ||
205 | }); | ||
206 | |||
207 | itRefusesToRegisterAnotherListener(); | ||
208 | |||
209 | itDoesNotPassPatchesToTheListener(); | ||
210 | }); | ||
211 | }); | ||
diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 9336433..ef466b4 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts | |||
@@ -21,8 +21,7 @@ | |||
21 | import { contextBridge } from 'electron'; | 21 | import { contextBridge } from 'electron'; |
22 | 22 | ||
23 | import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; | 23 | import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; |
24 | import { RendererToMainIpcService } from './services/RendererToMainIpcService'; | ||
25 | 24 | ||
26 | const sophieRenderer = createSophieRenderer(new RendererToMainIpcService()); | 25 | const sophieRenderer = createSophieRenderer(); |
27 | 26 | ||
28 | contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer); | 27 | contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer); |