Skip to content

Commit d9a5c30

Browse files
feat(desktop): carry desktop browser tabs to the server over bootstrap fds
The desktop's own server gets two more bootstrap descriptors, like the telemetry pair. Tabs of that server register with the preview manager, which offers each to the server once it holds the tab's debugger and withdraws it on close, swap, crash, or devtools. CDP for an offered tab crosses the pipe to its relay; no port is opened on either side for another process to reach. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e3d7b12 commit d9a5c30

19 files changed

Lines changed: 485 additions & 1 deletion

‎apps/desktop/src/backend/DesktopBackendConfiguration.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
560560
tailscaleServePort: backendExposure.tailscaleServePort,
561561
desktopTelemetryFd: 4,
562562
desktopTelemetryControlFd: 5,
563+
desktopBrowserFd: 6,
564+
desktopBrowserControlFd: 7,
563565
...Option.match(input.resourceMonitorPath, {
564566
onNone: () => ({}),
565567
onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),

‎apps/desktop/src/backend/DesktopBackendManager.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import * as DesktopApp from "../app/DesktopApp.ts";
2727
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
2828
import * as DesktopObservability from "../app/DesktopObservability.ts";
2929
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
30+
import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
3031
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
3132

3233
const decodeDesktopBackendBootstrap = Schema.decodeEffect(
@@ -173,6 +174,7 @@ function makeTestInstance(input: MakeInstanceInput) {
173174
updateCancellations: Stream.empty,
174175
...input.desktopTelemetryPublisher,
175176
}),
177+
DesktopBrowserHost.layer,
176178
DesktopWslEnvironment.layerTest(
177179
input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
178180
),

‎apps/desktop/src/backend/DesktopBackendManager.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
5252

5353
import * as DesktopObservability from "../app/DesktopObservability.ts";
5454
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
55+
import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
5556
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
5657

5758
const INITIAL_RESTART_DELAY = Duration.millis(500);
@@ -222,6 +223,10 @@ export type BackendProcessError = typeof BackendProcessError.Type;
222223

223224
interface RunBackendProcessOptions extends DesktopBackendStartConfig {
224225
readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
226+
/** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */
227+
readonly desktopBrowserStream?: Stream.Stream<Uint8Array>;
228+
/** Each line the backend writes to `desktopBrowserControlFd`. */
229+
readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect<void>;
225230
readonly onDesktopTelemetryControl?: (
226231
message: DesktopTelemetryControlMessageValue,
227232
) => Effect.Effect<void>;
@@ -472,6 +477,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
472477
type: "output",
473478
};
474479
}
480+
if (
481+
options.bootstrap.desktopBrowserFd !== undefined &&
482+
options.desktopBrowserStream !== undefined
483+
) {
484+
additionalFds[`fd${options.bootstrap.desktopBrowserFd}`] = {
485+
type: "input",
486+
stream: options.desktopBrowserStream,
487+
};
488+
}
489+
if (options.bootstrap.desktopBrowserControlFd !== undefined) {
490+
additionalFds[`fd${options.bootstrap.desktopBrowserControlFd}`] = { type: "output" };
491+
}
475492
}
476493
const command = ChildProcess.make(options.executablePath, options.args, {
477494
cwd: options.cwd,
@@ -542,6 +559,26 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
542559
Effect.forkScoped,
543560
);
544561
}
562+
if (
563+
options.bootstrap.desktopBrowserControlFd !== undefined &&
564+
options.onDesktopBrowserCommand !== undefined
565+
) {
566+
const browserFd = options.bootstrap.desktopBrowserControlFd;
567+
const handleCommand = options.onDesktopBrowserCommand;
568+
yield* handle.getOutputFd(browserFd).pipe(
569+
Stream.decodeText(),
570+
Stream.splitLines,
571+
Stream.filter((line) => line.length > 0),
572+
Stream.runForEach(handleCommand),
573+
Effect.catchCause((cause) =>
574+
logBackendProcessWarning("desktop browser command stream stopped", {
575+
fd: browserFd,
576+
cause: Cause.pretty(cause),
577+
}),
578+
),
579+
Effect.forkScoped,
580+
);
581+
}
545582
if (options.captureOutput) {
546583
const outputContext = {
547584
executablePath: options.executablePath,
@@ -642,6 +679,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
642679
| HttpClient.HttpClient
643680
| DesktopObservability.DesktopBackendOutputLogFactory
644681
| DesktopTelemetryPublisher.DesktopTelemetryPublisher
682+
| DesktopBrowserHost.DesktopBrowserHost
645683
| DesktopWslEnvironment.DesktopWslEnvironment
646684
| Scope.Scope
647685
> {
@@ -650,6 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
650688
const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
651689
const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
652690
const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
691+
const desktopBrowserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
653692
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
654693
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
655694
const httpClient = yield* HttpClient.HttpClient;
@@ -907,6 +946,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
907946
const program = runBackendProcess({
908947
...config.value,
909948
desktopTelemetryStream: desktopTelemetryPublisher.encoded,
949+
// Only a bootstrap that names the browser fds (the local primary) gets them.
950+
desktopBrowserStream: Stream.concat(
951+
Stream.fromEffectDrain(desktopBrowserHost.announceAll),
952+
desktopBrowserHost.events,
953+
),
954+
onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine,
910955
onDesktopTelemetryControl: (message) =>
911956
desktopTelemetryPublisher.handleControlForSource(spec.id, message),
912957
onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {

‎apps/desktop/src/backend/DesktopBackendPool.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
1212
import * as DesktopObservability from "../app/DesktopObservability.ts";
1313
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1414
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
15+
import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
1516
import * as ElectronDialog from "../electron/ElectronDialog.ts";
1617
import * as DesktopWindow from "../window/DesktopWindow.ts";
1718
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
@@ -77,6 +78,7 @@ function makePoolLayer(
7778
updateCommits: Stream.empty,
7879
updateCancellations: Stream.empty,
7980
}),
81+
DesktopBrowserHost.layer,
8082
Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
8183
resolvePrimary: Effect.die("unexpected primary config resolve"),
8284
resolvePrimaryLabel: Ref.get(labelRef),

‎apps/desktop/src/backend/DesktopBackendPool.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts";
9898
import * as DesktopObservability from "../app/DesktopObservability.ts";
9999
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100100
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101+
import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
101102
import * as DesktopWindow from "../window/DesktopWindow.ts";
102103
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
103104
import * as ElectronDialog from "../electron/ElectronDialog.ts";
@@ -180,6 +181,7 @@ export type BackendInstanceFactoryRequirements =
180181
| HttpClient.HttpClient
181182
| DesktopObservability.DesktopBackendOutputLogFactory
182183
| DesktopTelemetryPublisher.DesktopTelemetryPublisher
184+
| DesktopBrowserHost.DesktopBrowserHost
183185
| DesktopWslEnvironment.DesktopWslEnvironment;
184186

185187
interface ActiveRegisteredInstance {

‎apps/desktop/src/ipc/methods/preview.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ export const createTab = DesktopIpc.makeIpcMethod({
6868
tabId,
6969
zoomFactor,
7070
colorScheme,
71+
serverTab,
7172
}) {
7273
const manager = yield* PreviewManager.PreviewManager;
73-
yield* manager.createTab(tabId, { zoomFactor, colorScheme });
74+
yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
7475
}),
7576
});
7677

‎apps/desktop/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
6565
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
6666
import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
6767
import * as BrowserSession from "./preview/BrowserSession.ts";
68+
import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
6869
import * as PreviewManager from "./preview/Manager.ts";
6970
import * as DesktopWindow from "./window/DesktopWindow.ts";
7071
import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
@@ -157,6 +158,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
157158
);
158159

159160
const desktopPreviewLayer = PreviewManager.layer.pipe(
161+
Layer.provideMerge(DesktopBrowserHost.layer),
160162
// Merged rather than provided so the IPC handlers can reach the import
161163
// service alongside the manager; both sit on the same BrowserSession.
162164
Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),

‎apps/desktop/src/preload.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
290290
ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
291291
tabId,
292292
zoomFactor: defaults?.zoomFactor,
293+
serverTab: defaults?.serverTab,
293294
colorScheme: defaults?.colorScheme,
294295
}),
295296
closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* The desktop end of the desktop browser channel (see `DesktopBrowserEvent` in
3+
* contracts). The primary backend gets two file descriptors at spawn: this
4+
* service writes events for the desktop's tabs to one and reads commands from
5+
* the other. Each attached tab is reachable only through its `CdpRelay`.
6+
*
7+
* A tab is attached once its `<webview>` registers with a key the web app
8+
* gave it. The preview manager owns the tab's single debugger session and hands
9+
* it here; the relay shares it.
10+
*/
11+
import {
12+
DesktopBrowserCommand,
13+
DesktopBrowserEvent,
14+
type DesktopBrowserEvent as DesktopBrowserEventType,
15+
} from "@t3tools/contracts";
16+
import * as Context from "effect/Context";
17+
import * as Effect from "effect/Effect";
18+
import * as Layer from "effect/Layer";
19+
import * as Option from "effect/Option";
20+
import * as PubSub from "effect/PubSub";
21+
import * as Schema from "effect/Schema";
22+
import * as Stream from "effect/Stream";
23+
24+
import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
25+
26+
const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
27+
const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
28+
const lineEncoder = new TextEncoder();
29+
30+
export interface DesktopBrowserTabKey {
31+
readonly threadId: string;
32+
readonly tabId: string;
33+
}
34+
35+
/** A tab's debugger, as the preview manager lends it to the relay. */
36+
export interface DesktopBrowserTabDebugger {
37+
readonly webContents: Electron.WebContents;
38+
readonly debugger: Electron.Debugger;
39+
}
40+
41+
const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`;
42+
43+
interface AttachedTab {
44+
readonly key: DesktopBrowserTabKey;
45+
readonly debuggee: DesktopBrowserTabDebugger;
46+
relay: CdpRelayConnection | null;
47+
readonly onMessage: (
48+
event: Electron.Event,
49+
method: string,
50+
params: unknown,
51+
sessionId: string,
52+
) => void;
53+
}
54+
55+
export class DesktopBrowserHost extends Context.Service<
56+
DesktopBrowserHost,
57+
{
58+
/** Newline-delimited events for the backend's browser fd. */
59+
readonly events: Stream.Stream<Uint8Array>;
60+
/** One line from the backend's browser control fd. */
61+
readonly handleCommandLine: (line: string) => Effect.Effect<void>;
62+
/** Offers a server tab's `<webview>` to the server. */
63+
readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
64+
/** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */
65+
readonly detach: (key: DesktopBrowserTabKey) => void;
66+
/** Re-announces attached tabs to a backend that just started. */
67+
readonly announceAll: Effect.Effect<void>;
68+
}
69+
>()("@t3tools/desktop/preview/DesktopBrowserHost") {}
70+
71+
export const make = Effect.gen(function* () {
72+
const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>();
73+
const runFork = Effect.runForkWith(yield* Effect.context<never>());
74+
const tabs = new Map<string, AttachedTab>();
75+
const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event));
76+
77+
const relayFor = (tab: AttachedTab) => {
78+
if (tab.relay) return tab.relay;
79+
const { webContents, debugger: debuggee } = tab.debuggee;
80+
tab.relay = createCdpRelayConnection(
81+
{
82+
send: (method, params, sessionId) =>
83+
sessionId === undefined
84+
? debuggee.sendCommand(method, params)
85+
: debuggee.sendCommand(method, params, sessionId),
86+
targetId: () =>
87+
debuggee
88+
.sendCommand("Target.getTargetInfo")
89+
.then((result: { targetInfo: { targetId: string } }) => result.targetInfo.targetId),
90+
url: () => webContents.getURL(),
91+
title: () => webContents.getTitle(),
92+
userAgent: () => webContents.getUserAgent(),
93+
},
94+
(message) => emit({ type: "cdp", ...tab.key, message }),
95+
);
96+
return tab.relay;
97+
};
98+
99+
const detach = (key: DesktopBrowserTabKey) => {
100+
const id = keyOf(key);
101+
const tab = tabs.get(id);
102+
if (!tab) return;
103+
tabs.delete(id);
104+
tab.debuggee.debugger.off("message", tab.onMessage);
105+
emit({ type: "detached", ...key });
106+
};
107+
108+
const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => {
109+
const id = keyOf(key);
110+
if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return;
111+
detach(key);
112+
const tab: AttachedTab = {
113+
key,
114+
debuggee,
115+
relay: null,
116+
onMessage: (_event, method, params, sessionId) => tab.relay?.event(method, params, sessionId),
117+
};
118+
tabs.set(id, tab);
119+
debuggee.debugger.on("message", tab.onMessage);
120+
emit({ type: "attached", ...key });
121+
};
122+
123+
const handleCommandLine = (line: string) =>
124+
Effect.sync(() => {
125+
const command = decodeCommand(line);
126+
if (Option.isNone(command)) return;
127+
const tab = tabs.get(keyOf(command.value));
128+
if (!tab) return;
129+
if (command.value.type === "release") {
130+
// A new server connection starts with a fresh relay and fresh sessions.
131+
tab.relay = null;
132+
return;
133+
}
134+
relayFor(tab).receive(command.value.message);
135+
});
136+
137+
return DesktopBrowserHost.of({
138+
events: Stream.fromPubSub(outbox).pipe(
139+
Stream.map((event) => lineEncoder.encode(`${encodeEvent(event)}\n`)),
140+
),
141+
handleCommandLine,
142+
attach,
143+
detach,
144+
announceAll: Effect.forEach(
145+
[...tabs.values()],
146+
(tab) => {
147+
tab.relay = null;
148+
return PubSub.publish(outbox, { type: "attached", ...tab.key });
149+
},
150+
{ discard: true },
151+
),
152+
});
153+
});
154+
155+
export const layer = Layer.effect(DesktopBrowserHost, make);

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2424
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
2525
import * as ElectronWindow from "../electron/ElectronWindow.ts";
2626
import * as BrowserSession from "./BrowserSession.ts";
27+
import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
2728
import * as PreviewManager from "./Manager.ts";
2829

2930
describe("fitPictureInPictureContentSize", () => {
@@ -267,6 +268,7 @@ const fileSystemLayer = FileSystem.layerNoop({
267268
});
268269

269270
const layer = PreviewManager.layer.pipe(
271+
Layer.provideMerge(DesktopBrowserHost.layer),
270272
Layer.provideMerge(browserSessionLayer),
271273
Layer.provideMerge(environmentLayer),
272274
Layer.provideMerge(fileSystemLayer),

0 commit comments

Comments
 (0)