diff --git a/apps/electron/layers/preload/src/affine-apis.ts b/apps/electron/layers/preload/src/affine-apis.ts index b371611a11..351bd8c3a8 100644 --- a/apps/electron/layers/preload/src/affine-apis.ts +++ b/apps/electron/layers/preload/src/affine-apis.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - // NOTE: we will generate preload types from this file import { ipcRenderer } from 'electron'; diff --git a/apps/electron/layers/preload/src/bootstrap.ts b/apps/electron/layers/preload/src/bootstrap.ts new file mode 100644 index 0000000000..ecc67890e4 --- /dev/null +++ b/apps/electron/layers/preload/src/bootstrap.ts @@ -0,0 +1,72 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +(async () => { + const affineApis = await import('./affine-apis'); + contextBridge.exposeInMainWorld('apis', affineApis.apis); + contextBridge.exposeInMainWorld('events', affineApis.events); + contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo); + + // Credit to microsoft/vscode + function validateIPC(channel: string) { + if (!channel || !channel.startsWith('affine:')) { + throw new Error(`Unsupported event IPC channel '${channel}'`); + } + + return true; + } + + const globals = { + ipcRenderer: { + send(channel: string, ...args: any[]) { + if (validateIPC(channel)) { + ipcRenderer.send(channel, ...args); + } + }, + + invoke(channel: string, ...args: any[]) { + if (validateIPC(channel)) { + return ipcRenderer.invoke(channel, ...args); + } + }, + + on( + channel: string, + listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void + ) { + if (validateIPC(channel)) { + ipcRenderer.on(channel, listener); + + return this; + } + }, + + once( + channel: string, + listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void + ) { + if (validateIPC(channel)) { + ipcRenderer.once(channel, listener); + + return this; + } + }, + + removeListener( + channel: string, + listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void + ) { + if (validateIPC(channel)) { + ipcRenderer.removeListener(channel, listener); + + return this; + } + }, + }, + }; + + try { + contextBridge.exposeInMainWorld('affine', globals); + } catch (error) { + console.error(error); + } +})(); diff --git a/apps/electron/layers/preload/src/index.ts b/apps/electron/layers/preload/src/index.ts index 2361d817a1..e59d6a0adf 100644 --- a/apps/electron/layers/preload/src/index.ts +++ b/apps/electron/layers/preload/src/index.ts @@ -1,18 +1 @@ -/** - * @module preload - */ - -import { contextBridge } from 'electron'; - -import * as affineApis from './affine-apis'; - -/** - * The "Main World" is the JavaScript context that your main renderer code runs in. - * By default, the page you load in your renderer executes code in this world. - * - * @see https://www.electronjs.org/docs/api/context-bridge - */ - -contextBridge.exposeInMainWorld('apis', affineApis.apis); -contextBridge.exposeInMainWorld('events', affineApis.events); -contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo); +import './bootstrap'; diff --git a/apps/web/src/bootstrap/index.ts b/apps/web/src/bootstrap/index.ts index ee69e28aee..3ac27b251c 100644 --- a/apps/web/src/bootstrap/index.ts +++ b/apps/web/src/bootstrap/index.ts @@ -9,3 +9,26 @@ if (config.enablePlugin && !environment.isServer) { if (!environment.isServer) { import('@affine/bookmark-block'); } + +if (!environment.isDesktop && !environment.isServer) { + // Polyfill Electron + const unimplemented = () => { + throw new Error('AFFiNE Plugin Web will be supported in the future'); + }; + const affine = { + ipcRenderer: { + invoke: unimplemented, + send: unimplemented, + on: unimplemented, + once: unimplemented, + removeListener: unimplemented, + }, + }; + + Object.freeze(affine); + + Object.defineProperty(window, 'affine', { + value: affine, + writable: false, + }); +} diff --git a/packages/hooks/src/use-affine-ipc-renderer.ts b/packages/hooks/src/use-affine-ipc-renderer.ts new file mode 100644 index 0000000000..5cf9457f23 --- /dev/null +++ b/packages/hooks/src/use-affine-ipc-renderer.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from 'react'; + +declare global { + interface IPCRenderer { + send(channel: string, ...args: any[]): void; + invoke(channel: string, ...args: any[]): Promise; + on( + channel: string, + listener: (event: unknown, ...args: any[]) => void + ): this; + once( + channel: string, + listener: (event: unknown, ...args: any[]) => void + ): this; + removeListener(channel: string, listener: (...args: any[]) => void): this; + } + + interface Window { + affine: { + ipcRenderer: IPCRenderer; + }; + } +} + +/** + * Unsafe + */ +export function useAffineAsyncCallback(channel: string) { + return useCallback( + (...args: any[]): Promise => { + return window.affine.ipcRenderer.invoke(channel, ...args); + }, + [channel] + ); +} + +/** + * Unsafe + */ +export function useAffineListener( + channel: string, + listener: (event: unknown, ...args: any[]) => void, + once?: boolean +): void { + const fnRef = useRef<((event: unknown, ...args: any[]) => void) | null>(null); + if (!fnRef.current) { + fnRef.current = listener; + } + useEffect(() => { + if (once) { + window.affine.ipcRenderer.once(channel, fnRef.current!); + } else { + window.affine.ipcRenderer.on(channel, fnRef.current!); + } + return () => { + window.affine.ipcRenderer.removeListener(channel, fnRef.current!); + }; + }, [channel, once]); +}