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 */
12import {
13 DesktopBrowserCommand,
14 DesktopBrowserEvent,
15 type DesktopBrowserEvent as DesktopBrowserEventType,
16} from "@t3tools/contracts";
17import * as Context from "effect/Context";
18import * as NodePath from "node:path";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as PubSub from "effect/PubSub";
23import * as Schema from "effect/Schema";
24import * as Stream from "effect/Stream";
25
26import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
27
28const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
29const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
30const lineEncoder = new TextEncoder();
31/** CDP commands through which an agent clicks, types, or navigates the page. */
32const 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. */
35const AGENT_DOWNLOAD_WINDOW_MS = 5_000;
36
37export 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. */
43export interface DesktopBrowserTabDebugger {
44 readonly webContents: Electron.WebContents;
45 readonly debugger: Electron.Debugger;
46}
47
48const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`;
49
50interface 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
68export 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
99export 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
245export const layer = Layer.effect(DesktopBrowserHost, make);