From d07e7b834831230b53860d0919a68edc2d36193d Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 8 Jan 2022 21:36:43 +0100 Subject: build: Eslint fixes for multi-module project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kristóf Marussy --- packages/preload/.eslintrc.cjs | 6 + packages/preload/esbuild.config.js | 6 +- packages/preload/jest.config.js | 2 +- packages/preload/package.json | 6 +- .../src/contextBridge/SophieRendererImpl.ts | 96 -------- .../__tests__/SophieRendererImpl.spec.ts | 258 --------------------- .../__tests__/createSophieRenderer.spec.ts | 258 +++++++++++++++++++++ .../src/contextBridge/createSophieRenderer.ts | 96 ++++++++ packages/preload/src/index.ts | 2 +- packages/preload/tsconfig.json | 8 +- 10 files changed, 372 insertions(+), 366 deletions(-) create mode 100644 packages/preload/.eslintrc.cjs delete mode 100644 packages/preload/src/contextBridge/SophieRendererImpl.ts delete mode 100644 packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts create mode 100644 packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts create mode 100644 packages/preload/src/contextBridge/createSophieRenderer.ts (limited to 'packages/preload') diff --git a/packages/preload/.eslintrc.cjs b/packages/preload/.eslintrc.cjs new file mode 100644 index 0000000..02fab21 --- /dev/null +++ b/packages/preload/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + browser: true, + }, +}; diff --git a/packages/preload/esbuild.config.js b/packages/preload/esbuild.config.js index b73a071..66f5e84 100644 --- a/packages/preload/esbuild.config.js +++ b/packages/preload/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/preload/jest.config.js b/packages/preload/jest.config.js index e474c4c..27af475 100644 --- a/packages/preload/jest.config.js +++ b/packages/preload/jest.config.js @@ -1,6 +1,6 @@ import rootConfig from '../../config/jest.config.base.js'; -/** @type {import('ts-jest').InitialOptionsTsJest} */ +/** @type {import('@jest/types').Config.InitialOptions} */ export default { ...rootConfig, testEnvironment: 'jsdom', diff --git a/packages/preload/package.json b/packages/preload/package.json index 0957aaf..a03d7d9 100644 --- a/packages/preload/package.json +++ b/packages/preload/package.json @@ -6,7 +6,7 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@sophie/shared": "workspace:*", @@ -20,8 +20,6 @@ "@types/jest": "^27.4.0", "jest": "^27.4.7", "jest-mock": "^27.4.6", - "jsdom": "^19.0.0", - "rimraf": "^3.0.2", - "typescript": "^4.5.4" + "jsdom": "^19.0.0" } } diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts deleted file mode 100644 index f3c07c5..0000000 --- a/packages/preload/src/contextBridge/SophieRendererImpl.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2021-2022 Kristóf Marussy - * - * This file is part of Sophie. - * - * Sophie is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ipcRenderer } from 'electron'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - sharedStore, - SharedStoreListener, - SophieRenderer, -} from '@sophie/shared'; - -class SophieRendererImpl implements SophieRenderer { - private onSharedStoreChangeCalled: boolean = false; - - private listener: SharedStoreListener | null = null; - - constructor(private readonly allowReplaceListener: boolean) { - ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { - try { - // `mobx-state-tree` will validate the patch, so we can safely cast here. - this.listener?.onPatch(patch as IJsonPatch); - } catch (err) { - log.error('Shared store listener onPatch failed', err); - this.listener = null; - } - }); - } - - async onSharedStoreChange(listener: SharedStoreListener): Promise { - if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { - throw new Error('Shared store change listener was already set'); - } - this.onSharedStoreChangeCalled = true; - let success = false; - let snapshot: unknown | null = null; - try { - snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); - success = true; - } catch (err) { - log.error('Failed to get initial shared store snapshot', err); - } - if (success) { - if (sharedStore.is(snapshot)) { - listener.onSnapshot(snapshot); - this.listener = listener; - return; - } - log.error('Got invalid initial shared store snapshot', snapshot); - } - throw new Error('Failed to connect to shared store'); - } - - dispatchAction(actionToDispatch: Action): void { - // Let the full zod parse error bubble up to the main world, - // since all data it may contain was provided from the main world. - const parsedAction = action.parse(actionToDispatch); - try { - ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); - } catch (err) { - // Do not leak IPC failure details into the main world. - const message = 'Failed to dispatch action'; - log.error(message, actionToDispatch, err); - throw new Error(message); - } - } -} - -export function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { - const impl = new SophieRendererImpl(allowReplaceListener); - return { - onSharedStoreChange: impl.onSharedStoreChange.bind(impl), - 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 deleted file mode 100644 index ff77a63..0000000 --- a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright (C) 2021-2022 Kristóf Marussy - * - * This file is part of Sophie. - * - * Sophie is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - SharedStoreSnapshotIn, - SophieRenderer, -} from '@sophie/shared'; - -jest.unstable_mockModule('electron', () => ({ - ipcRenderer: { - invoke: jest.fn(), - on: jest.fn(), - send: jest.fn(), - }, -})); - -const { ipcRenderer } = await import('electron'); - -const { createSophieRenderer } = await import('../SophieRendererImpl'); - -const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; - -const snapshot: SharedStoreSnapshotIn = { - shouldUseDarkColors: true, -}; - -const invalidSnapshot = { - shouldUseDarkColors: -1, -} as unknown as SharedStoreSnapshotIn; - -const patch: IJsonPatch = { - op: 'replace', - path: 'foo', - value: 'bar', -}; - -const action: Action = { - action: 'set-theme-source', - themeSource: 'dark', -}; - -const invalidAction = { - action: 'not-a-valid-action', -} as unknown as Action; - -beforeAll(() => { - log.disableAll(); -}); - -describe('createSophieRenderer', () => { - it('registers a shared store patch listener', () => { - createSophieRenderer(false); - expect(ipcRenderer.on).toHaveBeenCalledWith( - MainToRendererIpcMessage.SharedStorePatch, - expect.anything(), - ); - }); -}); - -describe('SophieRendererImpl', () => { - let sut: SophieRenderer; - let onSharedStorePatch: (event1: Electron.IpcRendererEvent, patch1: unknown) => void; - let listener = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => {}), - }; - - beforeEach(() => { - sut = createSophieRenderer(false); - onSharedStorePatch = mocked(ipcRenderer.on).mock.calls.find(([channel]) => { - return channel === MainToRendererIpcMessage.SharedStorePatch; - })?.[1]!; - }); - - describe('onSharedStoreChange', () => { - it('should request a snapshot from the main process', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener.onSnapshot).toBeCalledWith(snapshot); - }); - - it('should catch IPC errors without exposing them', async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); - await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( - 'message', - expect.stringMatching(/s3cr3t/), - ); - expect(listener.onSnapshot).not.toBeCalled(); - }); - - it('should not pass on invalid snapshots', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - expect(listener.onSnapshot).not.toBeCalled(); - }); - }); - - describe('dispatchAction', () => { - it('should dispatch valid actions', () => { - sut.dispatchAction(action); - expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); - }); - - it('should not dispatch invalid actions', () => { - expect(() => sut.dispatchAction(invalidAction)).toThrowError(); - expect(ipcRenderer.send).not.toBeCalled(); - }); - }); - - describe('when no listener is registered', () => { - it('should discard the received patch without any error', () => { - onSharedStorePatch(event, patch); - }); - }); - - function itRefusesToRegisterAnotherListener() { - it('should refuse to register another listener', async () => { - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - }); - } - - function itDoesNotPassPatchesToTheListener( - name: string = 'should not pass patches to the listener', - ) { - it(name, () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).not.toBeCalled(); - }); - } - - describe('when a listener registered successfully', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - }); - - it('should pass patches to the listener', () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).toBeCalledWith(patch); - }); - - it('should catch listener errors', () => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - }); - - itRefusesToRegisterAnotherListener(); - - describe('after the listener threw in onPatch', () => { - beforeEach(() => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - listener.onPatch.mockRestore(); - }); - - itDoesNotPassPatchesToTheListener('should not pass on patches any more'); - }); - }); - - describe('when a listener failed to register due to IPC error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to an invalid snapshot', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to listener error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when it is allowed to replace listeners', () => { - const snapshot2 = { - shouldUseDarkColors: false, - }; - const listener2 = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => { }), - }; - - it('should fetch a second snapshot', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener2.onSnapshot).toBeCalledWith(snapshot2); - }); - - it('should pass the second snapshot to the new listener', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - onSharedStorePatch(event, patch); - expect(listener2.onPatch).toBeCalledWith(patch); - }); - }); -}); diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts new file mode 100644 index 0000000..a38dbac --- /dev/null +++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { + Action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + SharedStoreSnapshotIn, + SophieRenderer, +} from '@sophie/shared'; +import { mocked } from 'jest-mock'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +jest.unstable_mockModule('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }, +})); + +const { ipcRenderer } = await import('electron'); + +const { default: createSophieRenderer } = await import('../createSophieRenderer'); + +const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; + +const snapshot: SharedStoreSnapshotIn = { + shouldUseDarkColors: true, +}; + +const invalidSnapshot = { + shouldUseDarkColors: -1, +} as unknown as SharedStoreSnapshotIn; + +const patch: IJsonPatch = { + op: 'replace', + path: 'foo', + value: 'bar', +}; + +const action: Action = { + action: 'set-theme-source', + themeSource: 'dark', +}; + +const invalidAction = { + action: 'not-a-valid-action', +} as unknown as Action; + +beforeAll(() => { + log.disableAll(); +}); + +describe('createSophieRenderer', () => { + it('registers a shared store patch listener', () => { + createSophieRenderer(false); + expect(ipcRenderer.on).toHaveBeenCalledWith( + MainToRendererIpcMessage.SharedStorePatch, + expect.anything(), + ); + }); +}); + +describe('SharedStoreConnector', () => { + let sut: SophieRenderer; + let onSharedStorePatch: (eventArg: Electron.IpcRendererEvent, patchArg: unknown) => void; + const listener = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => {}), + }; + + beforeEach(() => { + sut = createSophieRenderer(false); + [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find( + ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch, + )!; + }); + + describe('onSharedStoreChange', () => { + it('should request a snapshot from the main process', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener.onSnapshot).toBeCalledWith(snapshot); + }); + + it('should catch IPC errors without exposing them', async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); + await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( + 'message', + expect.stringMatching(/s3cr3t/), + ); + expect(listener.onSnapshot).not.toBeCalled(); + }); + + it('should not pass on invalid snapshots', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + expect(listener.onSnapshot).not.toBeCalled(); + }); + }); + + describe('dispatchAction', () => { + it('should dispatch valid actions', () => { + sut.dispatchAction(action); + expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); + }); + + it('should not dispatch invalid actions', () => { + expect(() => sut.dispatchAction(invalidAction)).toThrowError(); + expect(ipcRenderer.send).not.toBeCalled(); + }); + }); + + describe('when no listener is registered', () => { + it('should discard the received patch without any error', () => { + onSharedStorePatch(event, patch); + }); + }); + + function itRefusesToRegisterAnotherListener(): void { + it('should refuse to register another listener', async () => { + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + }); + } + + function itDoesNotPassPatchesToTheListener( + name = 'should not pass patches to the listener', + ): void { + it(name, () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).not.toBeCalled(); + }); + } + + describe('when a listener registered successfully', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + }); + + it('should pass patches to the listener', () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).toBeCalledWith(patch); + }); + + it('should catch listener errors', () => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + }); + + itRefusesToRegisterAnotherListener(); + + describe('after the listener threw in onPatch', () => { + beforeEach(() => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + listener.onPatch.mockRestore(); + }); + + itDoesNotPassPatchesToTheListener('should not pass on patches any more'); + }); + }); + + describe('when a listener failed to register due to IPC error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to an invalid snapshot', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to listener error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when it is allowed to replace listeners', () => { + const snapshot2 = { + shouldUseDarkColors: false, + }; + const listener2 = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => { }), + }; + + it('should fetch a second snapshot', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener2.onSnapshot).toBeCalledWith(snapshot2); + }); + + it('should pass the second snapshot to the new listener', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + onSharedStorePatch(event, patch); + expect(listener2.onPatch).toBeCalledWith(patch); + }); + }); +}); diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts new file mode 100644 index 0000000..2055080 --- /dev/null +++ b/packages/preload/src/contextBridge/createSophieRenderer.ts @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + Action, + action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + sharedStore, + SharedStoreListener, + SophieRenderer, +} from '@sophie/shared'; +import { ipcRenderer } from 'electron'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +class SharedStoreConnector { + private onSharedStoreChangeCalled = false; + + private listener: SharedStoreListener | null = null; + + constructor(private readonly allowReplaceListener: boolean) { + ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { + try { + // `mobx-state-tree` will validate the patch, so we can safely cast here. + this.listener?.onPatch(patch as IJsonPatch); + } catch (err) { + log.error('Shared store listener onPatch failed', err); + this.listener = null; + } + }); + } + + async onSharedStoreChange(listener: SharedStoreListener): Promise { + if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { + throw new Error('Shared store change listener was already set'); + } + this.onSharedStoreChangeCalled = true; + let success = false; + let snapshot: unknown | null = null; + try { + snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); + success = true; + } catch (err) { + log.error('Failed to get initial shared store snapshot', err); + } + if (success) { + if (sharedStore.is(snapshot)) { + listener.onSnapshot(snapshot); + this.listener = listener; + return; + } + log.error('Got invalid initial shared store snapshot', snapshot); + } + throw new Error('Failed to connect to shared store'); + } +} + +function dispatchAction(actionToDispatch: Action): void { + // Let the full zod parse error bubble up to the main world, + // since all data it may contain was provided from the main world. + const parsedAction = action.parse(actionToDispatch); + try { + ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); + } catch (err) { + // Do not leak IPC failure details into the main world. + const message = 'Failed to dispatch action'; + log.error(message, actionToDispatch, err); + throw new Error(message); + } +} + +export default function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { + const connector = new SharedStoreConnector(allowReplaceListener); + return { + onSharedStoreChange: connector.onSharedStoreChange.bind(connector), + dispatchAction, + }; +} diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index de91742..f13220c 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -20,7 +20,7 @@ import { contextBridge } from 'electron'; -import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; +import createSophieRenderer from './contextBridge/createSophieRenderer'; const isDevelopment = import.meta.env.MODE === 'development'; diff --git a/packages/preload/tsconfig.json b/packages/preload/tsconfig.json index 741d435..ff49538 100644 --- a/packages/preload/tsconfig.json +++ b/packages/preload/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "lib": [ @@ -13,11 +13,13 @@ }, "references": [ { - "path": "../shared" + "path": "../shared/tsconfig.build.json" } ], "include": [ "src/**/*.ts", - "types/**/*.d.ts" + "types/**/*.d.ts", + "esbuild.config.js", + "jest.config.js" ] } -- cgit v1.2.3-54-g00ecf