Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopBrowserHost.ts

668 lines26.4 KB

1// @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk.
2/** The manager owns the single debugger attachment; the relay shares it. */
3import {
4 DesktopBrowserCommand,
5 DesktopBrowserEvent,
6 type DesktopBrowserEvent as DesktopBrowserEventType,
7 type PreviewAutomationLoadError,
8} from "@t3tools/contracts";
9import * as Context from "effect/Context";
10import * as NodePath from "node:path";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as PubSub from "effect/PubSub";
16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";
18​
19import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
20import { sendPageCommand } from "./GuestInput.ts";
21​
22const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
23const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
24const lineEncoder = new TextEncoder();
25const AGENT_INPUT_COMMAND =
26 /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/;
27/** The same commands as above, by method, for the relay's sends. */
28const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/;
29const AGENT_INPUT_WINDOW_MS = 5_000;
30/** A viewer's pointer travelling over the stream activates nothing on the page. */
31const POINTER_TRAVEL = new Set<unknown>(["mouseMoved", "mouseWheel"]);
32const FOCUS_IDLE = "5 seconds";
33/** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */
34const FIRST_PAGE_TIMEOUT = "5 seconds";
35const ERR_ABORTED = -3;
36​
37export interface DesktopBrowserTabKey {
38 readonly threadId: string;
39 readonly tabId: string;
41​
42export interface DesktopBrowserTabDebugger {
43 readonly webContents: Electron.WebContents;
44 readonly debugger: Electron.Debugger;
45 /** Runs after the server clears the tab's cookies, so state kept beside them goes too. */
46 readonly cookiesCleared?: () => Promise<void>;
48​
49const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`;
50​
51interface AttachedTab {
52 readonly key: DesktopBrowserTabKey;
53 readonly debuggee: DesktopBrowserTabDebugger;
54 relay: CdpRelayConnection | null;
55 screenshotTurn: object | null;
56 /** Each keeps the guest painting while whoever queued it still waits, and focused while `held`. */
57 readonly waitingScreenshots: Array<{
58 readonly start: () => void;
59 readonly live: () => boolean;
60 held: boolean;
61 }>;
62 downloadDirectory: string | null;
63 pendingDownloadGuid: string | null;
64 agentInputAt: number;
65 /** When the agent last drove the page with no person's input through a viewer since. */
66 agentLedAt: number;
67 focusEmulated: boolean;
68 focusChange: Promise<void> | null;
69 focusUses: number;
70 /** Each pending command, by the extra session waiting on it; the main connection's have none. */
71 readonly focusPending: Map<object, string | undefined>;
72 /** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */
73 readonly capturing: Set<object>;
74 /** Whether the client keeps the guest painting, as published on `painting`. */
75 painting: boolean;
76 /**
77 * Driven by the agent since its thread's last turn ended or the guest last throttled. A
78 * turn's start can reach us after its first command, so this holds focus once it does.
79 */
80 agentUsed: boolean;
81 focusReleasing: boolean;
82 /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */
83 readonly screencasts: Map<string | undefined, { started: boolean; pending: number }>;
84 readonly onMessage: (
85 event: Electron.Event,
86 method: string,
87 params: unknown,
88 sessionId: string,
89 ) => void;
90 readonly onNewDocument: () => void;
91 /** Stops waiting for the first page; set until the tab is announced. */
92 stopWaiting: (() => void) | null;
94​
95export class DesktopBrowserHost extends Context.Service<
96 DesktopBrowserHost,
97 {
98 readonly events: Stream.Stream<Uint8Array>;
99 readonly handleCommandLine: (line: string) => Effect.Effect<void>;
100 readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
101 readonly detach: (key: DesktopBrowserTabKey) => void;
102 readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void;
103 readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void;
104 readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
105 /** Whether what the page does now follows the agent's input, not a viewer's. */
106 readonly agentLedPage: (source: Electron.WebContents) => boolean;
107 readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
108 readonly pointers: Stream.Stream<{
109 readonly key: DesktopBrowserTabKey;
110 readonly phase: "move" | "click";
111 readonly x: number;
112 readonly y: number;
113 }>;
114 /** Whether a guest needs its frames painted while the panel hides it. */
115 readonly painting: Stream.Stream<{
116 readonly key: DesktopBrowserTabKey;
117 readonly active: boolean;
118 }>;
119 }
120>()("@t3tools/desktop/preview/DesktopBrowserHost") {}
121​
122export const make = Effect.gen(function* () {
123 const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>();
124 const pointers = yield* PubSub.sliding<{
125 readonly key: DesktopBrowserTabKey;
126 readonly phase: "move" | "click";
127 readonly x: number;
128 readonly y: number;
129 }>(16);
130 const painting = yield* PubSub.unbounded<{
131 readonly key: DesktopBrowserTabKey;
132 readonly active: boolean;
133 }>();
134 const runFork = Effect.runForkWith(yield* Effect.context<never>());
135 const tabs = new Map<string, AttachedTab>();
136 /** Threads whose agent turn runs, by the server's account. */
137 const agentTurns = new Set<string>();
138 // Kept apart from attachment: the renderer can show a webview before its debugger attaches,
139 // and the server forgets `shown` on detach, for example while DevTools holds the debugger.
140 const shownTabs = new Map<string, boolean>();
141 const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event));
142​
143 const nextScreenshot = (tab: AttachedTab) => {
144 tab.screenshotTurn = null;
145 tab.waitingScreenshots.shift()?.start();
146 releaseFocusWhenIdle(tab);
147 syncPainting(tab);
148 };
149​
150 /**
151 * Chromium below 157 crashes the whole app when a guest's renderer exits with a
152 * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that
153 * far likelier, and one the server gave up on stays pending in Chromium, so each guest
154 * gets one at a time. One whose relay or session has since gone is dropped unsent.
155 */
156 const takeScreenshot = (
157 tab: AttachedTab,
158 relay: CdpRelayConnection,
159 ownerLive: () => boolean,
160 /** Sends only while `current` holds; holds focus only while `held`. */
161 capture: (current: () => boolean, held: boolean) => Promise<unknown>,
162 ) =>
163 new Promise<unknown>((resolve, reject) => {
164 const live = () => tab.relay === relay && ownerLive();
165 const waiting = {
166 start: () => {
167 if (!live()) {
168 reject(new Error("The preview connection that asked for this screenshot closed."));
169 nextScreenshot(tab);
170 return;
171 }
172 const turn = {};
173 tab.screenshotTurn = turn;
174 capture(() => tab.screenshotTurn === turn && live(), waiting.held)
175 .finally(() => {
176 if (tab.screenshotTurn === turn) nextScreenshot(tab);
177 })
178 .then(resolve, reject);
179 },
180 live,
181 held: true,
182 };
183 if (tab.screenshotTurn === null) waiting.start();
184 else {
185 // The one ahead may be abandoned mid-capture; it needs frames to finish and free its turn.
186 tab.waitingScreenshots.push(waiting);
187 setFocusEmulation(tab, true);
188 syncPainting(tab);
189 }
190 });
191​
192 /**
193 * Chromium counts a focus-emulated guest as captured, so a guest the panel hides would
194 * never throttle. Without it, a hidden guest paints no frames and focus() fires no focus
195 * events, so the guest is emulated focused while the server drives it or streams it.
196 */
197 const setFocusEmulation = (tab: AttachedTab, enabled: boolean) => {
198 if (tab.focusEmulated === enabled) return;
199 tab.focusEmulated = enabled;
200 // A guest that refuses it, or a debugger already gone, behaves as unfocused.
201 const change: Promise<void> = tab.debuggee.debugger
202 .sendCommand("Emulation.setFocusEmulationEnabled", { enabled })
203 .then(
204 () => undefined,
205 () => undefined,
206 )
207 .finally(() => {
208 if (tab.focusChange === change) tab.focusChange = null;
209 });
210 tab.focusChange = change;
211 };
212​
213 const waitingHolds = (tab: AttachedTab) =>
214 tab.waitingScreenshots.some((waiting) => waiting.held && waiting.live());
215​
216 const releaseFocusWhenIdle = (tab: AttachedTab) => {
217 if (tab.focusReleasing) return;
218 tab.focusReleasing = true;
219 runFork(
220 Effect.gen(function* () {
221 let uses: number;
222 do {
223 uses = tab.focusUses;
224 yield* Effect.sleep(FOCUS_IDLE);
225 } while (uses !== tab.focusUses);
226 tab.focusReleasing = false;
227 // The last pending command to settle starts another idle wait.
228 if (tab.focusPending.size > 0 || tab.screencasts.size > 0 || waitingHolds(tab)) {
229 return;
230 }
231 // The agent pauses to think between steps; its tab stays focused until the turn ends.
232 if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return;
233 tab.agentUsed = false;
234 if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false);
235 }),
236 );
237 };
238​
239 /**
240 * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame
241 * that never comes. The client paints the guest behind the app while one is pending; the
242 * capture then finishes on its own, and the guest goes back offscreen once none is.
243 */
244 const syncPainting = (tab: AttachedTab) => {
245 const active =
246 tabs.get(keyOf(tab.key)) === tab &&
247 (tab.screencasts.size > 0 ||
248 tab.capturing.size > 0 ||
249 tab.waitingScreenshots.some((waiting) => waiting.live()));
250 if (tab.painting === active) return;
251 tab.painting = active;
252 runFork(PubSub.publish(painting, { key: tab.key, active }));
253 };
254​
255 /** A command may wait on the page's next frame, so the guest stays focused until it settles. */
256 const holdFocus = <A>(tab: AttachedTab, owner: string | undefined, work: () => Promise<A>) => {
257 const pending = {};
258 tab.focusPending.set(pending, owner);
259 tab.focusUses += 1;
260 setFocusEmulation(tab, true);
261 releaseFocusWhenIdle(tab);
262 return work().finally(() => {
263 if (!tab.focusPending.delete(pending)) return;
264 tab.focusUses += 1;
265 releaseFocusWhenIdle(tab);
266 });
267 };
268​
269 /** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */
270 const paintUntilAnswered = <A>(tab: AttachedTab, send: () => Promise<A>) => {
271 const sent = {};
272 tab.capturing.add(sent);
273 syncPainting(tab);
274 return send().finally(() => {
275 tab.capturing.delete(sent);
276 syncPainting(tab);
277 });
278 };
279​
280 /**
281 * Chromium can leave a command unanswered, so the screenshot guard's release points
282 * also end every focus hold: a released relay has no one waiting, and a new document
283 * answers nothing pending from the old one. A released relay's screencast has no viewer.
284 */
285 const dropFocusHolds = (tab: AttachedTab) => {
286 tab.focusPending.clear();
287 releaseFocusWhenIdle(tab);
288 syncPainting(tab);
289 };
290​
291 const dropRelay = (tab: AttachedTab) => {
292 tab.relay = null;
293 tab.screencasts.clear();
294 tab.agentUsed = false;
295 dropFocusHolds(tab);
296 };
297​
298 /** An owner that leaves without stopping would otherwise keep the screencast running unseen. */
299 const leaveScreencast = (tab: AttachedTab, owner: string | undefined) => {
300 if (!tab.screencasts.delete(owner) || tab.screencasts.size > 0) return;
301 void tab.debuggee.debugger.sendCommand("Page.stopScreencast").catch(() => {});
302 };
303​
304 /** A detached session, such as a closed viewer, waits on nothing it sent. */
305 const dropSessionHolds = (tab: AttachedTab, owner: string) => {
306 leaveScreencast(tab, owner);
307 for (const [pending, holder] of tab.focusPending) {
308 if (holder === owner) tab.focusPending.delete(pending);
309 }
310 releaseFocusWhenIdle(tab);
311 syncPainting(tab);
312 };
313​
314 /**
315 * A command the server stopped waiting on, such as a timed-out screenshot or evaluation,
316 * may never settle; the turn's end ends every focus hold. The agent also sends on its own extra
317 * session, so this cannot spare other sessions; a viewer's screencast still holds.
318 */
319 const endAgentTurn = (threadId: string) => {
320 agentTurns.delete(threadId);
321 for (const tab of tabs.values()) {
322 if (tab.key.threadId !== threadId) continue;
323 tab.agentUsed = false;
324 // Still sent in turn, so the queue keeps moving, but no longer kept focused.
325 for (const waiting of tab.waitingScreenshots) waiting.held = false;
326 dropFocusHolds(tab);
327 }
328 };
329​
330 const relayFor = (tab: AttachedTab) => {
331 if (tab.relay) return tab.relay;
332 const { webContents, debugger: debuggee, cookiesCleared } = tab.debuggee;
333 const send = async (method: string, params: Record<string, unknown>, sessionId?: string) => {
334 const result = await sendPageCommand(webContents, debuggee, method, params, sessionId);
335 if (method === "Network.clearBrowserCookies") await cookiesCleared?.();
336 return result;
337 };
338 const detachedOwners = new Set<string>();
339 /** Viewers and recordings stream; the agent's own sessions never do. */
340 const streamingOwners = new Set<string>();
341 const relay: CdpRelayConnection = createCdpRelayConnection(
342 {
343 send: (method, params, sessionId, owner) => {
344 // A child session's screencast is not the page's, such as a late one on a detached session.
345 const stream =
346 sessionId === undefined && method === "Page.startScreencast"
347 ? (tab.screencasts.get(owner) ?? { started: false, pending: 0 })
348 : undefined;
349 if (stream) {
350 stream.pending += 1;
351 tab.screencasts.set(owner, stream);
352 if (owner !== undefined) streamingOwners.add(owner);
353 syncPainting(tab);
354 } else if (sessionId === undefined && method === "Page.stopScreencast") {
355 tab.screencasts.delete(owner);
356 syncPainting(tab);
357 if (tab.screencasts.size > 0) return Promise.resolve({});
358 }
359 // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's.
360 if (AGENT_INPUT_METHOD.test(method)) {
361 if (owner === undefined || !streamingOwners.has(owner)) {
362 tab.agentUsed = true;
363 tab.agentLedAt = performance.now();
364 } else if (method.startsWith("Input.") && !POINTER_TRAVEL.has(params["type"])) {
365 tab.agentLedAt = Number.NEGATIVE_INFINITY;
366 }
367 }
368 // Waits for focus emulation; insertText and other non-CDP input would not queue behind it.
369 const forward = (current?: () => boolean) => {
370 const change = tab.focusChange;
371 const run = () =>
372 tab.relay !== relay || (current !== undefined && !current())
373 ? Promise.reject(new Error("The page this screenshot was for has moved on."))
374 : current === undefined
375 ? send(method, params, sessionId)
376 : paintUntilAnswered(tab, () => send(method, params, sessionId));
377 return change === null ? run() : change.then(run);
378 };
379 return method === "Page.captureScreenshot"
380 ? takeScreenshot(
381 tab,
382 relay,
383 () => owner === undefined || !detachedOwners.has(owner),
384 (current, held) =>
385 held ? holdFocus(tab, owner, () => forward(current)) : forward(current),
386 )
387 : method === "Page.startScreencast"
388 ? holdFocus(tab, owner, forward).then(
389 (result) => {
390 if (stream) {
391 stream.pending -= 1;
392 stream.started = true;
393 // A detach's stop can overtake a start still waiting on focus; nobody owns it now.
394 if (tab.screencasts.get(owner) !== stream && tab.screencasts.size === 0) {
395 void debuggee.sendCommand("Page.stopScreencast").catch(() => {});
396 }
397 }
398 return result;
399 },
400 (cause: unknown) => {
401 if (stream) stream.pending -= 1;
402 // A viewer can outlive a refused start; its running or pending stream still holds it.
403 if (
404 stream &&
405 !stream.started &&
406 stream.pending === 0 &&
407 tab.relay === relay &&
408 tab.screencasts.get(owner) === stream
409 ) {
410 leaveScreencast(tab, owner);
411 }
412 releaseFocusWhenIdle(tab);
413 syncPainting(tab);
414 throw cause;
415 },
416 )
417 : holdFocus(tab, owner, forward);
418 },
419 sessionDetached: (owner) => {
420 detachedOwners.add(owner);
421 dropSessionHolds(tab, owner);
422 },
423 receivesScreencast: (owner) => tab.screencasts.has(owner),
424 targetId: () =>
425 debuggee
426 .sendCommand("Target.getTargetInfo")
427 .then((result: { targetInfo: { targetId: string } }) => result.targetInfo.targetId),
428 url: () => webContents.getURL(),
429 title: () => webContents.getTitle(),
430 userAgent: () => webContents.getUserAgent(),
431 setDownloadDirectory: (directory) => {
432 tab.downloadDirectory = directory;
433 },
434 },
435 (message) => {
436 if (tab.relay === relay && tabs.get(keyOf(tab.key)) === tab) {
437 emit({ type: "cdp", ...tab.key, message });
438 }
439 },
440 // Announcing the tab again would reconnect Playwright, and the page could
441 // replay the same oversized event, so the tab stays detached.
442 () => {
443 if (tab.relay !== relay || tabs.get(keyOf(tab.key)) !== tab) return;
444 // Disabling auto-attach detaches child sessions and releases their debugger pauses.
445 void debuggee
446 .sendCommand("Target.setAutoAttach", {
447 autoAttach: false,
448 waitForDebuggerOnStart: false,
449 flatten: true,
450 })
451 .catch(() => {});
452 if (tab.screencasts.size > 0) {
453 void debuggee.sendCommand("Page.stopScreencast").catch(() => {});
454 }
455 setFocusEmulation(tab, false);
456 detach(tab.key);
457 },
458 );
459 tab.relay = relay;
460 return relay;
461 };
462​
463 // CDP supplies the path before this runs; otherwise Electron opens a Save dialog.
464 const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => {
465 const tab = [...tabs.values()].find(
466 (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory,
467 );
468 if (!tab?.downloadDirectory || !tab.pendingDownloadGuid) return false;
469 item.setSavePath(NodePath.join(tab.downloadDirectory, tab.pendingDownloadGuid));
470 tab.pendingDownloadGuid = null;
471 return true;
472 };
473​
474 const detach = (key: DesktopBrowserTabKey) => {
475 const id = keyOf(key);
476 const tab = tabs.get(id);
477 if (!tab) return;
478 tabs.delete(id);
479 const announced = tab.stopWaiting === null;
480 tab.stopWaiting?.();
481 tab.relay = null;
482 tab.debuggee.debugger.off("message", tab.onMessage);
483 tab.debuggee.webContents.off("did-navigate", tab.onNewDocument);
484 syncPainting(tab);
485 if (announced) emit({ type: "detached", ...key });
486 };
487​
488 const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => {
489 const id = keyOf(key);
490 if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return;
491 detach(key);
492 const tab: AttachedTab = {
493 key,
494 debuggee,
495 relay: null,
496 screenshotTurn: null,
497 waitingScreenshots: [],
498 downloadDirectory: null,
499 pendingDownloadGuid: null,
500 agentInputAt: Number.NEGATIVE_INFINITY,
501 agentLedAt: Number.NEGATIVE_INFINITY,
502 focusEmulated: false,
503 focusChange: null,
504 focusUses: 0,
505 focusPending: new Map(),
506 capturing: new Set(),
507 painting: false,
508 agentUsed: false,
509 focusReleasing: false,
510 screencasts: new Map(),
511 onMessage: (_event, method, params, sessionId) => {
512 if (method === "Page.downloadWillBegin") {
513 const guid = (params as { guid?: unknown } | undefined)?.guid;
514 tab.pendingDownloadGuid = typeof guid === "string" ? guid : null;
515 }
516 tab.relay?.event(method, params, sessionId);
517 },
518 // A screenshot pending when the page loads a new document, a reload included, can
519 // stay unanswered. A crashed page lands here once it reloads, well after its renderer died.
520 onNewDocument: () => {
521 // Before the next screenshot starts, so its own holds survive.
522 tab.capturing.clear();
523 dropFocusHolds(tab);
524 if (tab.screenshotTurn !== null) nextScreenshot(tab);
525 },
526 stopWaiting: null,
527 };
528 tabs.set(id, tab);
529 debuggee.debugger.on("message", tab.onMessage);
530 debuggee.webContents.on("did-navigate", tab.onNewDocument);
531 const announce = () => {
532 emit({ type: "attached", ...key });
533 const shown = shownTabs.get(id);
534 if (shown !== undefined) emit({ type: "shown", ...key, shown });
535 };
536 if (!debuggee.webContents.isLoadingMainFrame()) {
537 announce();
538 return;
539 }
540 // Electron does not pause a guest for the debugger, so Playwright connecting during its first
541 // commit builds its utility world in the discarded document and every wait on it hangs.
542 const { webContents } = debuggee;
543 // ERR_ABORTED is a newer navigation replacing this one, whose commit is still to come.
544 const onFailLoad = (
545 _event: Electron.Event,
546 code: number,
547 _description: string,
548 _url: string,
549 isMainFrame: boolean,
550 ) => {
551 if (isMainFrame && code !== ERR_ABORTED) ready();
552 };
553 const ready = () => {
554 if (tab.stopWaiting === null) return;
555 // The manager's cleanup for a destroyed guest can still be queued behind another tab's attach.
556 if (webContents.isDestroyed()) return detach(key);
557 tab.stopWaiting();
558 announce();
559 };
560 const timeout = runFork(
561 Effect.sleep(FIRST_PAGE_TIMEOUT).pipe(Effect.andThen(Effect.sync(ready))),
562 );
563 tab.stopWaiting = () => {
564 tab.stopWaiting = null;
565 webContents.off("dom-ready", ready);
566 webContents.off("did-stop-loading", ready);
567 webContents.off("did-fail-load", onFailLoad);
568 runFork(Fiber.interrupt(timeout));
569 };
570 webContents.on("dom-ready", ready);
571 webContents.on("did-stop-loading", ready);
572 webContents.on("did-fail-load", onFailLoad);
573 };
574​
575 const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => {
576 const id = keyOf(key);
577 if (shown === undefined) {
578 shownTabs.delete(id);
579 return;
580 }
581 if (shownTabs.get(id) === shown) return;
582 shownTabs.set(id, shown);
583 if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown });
584 };
585​
586 const loadFailed: DesktopBrowserHost["Service"]["loadFailed"] = (key, failure) => {
587 if (tabs.has(keyOf(key))) emit({ type: "loadFailed", ...key, ...failure });
588 };
589​
590 const handleCommandLine = (line: string) =>
591 Effect.sync(() => {
592 const command = decodeCommand(line);
593 if (Option.isNone(command)) return;
594 if (command.value.type === "turn") {
595 if (command.value.active) agentTurns.add(command.value.threadId);
596 else endAgentTurn(command.value.threadId);
597 return;
598 }
599 const tab = tabs.get(keyOf(command.value));
600 if (!tab) return;
601 if (command.value.type === "pointer") {
602 const { threadId, tabId, phase, x, y } = command.value;
603 runFork(PubSub.publish(pointers, { key: { threadId, tabId }, phase, x, y }));
604 return;
605 }
606 if (command.value.type === "release") {
607 dropRelay(tab);
608 return;
609 }
610 if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now();
611 relayFor(tab).receive(command.value.message);
612 });
613​
614 const humanStartedDownload = (source: Electron.WebContents) => {
615 const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source);
616 return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_INPUT_WINDOW_MS;
617 };
618​
619 const agentLedPage = (source: Electron.WebContents) => {
620 const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source);
621 return tab !== undefined && performance.now() - tab.agentLedAt <= AGENT_INPUT_WINDOW_MS;
622 };
623​
624 const announceAll = Effect.suspend(() => {
625 // A new backend: the old one's turns ended without telling us.
626 agentTurns.clear();
627 return Effect.forEach(
628 [...tabs.values()],
629 (tab) => {
630 dropRelay(tab);
631 // Announced once its first page is up.
632 if (tab.stopWaiting !== null) return Effect.void;
633 const shown = shownTabs.get(keyOf(tab.key));
634 return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe(
635 Effect.andThen(
636 shown !== undefined
637 ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown })
638 : Effect.void,
639 ),
640 );
641 },
642 { discard: true },
643 );
644 });
645​
646 return DesktopBrowserHost.of({
647 pointers: Stream.fromPubSub(pointers),
648 painting: Stream.fromPubSub(painting),
649 // Subscribes before announcing, so no attach falls between the two.
650 events: Stream.unwrap(
651 Effect.gen(function* () {
652 const subscription = yield* PubSub.subscribe(outbox);
653 yield* announceAll;
654 return Stream.fromSubscription(subscription);
655 }),
656 ).pipe(Stream.map((event) => lineEncoder.encode(`${encodeEvent(event)}\n`))),
657 handleCommandLine,
658 attach,
659 detach,
660 loadFailed,
661 setShown,
662 placeDownload,
663 humanStartedDownload,
664 agentLedPage,
665 });
666});
667​
668export const layer = Layer.effect(DesktopBrowserHost, make);