| 1 | // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. |
| 2 | /** |
| 3 | * The desktop end of the desktop browser channel (see `DesktopBrowserEvent` in |
| 4 | * contracts). The primary backend gets two file descriptors at spawn: this |
| 5 | * service writes events for the desktop's tabs to one and reads commands from |
| 6 | * the other. Each attached tab is reachable only through its `CdpRelay`. |
| 7 | * |
| 8 | * A tab is attached once its `<webview>` registers with a key the web app |
| 9 | * gave it. The preview manager owns the tab's single debugger session and hands |
| 10 | * it here; the relay shares it. |
| 11 | */ |
| 12 | import { |
| 13 | DesktopBrowserCommand, |
| 14 | DesktopBrowserEvent, |
| 15 | type DesktopBrowserEvent as DesktopBrowserEventType, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import * as Context from "effect/Context"; |
| 18 | import * as NodePath from "node:path"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Layer from "effect/Layer"; |
| 21 | import * as Option from "effect/Option"; |
| 22 | import * as PubSub from "effect/PubSub"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | import * as Stream from "effect/Stream"; |
| 25 | |
| 26 | import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts"; |
| 27 | |
| 28 | const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); |
| 29 | const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); |
| 30 | const lineEncoder = new TextEncoder(); |
| 31 | /** CDP commands through which an agent clicks, types, or navigates the page. */ |
| 32 | const AGENT_INPUT_COMMAND = |
| 33 | /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; |
| 34 | /** How long after an agent's input a download still counts as the agent's. */ |
| 35 | const AGENT_DOWNLOAD_WINDOW_MS = 5_000; |
| 36 | |
| 37 | export interface DesktopBrowserTabKey { |
| 38 | readonly threadId: string; |
| 39 | readonly tabId: string; |
| 40 | } |
| 41 | |
| 42 | /** A tab's debugger, as the preview manager lends it to the relay. */ |
| 43 | export interface DesktopBrowserTabDebugger { |
| 44 | readonly webContents: Electron.WebContents; |
| 45 | readonly debugger: Electron.Debugger; |
| 46 | } |
| 47 | |
| 48 | const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`; |
| 49 | |
| 50 | interface AttachedTab { |
| 51 | readonly key: DesktopBrowserTabKey; |
| 52 | readonly debuggee: DesktopBrowserTabDebugger; |
| 53 | relay: CdpRelayConnection | null; |
| 54 | /** Where the server wants this tab's downloads; null keeps Electron's own handling. */ |
| 55 | downloadDirectory: string | null; |
| 56 | /** The guid CDP gave the download that is about to start. */ |
| 57 | pendingDownloadGuid: string | null; |
| 58 | /** When the server last sent this page input, which only an agent does. */ |
| 59 | agentInputAt: number; |
| 60 | readonly onMessage: ( |
| 61 | event: Electron.Event, |
| 62 | method: string, |
| 63 | params: unknown, |
| 64 | sessionId: string, |
| 65 | ) => void; |
| 66 | } |
| 67 | |
| 68 | export class DesktopBrowserHost extends Context.Service< |
| 69 | DesktopBrowserHost, |
| 70 | { |
| 71 | /** |
| 72 | * Newline-delimited events for a backend's browser fd. Each run starts by |
| 73 | * announcing the tabs already attached, so a restarted backend hears them. |
| 74 | */ |
| 75 | readonly events: Stream.Stream<Uint8Array>; |
| 76 | /** One line from the backend's browser control fd. */ |
| 77 | readonly handleCommandLine: (line: string) => Effect.Effect<void>; |
| 78 | /** Offers a server tab's `<webview>` to the server. */ |
| 79 | readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; |
| 80 | /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */ |
| 81 | readonly detach: (key: DesktopBrowserTabKey) => void; |
| 82 | /** |
| 83 | * Whether a server tab's download came from the person at this desktop: |
| 84 | * the server sent the page no input just before it started. |
| 85 | */ |
| 86 | readonly humanStartedDownload: (source: Electron.WebContents) => boolean; |
| 87 | /** Points a server tab's download at the server; false for any other download. */ |
| 88 | readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; |
| 89 | /** The agent's cursor positions for attached tabs, keyed by their server tab. */ |
| 90 | readonly pointers: Stream.Stream<{ |
| 91 | readonly key: DesktopBrowserTabKey; |
| 92 | readonly phase: "move" | "click"; |
| 93 | readonly x: number; |
| 94 | readonly y: number; |
| 95 | }>; |
| 96 | } |
| 97 | >()("@t3tools/desktop/preview/DesktopBrowserHost") {} |
| 98 | |
| 99 | export const make = Effect.gen(function* () { |
| 100 | const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>(); |
| 101 | const pointers = yield* PubSub.sliding<{ |
| 102 | readonly key: DesktopBrowserTabKey; |
| 103 | readonly phase: "move" | "click"; |
| 104 | readonly x: number; |
| 105 | readonly y: number; |
| 106 | }>(16); |
| 107 | const runFork = Effect.runForkWith(yield* Effect.context<never>()); |
| 108 | const tabs = new Map<string, AttachedTab>(); |
| 109 | const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); |
| 110 | |
| 111 | const relayFor = (tab: AttachedTab) => { |
| 112 | if (tab.relay) return tab.relay; |
| 113 | const { webContents, debugger: debuggee } = tab.debuggee; |
| 114 | const relay: CdpRelayConnection = createCdpRelayConnection( |
| 115 | { |
| 116 | send: (method, params, sessionId) => |
| 117 | sessionId === undefined |
| 118 | ? debuggee.sendCommand(method, params) |
| 119 | : debuggee.sendCommand(method, params, sessionId), |
| 120 | targetId: () => |
| 121 | debuggee |
| 122 | .sendCommand("Target.getTargetInfo") |
| 123 | .then((result: { targetInfo: { targetId: string } }) => result.targetInfo.targetId), |
| 124 | url: () => webContents.getURL(), |
| 125 | title: () => webContents.getTitle(), |
| 126 | userAgent: () => webContents.getUserAgent(), |
| 127 | setDownloadDirectory: (directory) => { |
| 128 | tab.downloadDirectory = directory; |
| 129 | }, |
| 130 | }, |
| 131 | // A released relay's late replies belong to a connection that is gone. |
| 132 | (message) => { |
| 133 | if (tab.relay === relay && tabs.get(keyOf(tab.key)) === tab) { |
| 134 | emit({ type: "cdp", ...tab.key, message }); |
| 135 | } |
| 136 | }, |
| 137 | ); |
| 138 | tab.relay = relay; |
| 139 | return relay; |
| 140 | }; |
| 141 | |
| 142 | /** |
| 143 | * Saves a download from a server tab where the server's Playwright expects |
| 144 | * it. Without a path Electron would open its Save dialog over the app for a |
| 145 | * file the agent asked for. CDP names the download just before this runs. |
| 146 | */ |
| 147 | const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { |
| 148 | const tab = [...tabs.values()].find( |
| 149 | (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, |
| 150 | ); |
| 151 | if (!tab?.downloadDirectory || !tab.pendingDownloadGuid) return false; |
| 152 | item.setSavePath(NodePath.join(tab.downloadDirectory, tab.pendingDownloadGuid)); |
| 153 | tab.pendingDownloadGuid = null; |
| 154 | return true; |
| 155 | }; |
| 156 | |
| 157 | const detach = (key: DesktopBrowserTabKey) => { |
| 158 | const id = keyOf(key); |
| 159 | const tab = tabs.get(id); |
| 160 | if (!tab) return; |
| 161 | tabs.delete(id); |
| 162 | tab.debuggee.debugger.off("message", tab.onMessage); |
| 163 | emit({ type: "detached", ...key }); |
| 164 | }; |
| 165 | |
| 166 | const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => { |
| 167 | const id = keyOf(key); |
| 168 | if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return; |
| 169 | detach(key); |
| 170 | const tab: AttachedTab = { |
| 171 | key, |
| 172 | debuggee, |
| 173 | relay: null, |
| 174 | downloadDirectory: null, |
| 175 | pendingDownloadGuid: null, |
| 176 | agentInputAt: Number.NEGATIVE_INFINITY, |
| 177 | onMessage: (_event, method, params, sessionId) => { |
| 178 | if (method === "Browser.downloadWillBegin") { |
| 179 | const guid = (params as { guid?: unknown } | undefined)?.guid; |
| 180 | tab.pendingDownloadGuid = typeof guid === "string" ? guid : null; |
| 181 | } |
| 182 | tab.relay?.event(method, params, sessionId); |
| 183 | }, |
| 184 | }; |
| 185 | tabs.set(id, tab); |
| 186 | debuggee.debugger.on("message", tab.onMessage); |
| 187 | emit({ type: "attached", ...key }); |
| 188 | }; |
| 189 | |
| 190 | const handleCommandLine = (line: string) => |
| 191 | Effect.sync(() => { |
| 192 | const command = decodeCommand(line); |
| 193 | if (Option.isNone(command)) return; |
| 194 | const tab = tabs.get(keyOf(command.value)); |
| 195 | if (!tab) return; |
| 196 | if (command.value.type === "pointer") { |
| 197 | const { threadId, tabId, phase, x, y } = command.value; |
| 198 | runFork(PubSub.publish(pointers, { key: { threadId, tabId }, phase, x, y })); |
| 199 | return; |
| 200 | } |
| 201 | if (command.value.type === "release") { |
| 202 | // A new server connection starts with a fresh relay and fresh sessions. |
| 203 | tab.relay = null; |
| 204 | return; |
| 205 | } |
| 206 | if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); |
| 207 | relayFor(tab).receive(command.value.message); |
| 208 | }); |
| 209 | |
| 210 | const humanStartedDownload = (source: Electron.WebContents) => { |
| 211 | const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); |
| 212 | return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; |
| 213 | }; |
| 214 | |
| 215 | // Read when a backend starts, not when the host is built. |
| 216 | const announceAll = Effect.suspend(() => |
| 217 | Effect.forEach( |
| 218 | [...tabs.values()], |
| 219 | (tab) => { |
| 220 | tab.relay = null; |
| 221 | return PubSub.publish(outbox, { type: "attached", ...tab.key }); |
| 222 | }, |
| 223 | { discard: true }, |
| 224 | ), |
| 225 | ); |
| 226 | |
| 227 | return DesktopBrowserHost.of({ |
| 228 | pointers: Stream.fromPubSub(pointers), |
| 229 | // Subscribes before announcing, so no attach falls between the two. |
| 230 | events: Stream.unwrap( |
| 231 | Effect.gen(function* () { |
| 232 | const subscription = yield* PubSub.subscribe(outbox); |
| 233 | yield* announceAll; |
| 234 | return Stream.fromSubscription(subscription); |
| 235 | }), |
| 236 | ).pipe(Stream.map((event) => lineEncoder.encode(`${encodeEvent(event)}\n`))), |
| 237 | handleCommandLine, |
| 238 | attach, |
| 239 | detach, |
| 240 | placeDownload, |
| 241 | humanStartedDownload, |
| 242 | }); |
| 243 | }); |
| 244 | |
| 245 | export const layer = Layer.effect(DesktopBrowserHost, make); |