| 1 | import { contextBridge, ipcRenderer, webUtils } from "electron"; |
| 2 | import { DesktopEventStream, MissedEventSubscriptions } from "../shared/eventStream.js"; |
| 3 | import { |
| 4 | hostOS, |
| 5 | IPC, |
| 6 | type BrowserControlState, |
| 7 | type BrowserDownloadView, |
| 8 | type BrowserLayoutRect, |
| 9 | type BrowserNavigateTarget, |
| 10 | type BrowserOpenOptions, |
| 11 | type BrowserTabView, |
| 12 | type ChromeImportOutcome, |
| 13 | type ContractInfo, |
| 14 | type IpcResult, |
| 15 | type ServiceState, |
| 16 | type WindowTheme, |
| 17 | } from "../shared/ipc.js"; |
| 18 | import type { GraphicsSettingsState } from "../main/graphics.js"; |
| 19 | |
| 20 | type Listener = (...args: unknown[]) => void; |
| 21 | |
| 22 | function isResult(value: unknown): value is IpcResult { |
| 23 | return typeof value === "object" && value !== null && typeof (value as { ok?: unknown }).ok === "boolean"; |
| 24 | } |
| 25 | |
| 26 | function unwrap(value: unknown): unknown { |
| 27 | if (!isResult(value)) throw new Error("malformed reply from the desktop shell"); |
| 28 | if (value.ok) return value.value; |
| 29 | throw new Error(value.message); |
| 30 | } |
| 31 | |
| 32 | async function call(channel: string, ...args: unknown[]): Promise<unknown> { |
| 33 | return unwrap(await ipcRenderer.invoke(channel, ...args)); |
| 34 | } |
| 35 | |
| 36 | function fire(channel: string, ...args: unknown[]): void { |
| 37 | void call(channel, ...args).catch((error: unknown) => console.warn(`[reasonixDesktop] ${channel} failed`, error)); |
| 38 | } |
| 39 | |
| 40 | function readContract(): ContractInfo { |
| 41 | const raw = ipcRenderer.sendSync(IPC.contract) as unknown; |
| 42 | if (typeof raw !== "object" || raw === null) return { protocolVersion: 1, digest: "", commands: Object.freeze([]) }; |
| 43 | const record = raw as { protocolVersion?: unknown; digest?: unknown; commands?: unknown }; |
| 44 | const commands = Array.isArray(record.commands) ? record.commands.filter((name): name is string => typeof name === "string") : []; |
| 45 | return Object.freeze({ |
| 46 | protocolVersion: typeof record.protocolVersion === "number" ? record.protocolVersion : 1, |
| 47 | digest: typeof record.digest === "string" ? record.digest : "", |
| 48 | commands: Object.freeze(commands), |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | const listeners = new Map<string, Set<Listener>>(); |
| 53 | const missedSubscriptions = new MissedEventSubscriptions(); |
| 54 | |
| 55 | function emit(name: string, args: unknown[]): void { |
| 56 | const set = listeners.get(name); |
| 57 | if (!set) { if (name !== "desktop:resync") missedSubscriptions.add(name); return; } |
| 58 | for (const listener of [...set]) { |
| 59 | try { |
| 60 | listener(...args); |
| 61 | } catch (error) { |
| 62 | console.error(`[reasonixDesktop] listener for ${name} failed`, error); |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | const eventStream = new DesktopEventStream((frame) => emit(frame.name, frame.args), (recovery) => { |
| 68 | if (recovery.reason === "generation") missedSubscriptions.clear(); |
| 69 | emit("desktop:resync", [recovery]); |
| 70 | }); |
| 71 | // Bind before React mounts, so unsubscribed event names still advance the |
| 72 | // transport cursor and cannot hide missing frames from later subscribers. |
| 73 | ipcRenderer.on(IPC.event, (_event, frame: unknown) => eventStream.accept(frame)); |
| 74 | |
| 75 | function on(name: string, listener: Listener): () => void { |
| 76 | let set = listeners.get(name); |
| 77 | if (!set) { |
| 78 | set = new Set(); |
| 79 | listeners.set(name, set); |
| 80 | } |
| 81 | set.add(listener); |
| 82 | if (name === "desktop:resync" && eventStream.recovery) { |
| 83 | queueMicrotask(() => { |
| 84 | if (set.has(listener) && eventStream.recovery) listener(eventStream.recovery); |
| 85 | }); |
| 86 | } else if (missedSubscriptions.consume(name)) { |
| 87 | eventStream.requestRecovery("subscription"); |
| 88 | } |
| 89 | return () => { |
| 90 | set.delete(listener); |
| 91 | if (set.size === 0) listeners.delete(name); |
| 92 | }; |
| 93 | } |
| 94 | |
| 95 | let lastServiceState: ServiceState | null = null; |
| 96 | const serviceStateListeners = new Set<(state: ServiceState) => void>(); |
| 97 | ipcRenderer.on(IPC.serviceState, (_event, state: ServiceState) => { |
| 98 | lastServiceState = state; |
| 99 | eventStream.observeState(state); |
| 100 | for (const listener of [...serviceStateListeners]) listener(state); |
| 101 | }); |
| 102 | |
| 103 | // The stream must know its generation even when the app has no service-state |
| 104 | // subscriber. A newer push wins over this initial asynchronous snapshot. |
| 105 | void call(IPC.serviceStateGet).then((state) => { |
| 106 | if (lastServiceState) return; |
| 107 | lastServiceState = state as ServiceState; |
| 108 | eventStream.observeState(lastServiceState); |
| 109 | for (const listener of [...serviceStateListeners]) listener(lastServiceState); |
| 110 | }).catch(() => undefined); |
| 111 | |
| 112 | // A renderer that mounts after the service became ready never saw the push; |
| 113 | // the first subscriber pulls the current state so nobody waits on a past event. |
| 114 | function onServiceState(listener: (state: ServiceState) => void): () => void { |
| 115 | serviceStateListeners.add(listener); |
| 116 | if (lastServiceState) listener(lastServiceState); |
| 117 | else { |
| 118 | void call(IPC.serviceStateGet).then((state) => { |
| 119 | if (lastServiceState || !serviceStateListeners.has(listener)) return; |
| 120 | lastServiceState = state as ServiceState; |
| 121 | eventStream.observeState(lastServiceState); |
| 122 | listener(lastServiceState); |
| 123 | }).catch(() => undefined); |
| 124 | } |
| 125 | return () => { |
| 126 | serviceStateListeners.delete(listener); |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | let lastTabs: BrowserTabView[] | null = null; |
| 131 | const tabListeners = new Set<(tabs: BrowserTabView[]) => void>(); |
| 132 | ipcRenderer.on(IPC.browserTabs, (_event, tabs: BrowserTabView[]) => { |
| 133 | lastTabs = Array.isArray(tabs) ? tabs : []; |
| 134 | for (const listener of [...tabListeners]) listener(lastTabs); |
| 135 | }); |
| 136 | |
| 137 | // Fires immediately with the current list so a panel that mounts late never |
| 138 | // waits for the next change. |
| 139 | function onTabs(listener: (tabs: BrowserTabView[]) => void): () => void { |
| 140 | tabListeners.add(listener); |
| 141 | if (lastTabs) listener(lastTabs); |
| 142 | else { |
| 143 | void call(IPC.browserList).then((tabs) => { |
| 144 | if (lastTabs || !tabListeners.has(listener)) return; |
| 145 | lastTabs = Array.isArray(tabs) ? (tabs as BrowserTabView[]) : []; |
| 146 | listener(lastTabs); |
| 147 | }).catch(() => undefined); |
| 148 | } |
| 149 | return () => { |
| 150 | tabListeners.delete(listener); |
| 151 | }; |
| 152 | } |
| 153 | |
| 154 | const downloadListeners = new Set<(download: BrowserDownloadView) => void>(); |
| 155 | ipcRenderer.on(IPC.browserDownload, (_event, download: BrowserDownloadView) => { |
| 156 | for (const listener of [...downloadListeners]) listener(download); |
| 157 | }); |
| 158 | |
| 159 | function onDownload(listener: (download: BrowserDownloadView) => void): () => void { |
| 160 | downloadListeners.add(listener); |
| 161 | return () => { |
| 162 | downloadListeners.delete(listener); |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | const browser = { |
| 167 | list: () => call(IPC.browserList) as Promise<BrowserTabView[]>, |
| 168 | open: (url: string, opts?: BrowserOpenOptions) => call(IPC.browserOpen, url, opts ?? {}) as Promise<BrowserTabView>, |
| 169 | close: (tabId: string) => call(IPC.browserClose, tabId).then(() => undefined), |
| 170 | activate: (tabId: string | null) => call(IPC.browserActivate, tabId).then(() => undefined), |
| 171 | navigate: (tabId: string, target: BrowserNavigateTarget) => call(IPC.browserNavigate, tabId, target).then(() => undefined), |
| 172 | setZoom: (tabId: string, factor: number) => call(IPC.browserSetZoom, tabId, factor).then(() => undefined), |
| 173 | toggleDevTools: (tabId: string) => call(IPC.browserToggleDevTools, tabId).then(() => undefined), |
| 174 | resume: (tabId: string) => call(IPC.browserResume, tabId).then(() => undefined), |
| 175 | takeover: (tabId: string) => call(IPC.browserUserTakeover, tabId).then(() => undefined), |
| 176 | setLayout: (rect: BrowserLayoutRect | null) => fire(IPC.browserSetLayout, rect), |
| 177 | setOverlay: (active: boolean) => fire(IPC.browserSetOverlay, active), |
| 178 | onTabs, |
| 179 | onDownload, |
| 180 | }; |
| 181 | |
| 182 | contextBridge.exposeInMainWorld("reasonixDesktop", { |
| 183 | kind: "electron", |
| 184 | contract: readContract(), |
| 185 | platform: { |
| 186 | os: hostOS(process.platform), |
| 187 | arch: process.arch, |
| 188 | versions: { electron: process.versions.electron ?? "", chrome: process.versions.chrome ?? "", node: process.versions.node ?? "" }, |
| 189 | }, |
| 190 | invoke: (method: string, args: unknown[]) => call(IPC.invoke, method, Array.isArray(args) ? args : []), |
| 191 | on, |
| 192 | native: { |
| 193 | processDiagnostics: () => call(IPC.processDiagnostics), |
| 194 | captureRendererProfile: (requestId?: string) => call(IPC.captureRendererProfile, requestId), |
| 195 | cancelRendererProfile: (requestId?: string) => call(IPC.cancelRendererProfile, requestId), |
| 196 | exportHeapSnapshot: () => call(IPC.exportHeapSnapshot), |
| 197 | recordRendererDiagnostic: (event: Record<string, string | number>) => call(IPC.rendererDiagnostic, event).then(() => undefined), |
| 198 | openExternal: (url: string) => call(IPC.openExternal, url).then(() => undefined), |
| 199 | clipboard: { |
| 200 | writeText: (text: string) => call(IPC.clipboardWrite, text).then((ok) => ok === true), |
| 201 | readText: () => call(IPC.clipboardRead).then((text) => (typeof text === "string" ? text : "")), |
| 202 | }, |
| 203 | window: { |
| 204 | setTheme: (theme: WindowTheme) => fire(IPC.windowSetTheme, theme), |
| 205 | setBackgroundColour: (r: number, g: number, b: number, a: number) => fire(IPC.windowSetBackground, r, g, b, a), |
| 206 | getBounds: () => call(IPC.windowGetBounds), |
| 207 | isMaximised: () => call(IPC.windowIsMaximised).then((value) => value === true), |
| 208 | minimise: () => call(IPC.windowMinimise).then(() => undefined), |
| 209 | toggleMaximise: () => call(IPC.windowToggleMaximise).then(() => undefined), |
| 210 | close: () => call(IPC.windowClose).then(() => undefined), |
| 211 | getAppZoom: () => call(IPC.appZoomGet), |
| 212 | setAppZoom: (factor: number) => call(IPC.appZoomSet, factor), |
| 213 | resetAppZoom: () => call(IPC.appZoomReset), |
| 214 | }, |
| 215 | graphics: { |
| 216 | get: () => call(IPC.graphicsGet) as Promise<GraphicsSettingsState>, |
| 217 | setHardwareAcceleration: (enabled: boolean) => call(IPC.graphicsSet, enabled) as Promise<GraphicsSettingsState>, |
| 218 | }, |
| 219 | browserControl: { |
| 220 | get: () => call(IPC.browserControlGet) as Promise<BrowserControlState | null>, |
| 221 | setEnabled: (enabled: boolean) => call(IPC.browserControlSetEnabled, enabled) as Promise<BrowserControlState>, |
| 222 | setIgnoreCertificateErrors: (enabled: boolean) => |
| 223 | call(IPC.browserControlSetIgnoreCertificateErrors, enabled) as Promise<BrowserControlState>, |
| 224 | clearCache: () => call(IPC.browserControlClearCache).then(() => undefined), |
| 225 | clearAllData: () => call(IPC.browserControlClearAll).then(() => undefined), |
| 226 | importChromeLogin: () => call(IPC.browserControlImportChrome) as Promise<ChromeImportOutcome>, |
| 227 | }, |
| 228 | getPathForFile: (file: File) => webUtils.getPathForFile(file), |
| 229 | onServiceState, |
| 230 | }, |
| 231 | browser, |
| 232 | }); |
| 233 |