From 950fb9be8061e2a26e0536b98c6a3ee230618f54 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 23 Dec 2021 13:40:47 +0100 Subject: feat: Main to renderer store synchronization Patches are send in one direction only, from the main to the renderer, so all actions have to go through the context bridge and the renderer IPC to modify the store in the renderer. This makes the store in the main process a single source of truth, which simplifies debugging and state persistence. The store in the renderer is connected to redux devtools for inspection, but playing back the state in the devtools won't change the sotre in main process. --- packages/main/package.json | 6 +- packages/main/src/devTools.ts | 49 ++++++++++++++ packages/main/src/index.ts | 66 ++++++++++--------- packages/main/src/stores/RootStore.ts | 12 ++++ packages/preload/package.json | 4 +- packages/preload/src/SophieRendererImpl.ts | 74 ++++++++++++++++++++++ packages/preload/src/index.ts | 11 ++-- packages/renderer/package.json | 6 +- packages/renderer/src/devTools.ts | 37 +++++++++++ packages/renderer/src/index.tsx | 23 ++++++- packages/renderer/tsconfig.json | 3 + packages/renderer/vite.config.js | 3 +- packages/shared/package.json | 5 ++ .../shared/src/contextBridge/SophieRenderer.ts | 6 +- packages/shared/src/index.ts | 11 +++- .../shared/src/ipc/MainToRendererIpcMessage.ts | 4 ++ packages/shared/src/ipc/RendererIpcMessage.ts | 3 - .../shared/src/ipc/RendererToMainIpcMessage.ts | 4 ++ packages/shared/src/stores/SharedStore.ts | 23 +++++++ packages/shared/vite.config.js | 8 +-- 20 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 packages/main/src/devTools.ts create mode 100644 packages/main/src/stores/RootStore.ts create mode 100644 packages/preload/src/SophieRendererImpl.ts create mode 100644 packages/renderer/src/devTools.ts create mode 100644 packages/shared/src/ipc/MainToRendererIpcMessage.ts delete mode 100644 packages/shared/src/ipc/RendererIpcMessage.ts create mode 100644 packages/shared/src/ipc/RendererToMainIpcMessage.ts create mode 100644 packages/shared/src/stores/SharedStore.ts (limited to 'packages') diff --git a/packages/main/package.json b/packages/main/package.json index 63da432..22afafa 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -9,11 +9,13 @@ }, "dependencies": { "@sophie/shared": "workspace:*", - "electron": "^16.0.5" + "electron": "^16.0.5", + "mobx": "^6.3.10", + "mobx-state-tree": "^5.1.0" }, "devDependencies": { "@types/electron-devtools-installer": "^2.2.0", - "@types/node": "^16.11.15", + "@types/node": "^16.11.17", "electron-devtools-installer": "^3.2.0", "typescript": "^4.5.4", "vite": "^2.7.6" diff --git a/packages/main/src/devTools.ts b/packages/main/src/devTools.ts new file mode 100644 index 0000000..d02bfbf --- /dev/null +++ b/packages/main/src/devTools.ts @@ -0,0 +1,49 @@ +import type { App, BrowserWindow } from 'electron'; + +/** + * Installs the react and redux developer tools extensions. + * + * We use the redux devtools and connect the mobx store to it with `mst-middlewares`, + * because the mobx-state-tree devtools are currently unmaintained. + * + * @param app The electron application instance. + */ +export function installDevToolsExtensions(app: App): void { + app.whenReady().then(async () => { + const { + default: installExtension, + REACT_DEVELOPER_TOOLS, + REDUX_DEVTOOLS, + } = await import('electron-devtools-installer'); + installExtension( + [ + REACT_DEVELOPER_TOOLS, + REDUX_DEVTOOLS, + ], + { + forceDownload: false, + loadExtensionOptions: { + allowFileAccess: true, + }, + }, + ); + }).catch((err) => { + console.error('Failed to install devtools extension', err); + }); +} + +/** + * Opens the developer tools while applying a workaround to enable the redux devtools. + * + * @param browserWindow The browser window to open the devtools in. + * @see https://github.com/MarshallOfSound/electron-devtools-installer/issues/195#issuecomment-998872878 + */ +export function openDevToolsWhenReady(browserWindow: BrowserWindow): void { + const { webContents } = browserWindow; + webContents.once('dom-ready', () => { + webContents.once('devtools-opened', () => { + browserWindow?.focus(); + }); + webContents.openDevTools(); + }); +} diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index cd04276..dbe6890 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -1,8 +1,18 @@ import { app, BrowserWindow } from 'electron'; +import { getSnapshot, onPatch } from 'mobx-state-tree'; import { join } from 'path'; -import { RendererIpcMessage } from '@sophie/shared'; +import { + MainToRendererIpcMessage, + RendererToMainIpcMessage, +} from '@sophie/shared'; import { URL } from 'url'; +import { + installDevToolsExtensions, + openDevToolsWhenReady, +} from './devTools'; +import { rootStore } from './stores/RootStore'; + const isSingleInstance = app.requestSingleInstanceLock(); const isDevelopment = import.meta.env.MODE === 'development'; @@ -14,62 +24,46 @@ if (!isSingleInstance) { app.enableSandbox(); if (isDevelopment) { - app.whenReady().then(async () => { - const { - default: installExtension, - MOBX_DEVTOOLS, - REACT_DEVELOPER_TOOLS, - } = await import('electron-devtools-installer'); - installExtension( - [ - MOBX_DEVTOOLS, - REACT_DEVELOPER_TOOLS, - ], - { - forceDownload: false, - loadExtensionOptions: { - allowFileAccess: true, - }, - }, - ); - }).catch((err) => { - console.error('Failed to install devtools extension', err); - }); + installDevToolsExtensions(app); } let mainWindow: BrowserWindow | null = null; +const store = rootStore.create({ + shared: { + clickCount: 1, + }, +}); + function createWindow(): Promise { mainWindow = new BrowserWindow({ show: false, + autoHideMenuBar: true, webPreferences: { nativeWindowOpen: true, webviewTag: false, sandbox: true, preload: join(__dirname, '../../preload/dist/index.cjs'), - } + }, }); - const { webContents } = mainWindow; - - // See https://github.com/MarshallOfSound/electron-devtools-installer/issues/195#issuecomment-998872878 if (isDevelopment) { - webContents.once('dom-ready', () => { - webContents.once('devtools-opened', () => { - mainWindow?.focus(); - }); - webContents.openDevTools(); - }); + openDevToolsWhenReady(mainWindow); } mainWindow.on('ready-to-show', () => { mainWindow?.show(); }); + const { webContents } = mainWindow; + webContents.on('ipc-message', (_event, channel, ...args) => { switch (channel) { - case RendererIpcMessage.ButtonClicked: - console.log('Button clicked'); + case RendererToMainIpcMessage.SharedStoreSnapshotRequest: + webContents.send(MainToRendererIpcMessage.SharedStoreSnapshot, getSnapshot(store.shared)); + break; + case RendererToMainIpcMessage.ButtonClick: + store.buttonClick(); break; default: console.warn('Unknown IPC message:', channel, args); @@ -77,6 +71,10 @@ function createWindow(): Promise { } }); + onPatch(store.shared, (patch) => { + webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); + }); + const pageUrl = (isDevelopment && import.meta.env.VITE_DEV_SERVER_URL !== undefined) ? import.meta.env.VITE_DEV_SERVER_URL : new URL('../renderer/dist/index.html', `file://${__dirname}`).toString(); diff --git a/packages/main/src/stores/RootStore.ts b/packages/main/src/stores/RootStore.ts new file mode 100644 index 0000000..a9c1290 --- /dev/null +++ b/packages/main/src/stores/RootStore.ts @@ -0,0 +1,12 @@ +import { Instance, types } from 'mobx-state-tree'; +import { sharedStore } from '@sophie/shared'; + +export const rootStore = types.model('RootStore', { + shared: sharedStore, +}).actions((self) => ({ + buttonClick() { + self.shared.clickCount += 1; + }, +})); + +export interface RootStore extends Instance {} diff --git a/packages/preload/package.json b/packages/preload/package.json index 5556c10..2ad215c 100644 --- a/packages/preload/package.json +++ b/packages/preload/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@sophie/shared": "workspace:*", - "electron": "^16.0.5" + "electron": "^16.0.5", + "mobx": "^6.3.10", + "mobx-state-tree": "^5.1.0" }, "devDependencies": { "typescript": "^4.5.4", diff --git a/packages/preload/src/SophieRendererImpl.ts b/packages/preload/src/SophieRendererImpl.ts new file mode 100644 index 0000000..e03c89e --- /dev/null +++ b/packages/preload/src/SophieRendererImpl.ts @@ -0,0 +1,74 @@ +import { ipcRenderer } from 'electron'; +import type { IJsonPatch } from 'mobx-state-tree'; +import { + MainToRendererIpcMessage, + RendererToMainIpcMessage, + sharedStore, + SharedStoreListener, + SharedStoreSnapshotIn, + SophieRenderer +} from '@sophie/shared'; + +export type MessageSender = (channel: RendererToMainIpcMessage, ...args: unknown[]) => void; + +export class SophieRendererImpl implements SophieRenderer { + readonly #send: MessageSender; + + #listener: SharedStoreListener | null = null; + + #snapshot: SharedStoreSnapshotIn | null = null; + + constructor(send: MessageSender) { + this.#send = send; + } + + sharedStoreSnapshotReceived(snapshot: unknown): void { + if (sharedStore.is(snapshot)) { + if (this.#listener === null) { + this.#snapshot = snapshot; + } else { + this.#listener.onSnapshot(snapshot); + } + } else { + console.error('Received invalid snapshot', snapshot); + this.#snapshot = null; + } + } + + sharedStorePatchReceived(patch: unknown): void { + if (this.#listener !== null) { + // `mobx-state-tree` will validate the patch, so we can safely cast here. + this.#listener.onPatch(patch as IJsonPatch); + } + } + + setSharedStoreListener(listener: SharedStoreListener): void { + this.#listener = listener; + if (this.#snapshot !== null) { + listener.onSnapshot(this.#snapshot); + this.#snapshot = null; + } + this.#send(RendererToMainIpcMessage.SharedStoreSnapshotRequest); + } + + buttonClick(): void { + this.#send(RendererToMainIpcMessage.ButtonClick); + } +} + +export function createSophieRenderer(): SophieRenderer { + const impl = new SophieRendererImpl(ipcRenderer.send); + + ipcRenderer.on(MainToRendererIpcMessage.SharedStoreSnapshot, (_event, snapshot) => { + impl.sharedStoreSnapshotReceived(snapshot); + }); + + ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { + impl.sharedStorePatchReceived(patch); + }); + + return { + setSharedStoreListener: impl.setSharedStoreListener.bind(impl), + buttonClick: impl.buttonClick.bind(impl), + }; +} diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 830901a..19d1285 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -1,10 +1,7 @@ -import { contextBridge, ipcRenderer } from 'electron'; -import { RendererIpcMessage, SophieRenderer } from '@sophie/shared'; +import { contextBridge } from 'electron'; -const sophieRenderer: SophieRenderer = { - buttonClicked() { - ipcRenderer.send(RendererIpcMessage.ButtonClicked); - } -}; +import { createSophieRenderer } from './SophieRendererImpl'; + +const sophieRenderer = createSophieRenderer(); contextBridge.exposeInMainWorld('sophieRenderer', sophieRenderer); diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 559e668..ee627b4 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -14,13 +14,17 @@ "@mui/icons-material": "^5.2.5", "@mui/material": "^5.2.5", "@sophie/shared": "workspace:*", + "mobx": "^6.3.10", + "mobx-state-tree": "^5.1.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@types/react": "^17.0.37", + "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", "@vitejs/plugin-react": "^1.1.3", + "mst-middlewares": "^5.1.0", + "remotedev": "^0.2.9", "typescript": "^4.5.4", "vite": "^2.7.6" } diff --git a/packages/renderer/src/devTools.ts b/packages/renderer/src/devTools.ts new file mode 100644 index 0000000..5930c48 --- /dev/null +++ b/packages/renderer/src/devTools.ts @@ -0,0 +1,37 @@ +import type { IAnyStateTreeNode } from 'mobx-state-tree'; + +/** + * Connects the `model` to the redux devtools extension after loading the required + * dependencies asynchronously. + * + * We have to apply a workaround to load `remotedev` by shimming the `global` object, + * because `remotedev` uses an old version of `socketcluster-client` that refers to + * `global` instead of `globalThis`. + * + * Due to the old dependencies, this function is not safe to call in production. + * However, we don't bundle `remotedev` in production, so the call would fail anyways. + * + * @param model The store to connect to the redux devtools. + * @see https://github.com/SocketCluster/socketcluster-client/issues/118#issuecomment-469064682 + */ +async function exposeToReduxDevtoolsAsync(model: IAnyStateTreeNode): Promise { + (window as { global?: unknown }).global = window; + + const [remotedev, { connectReduxDevtools }] = await Promise.all([ + // @ts-ignore + import('remotedev'), + import('mst-middlewares'), + ]); + connectReduxDevtools(remotedev, model); +} + +/** + * Connects the `model` to the redux devtools extension. + * + * @param model The store to connect to the redux devtools. + */ +export function exposeToReduxDevtools(model: IAnyStateTreeNode): void { + exposeToReduxDevtoolsAsync(model).catch((err) => { + console.error('Could not connect to Redux devtools', err); + }); +} diff --git a/packages/renderer/src/index.tsx b/packages/renderer/src/index.tsx index 452448c..37daaa6 100644 --- a/packages/renderer/src/index.tsx +++ b/packages/renderer/src/index.tsx @@ -2,6 +2,7 @@ import '@fontsource/roboto/300.css'; import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; +import { applyPatch, applySnapshot } from 'mobx-state-tree'; import Button from "@mui/material/Button"; import CssBaseline from "@mui/material/CssBaseline"; import { @@ -10,6 +11,26 @@ import { } from '@mui/material/styles'; import React from 'react'; import { render } from 'react-dom'; +import { sharedStore } from '@sophie/shared'; + +import { exposeToReduxDevtools } from './devTools'; + +const isDevelopment = import.meta.env.MODE === 'development'; + +const store = sharedStore.create(); + +if (isDevelopment) { + exposeToReduxDevtools(store); +} + +window.sophieRenderer.setSharedStoreListener({ + onSnapshot(snapshot) { + applySnapshot(store, snapshot); + }, + onPatch(patch) { + applyPatch(store, patch); + }, +}); const theme = createTheme({ palette: { @@ -24,7 +45,7 @@ function App() { diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json index 668356c..8746462 100644 --- a/packages/renderer/tsconfig.json +++ b/packages/renderer/tsconfig.json @@ -7,6 +7,9 @@ "dom", "dom.iterable", "esnext" + ], + "types": [ + "vite/client" ] }, "references": [ diff --git a/packages/renderer/vite.config.js b/packages/renderer/vite.config.js index ff34b6d..87c2d0c 100644 --- a/packages/renderer/vite.config.js +++ b/packages/renderer/vite.config.js @@ -3,7 +3,6 @@ /* eslint-env node */ import { builtinModules } from 'module'; -import { join } from 'path'; import react from '@vitejs/plugin-react'; // `resolveJsonModule` is disabled for this package, but vite will load the json nevertheless. @@ -36,6 +35,8 @@ const config = { assetsDir: '.', rollupOptions: { external: [ + 'mst-middlewares', + 'remotedev', ...builtinModules, ], }, diff --git a/packages/shared/package.json b/packages/shared/package.json index ccde705..f49be47 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "main": "dist/index.cjs", + "module": "dist/index.es.js", "types": "dist/index.d.ts", "scripts": { "build": "vite build", @@ -11,5 +12,9 @@ "devDependencies": { "typescript": "^4.5.4", "vite": "^2.7.6" + }, + "dependencies": { + "mobx": "^6.3.10", + "mobx-state-tree": "^5.1.0" } } diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts index 91a0a3c..c878595 100644 --- a/packages/shared/src/contextBridge/SophieRenderer.ts +++ b/packages/shared/src/contextBridge/SophieRenderer.ts @@ -1,3 +1,7 @@ +import { SharedStoreListener } from '../stores/SharedStore'; + export interface SophieRenderer { - buttonClicked(): void; + setSharedStoreListener(listener: SharedStoreListener): void; + + buttonClick(): void; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f95675d..7f192bd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,12 @@ export type { SophieRenderer } from './contextBridge/SophieRenderer'; -export { RendererIpcMessage } from './ipc/RendererIpcMessage'; +export { MainToRendererIpcMessage } from './ipc/MainToRendererIpcMessage'; +export { RendererToMainIpcMessage } from './ipc/RendererToMainIpcMessage'; + +export type { + SharedStore, + SharedStoreListener, + SharedStoreSnapshotIn, + SharedStoreSnapshotOut, +} from './stores/SharedStore'; +export { sharedStore } from './stores/SharedStore'; diff --git a/packages/shared/src/ipc/MainToRendererIpcMessage.ts b/packages/shared/src/ipc/MainToRendererIpcMessage.ts new file mode 100644 index 0000000..2ddca80 --- /dev/null +++ b/packages/shared/src/ipc/MainToRendererIpcMessage.ts @@ -0,0 +1,4 @@ +export enum MainToRendererIpcMessage { + SharedStoreSnapshot = "sophie-shared-store-snapshot", + SharedStorePatch = "sophie-shared-store-patch", +} diff --git a/packages/shared/src/ipc/RendererIpcMessage.ts b/packages/shared/src/ipc/RendererIpcMessage.ts deleted file mode 100644 index cfb87ae..0000000 --- a/packages/shared/src/ipc/RendererIpcMessage.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum RendererIpcMessage { - ButtonClicked = "button-clicked" -} diff --git a/packages/shared/src/ipc/RendererToMainIpcMessage.ts b/packages/shared/src/ipc/RendererToMainIpcMessage.ts new file mode 100644 index 0000000..c4c682d --- /dev/null +++ b/packages/shared/src/ipc/RendererToMainIpcMessage.ts @@ -0,0 +1,4 @@ +export enum RendererToMainIpcMessage { + SharedStoreSnapshotRequest = "sophie-shared-store-snapshot-request", + ButtonClick = "sophie-button-click" +} diff --git a/packages/shared/src/stores/SharedStore.ts b/packages/shared/src/stores/SharedStore.ts new file mode 100644 index 0000000..8933bca --- /dev/null +++ b/packages/shared/src/stores/SharedStore.ts @@ -0,0 +1,23 @@ +import { + IJsonPatch, + Instance, + types, + SnapshotIn, + SnapshotOut, +} from 'mobx-state-tree'; + +export const sharedStore = types.model("SharedStore", { + clickCount: 0 +}); + +export interface SharedStore extends Instance {} + +export interface SharedStoreSnapshotIn extends SnapshotIn {} + +export interface SharedStoreSnapshotOut extends SnapshotOut {} + +export interface SharedStoreListener { + onSnapshot(snapshot: SharedStoreSnapshotIn): void; + + onPatch(patch: IJsonPatch): void; +} diff --git a/packages/shared/vite.config.js b/packages/shared/vite.config.js index cbebb8a..05ec118 100644 --- a/packages/shared/vite.config.js +++ b/packages/shared/vite.config.js @@ -26,15 +26,15 @@ const config = { outDir: 'dist', lib: { entry: 'src/index.ts', - formats: ['cjs'], + formats: ['cjs', 'es'], + fileName: (format) => format === 'cjs' ? 'index.cjs' : `index.${format}.js`, }, rollupOptions: { external: [ + 'mobx', + 'mobx-state-tree', ...builtinModules, ], - output: { - entryFileNames: '[name].cjs', - }, }, emptyOutDir: false, // Do not remove `.d.ts` files. brotliSize: false, -- cgit v1.2.3-70-g09d2