| 1 | import { num, record, str, strList, type Params } from "./params.js"; |
| 2 | import { RpcError } from "./rpc.js"; |
| 3 | |
| 4 | export interface WindowHostApi { |
| 5 | show(reason: string): void; |
| 6 | hide(): void; |
| 7 | maximise(): void; |
| 8 | unmaximise(): void; |
| 9 | minimise(): void; |
| 10 | unminimise(): void; |
| 11 | toggleMaximise(): void; |
| 12 | center(): void; |
| 13 | isMaximised(): boolean; |
| 14 | isMinimised(): boolean; |
| 15 | setPosition(x: number, y: number): void; |
| 16 | setTitle(title: string): void; |
| 17 | toggleDevTools(): void; |
| 18 | } |
| 19 | |
| 20 | export interface DialogHostApi { |
| 21 | openDirectory(params: Params): Promise<{ path: string }>; |
| 22 | openFile(params: Params): Promise<{ paths: string[] }>; |
| 23 | saveFile(params: Params): Promise<{ path: string }>; |
| 24 | message(params: Params): Promise<{ button: string }>; |
| 25 | } |
| 26 | |
| 27 | export interface TrayLabels { |
| 28 | openTitle: string; |
| 29 | openTooltip: string; |
| 30 | quitTitle: string; |
| 31 | quitTooltip: string; |
| 32 | tooltip: string; |
| 33 | } |
| 34 | |
| 35 | export interface TrayHostApi { |
| 36 | ensure(labels: TrayLabels): { ready: boolean; reason: string }; |
| 37 | destroy(): void; |
| 38 | } |
| 39 | |
| 40 | export interface RemoteWindowHostApi { |
| 41 | open(input: { hostKey: string; url: string; title: string }): { windowId: string }; |
| 42 | navigate(input: { hostKey: string; url: string; title: string }): void; |
| 43 | focus(hostKey: string): void; |
| 44 | close(hostKey: string): void; |
| 45 | } |
| 46 | |
| 47 | export interface LifecycleHostApi { |
| 48 | approve(): void; |
| 49 | relaunch(args: string[], execPath?: string): void; |
| 50 | } |
| 51 | |
| 52 | export interface ScreenInfo { |
| 53 | x: number; |
| 54 | y: number; |
| 55 | width: number; |
| 56 | height: number; |
| 57 | scale: number; |
| 58 | primary: boolean; |
| 59 | } |
| 60 | |
| 61 | export type HostCall = (params: Params) => Promise<unknown> | unknown; |
| 62 | export type HostCallTable = Record<string, HostCall>; |
| 63 | |
| 64 | export interface HostCallDeps { |
| 65 | window: WindowHostApi; |
| 66 | dialogs: DialogHostApi; |
| 67 | tray: TrayHostApi; |
| 68 | remote: RemoteWindowHostApi; |
| 69 | lifecycle: LifecycleHostApi; |
| 70 | openExternal(url: string): Promise<void>; |
| 71 | hideApp(): void; |
| 72 | screens(): ScreenInfo[]; |
| 73 | browser?: HostCallTable; |
| 74 | } |
| 75 | |
| 76 | const remoteInput = (params: Params) => ({ hostKey: str(params, "hostKey"), url: str(params, "url"), title: str(params, "title") }); |
| 77 | const EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); |
| 78 | |
| 79 | function externalURL(value: string): string { |
| 80 | let url: URL; |
| 81 | try { |
| 82 | url = new URL(value); |
| 83 | } catch { |
| 84 | throw new Error("refusing to open an invalid external URL"); |
| 85 | } |
| 86 | if (!EXTERNAL_PROTOCOLS.has(url.protocol)) throw new Error(`refusing to open external URL with protocol ${url.protocol}`); |
| 87 | return url.href; |
| 88 | } |
| 89 | |
| 90 | export function buildHostCallTable(deps: HostCallDeps): HostCallTable { |
| 91 | const done = (run: () => void): HostCall => (params) => { |
| 92 | void params; |
| 93 | run(); |
| 94 | return {}; |
| 95 | }; |
| 96 | return { |
| 97 | "host/window.show": (params) => { |
| 98 | deps.window.show(str(params, "reason")); |
| 99 | return {}; |
| 100 | }, |
| 101 | "host/window.hide": done(() => deps.window.hide()), |
| 102 | "host/app.hide": done(() => deps.hideApp()), |
| 103 | "host/window.maximise": done(() => deps.window.maximise()), |
| 104 | "host/window.unmaximise": done(() => deps.window.unmaximise()), |
| 105 | "host/window.minimise": done(() => deps.window.minimise()), |
| 106 | "host/window.unminimise": done(() => deps.window.unminimise()), |
| 107 | "host/window.toggleMaximise": done(() => deps.window.toggleMaximise()), |
| 108 | "host/window.center": done(() => deps.window.center()), |
| 109 | "host/window.isMaximised": () => ({ value: deps.window.isMaximised() }), |
| 110 | "host/window.isMinimised": () => ({ value: deps.window.isMinimised() }), |
| 111 | "host/window.setPosition": (params) => { |
| 112 | deps.window.setPosition(num(params, "x"), num(params, "y")); |
| 113 | return {}; |
| 114 | }, |
| 115 | "host/window.setTitle": (params) => { |
| 116 | deps.window.setTitle(str(params, "title")); |
| 117 | return {}; |
| 118 | }, |
| 119 | "host/screen.list": () => ({ screens: deps.screens() }), |
| 120 | "host/dialog.openDirectory": (params) => deps.dialogs.openDirectory(params), |
| 121 | "host/dialog.openFile": (params) => deps.dialogs.openFile(params), |
| 122 | "host/dialog.saveFile": (params) => deps.dialogs.saveFile(params), |
| 123 | "host/dialog.message": (params) => deps.dialogs.message(params), |
| 124 | "host/shell.openExternal": async (params) => { |
| 125 | await deps.openExternal(externalURL(str(params, "url"))); |
| 126 | return {}; |
| 127 | }, |
| 128 | "host/app.quit": done(() => deps.lifecycle.approve()), |
| 129 | "host/app.relaunch": (params) => { |
| 130 | const execPath = str(params, "execPath"); |
| 131 | if (execPath) deps.lifecycle.relaunch(strList(params, "args"), execPath); |
| 132 | else deps.lifecycle.relaunch(strList(params, "args")); |
| 133 | return {}; |
| 134 | }, |
| 135 | "host/devtools.toggle": done(() => deps.window.toggleDevTools()), |
| 136 | "host/remoteWindow.open": (params) => deps.remote.open(remoteInput(params)), |
| 137 | "host/remoteWindow.navigate": (params) => { |
| 138 | deps.remote.navigate(remoteInput(params)); |
| 139 | return {}; |
| 140 | }, |
| 141 | "host/remoteWindow.focus": (params) => { |
| 142 | deps.remote.focus(str(params, "hostKey")); |
| 143 | return {}; |
| 144 | }, |
| 145 | "host/remoteWindow.close": (params) => { |
| 146 | deps.remote.close(str(params, "hostKey")); |
| 147 | return {}; |
| 148 | }, |
| 149 | "host/tray.ensure": (params) => deps.tray.ensure({ |
| 150 | openTitle: str(params, "openTitle", "Open"), |
| 151 | openTooltip: str(params, "openTooltip"), |
| 152 | quitTitle: str(params, "quitTitle", "Quit"), |
| 153 | quitTooltip: str(params, "quitTooltip"), |
| 154 | tooltip: str(params, "tooltip", "Reasonix"), |
| 155 | }), |
| 156 | "host/tray.destroy": done(() => deps.tray.destroy()), |
| 157 | ...(deps.browser ?? {}), |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | export async function dispatchHostCall(table: HostCallTable, method: string, params: unknown): Promise<unknown> { |
| 162 | const call = Object.prototype.hasOwnProperty.call(table, method) ? table[method] : undefined; |
| 163 | if (!call) throw new RpcError(-32601, `unknown host method: ${method}`); |
| 164 | return call(record(params)); |
| 165 | } |
| 166 |