diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791f..0cf9965b1cc29 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -830,6 +830,48 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("gives only a WSL-only primary the browser channel socket secret", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const primary = yield* configuration.resolvePrimary; + const besideWindows = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.equal(primary.executablePath, "wsl.exe"); + assert.match(primary.bootstrap.desktopBrowserSocketSecret ?? "", /^[0-9a-f]{64}$/); + // Only the primary's tabs render natively. + assert.isUndefined(besideWindows.bootstrap.desktopBrowserSocketSecret); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect( "resolvePrimary marks a removed persisted WSL distro as a fatal preflight failure", () => diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index dadbe8acc743b..6594569dce8e7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -568,6 +568,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl input: SharedBootstrapInput & { readonly port: number; readonly distro: string | null; + readonly desktopBrowserSocketSecret?: string; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -602,6 +603,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // valid number in this slot. tailscaleServeEnabled: false, tailscaleServePort: 443, + ...(input.desktopBrowserSocketSecret === undefined + ? {} + : { desktopBrowserSocketSecret: input.desktopBrowserSocketSecret }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -799,10 +803,13 @@ export const make = Effect.gen(function* () { const persistedSettings = yield* settings.get; const shared = yield* sharedInputs; yield* wslEnvironment.preWarm(persistedSettings.wslDistro); + // Only the primary's tabs render natively, so a WSL backend beside Windows gets no channel. + const desktopBrowserSocketSecret = Hex.encode(yield* crypto.randomBytes(32)); return yield* resolveWslStartConfig({ ...shared, port: backendExposure.port, distro: persistedSettings.wslDistro, + desktopBrowserSocketSecret, }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b4057f4d0194b..83c0ccd2816b7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -15,9 +15,13 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as Socket from "effect/socket/Socket"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED, + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, PRIMARY_LOCAL_ENVIRONMENT_ID, @@ -30,6 +34,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import { linuxPathToWslUncPath } from "../wsl/wslPathParsing.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -42,6 +47,8 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +const DESKTOP_BROWSER_SOCKET_RETRY = Duration.seconds(1); +const DESKTOP_BROWSER_SOCKET_MAX_RETRY = Duration.seconds(30); const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -188,7 +195,10 @@ export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly desktopTelemetryStream: Stream.Stream; readonly desktopBrowserStream?: Stream.Stream; - readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect; + readonly onDesktopBrowserCommand?: ( + line: string, + serverPath?: (path: string) => string, + ) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, ) => Effect.Effect; @@ -374,6 +384,64 @@ function drainBackendOutput( ); } +const socketTextDecoder = new TextDecoder(); + +/** + * Carries the browser channel to a backend that could not inherit its fds, as + * inside WSL. Reconnects, backing off, until the backend process's scope + * interrupts it. + */ +const bridgeDesktopBrowserSocket = Effect.fn("desktop.backendProcess.bridgeDesktopBrowserSocket")( + function* (input: { + readonly httpBaseUrl: URL; + readonly secret: string; + readonly events: Stream.Stream; + readonly onCommand: (line: string) => Effect.Effect; + }) { + const url = new URL(DESKTOP_BROWSER_SOCKET_PATH, input.httpBaseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const connect = Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket(url.href, { + headers: { [DESKTOP_BROWSER_SOCKET_SECRET_HEADER]: input.secret }, + }); + const reader = yield* socket.reader; + const writer = yield* socket.writer; + yield* input.events.pipe( + Stream.runForEach((line) => writer.write(line)), + Effect.forkScoped, + ); + return yield* reader.pull.pipe( + Effect.flatMap((frames) => + Effect.forEach( + frames.flatMap((frame) => + (typeof frame === "string" ? frame : socketTextDecoder.decode(frame)).split("\n"), + ), + (line) => (line.length === 0 ? Effect.void : input.onCommand(line)), + { discard: true }, + ), + ), + Effect.forever, + ); + }).pipe(Effect.scoped); + return yield* connect.pipe( + Effect.catchCause((cause) => + logBackendProcessWarning("desktop browser socket stopped", { + url: url.href, + cause: Cause.pretty(cause), + }), + ), + Effect.repeat( + Schedule.exponential(DESKTOP_BROWSER_SOCKET_RETRY).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, DESKTOP_BROWSER_SOCKET_MAX_RETRY)), + ), + ), + ), + ); + }, + Effect.provide(NodeSocket.layerWebSocketConstructor), +); + const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), @@ -565,7 +633,31 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); - yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); + const desktopBrowserSocketSecret = options.bootstrap.desktopBrowserSocketSecret; + const onDesktopBrowserCommand = options.onDesktopBrowserCommand; + const runningDistro = options.runningDistro; + // The backend asks for downloads on its own disk, which this app reaches over \\wsl.localhost. + const serverPath = + runningDistro === undefined + ? undefined + : (path: string) => linuxPathToWslUncPath(runningDistro, path); + const bridgeDesktopBrowser = + desktopBrowserSocketSecret !== undefined && + options.desktopBrowserStream !== undefined && + onDesktopBrowserCommand !== undefined + ? bridgeDesktopBrowserSocket({ + httpBaseUrl: options.httpBaseUrl, + secret: desktopBrowserSocketSecret, + events: options.desktopBrowserStream, + onCommand: (line) => onDesktopBrowserCommand(line, serverPath), + }) + : Effect.void; + + yield* probeReadiness().pipe( + Effect.repeat({ while: (ready) => !ready }), + Effect.andThen(bridgeDesktopBrowser), + Effect.forkScoped, + ); const exit = yield* handle.exitCode.pipe( Effect.map((exitCode) => ({ diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 2d17a28744545..2f022c05f28d5 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -5,12 +5,14 @@ import { createCdpRelayConnection, type CdpRelayTarget } from "./CdpRelay.ts"; const makeTarget = ( send: CdpRelayTarget["send"], setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {}, + serverPath: CdpRelayTarget["serverPath"] = (path) => path, ): CdpRelayTarget => ({ send, targetId: async () => "GUEST-TARGET", url: () => "http://localhost:4719/", title: () => "Fixture", userAgent: () => "Electron", + serverPath, setDownloadDirectory, }); @@ -190,4 +192,35 @@ describe("CDP relay downloads", () => { { method: "Browser.downloadWillBegin", params: { guid: "g1", suggestedFilename: "r.csv" } }, ]); }); + + it("saves downloads where this app reaches a WSL server's directory", async () => { + const send = vi.fn(async () => ({})); + const directories: Array = []; + const relay = createCdpRelayConnection( + makeTarget( + send, + (directory) => directories.push(directory), + (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll("/", "\\")}`, + ), + () => {}, + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath: "/tmp/artifacts", eventsEnabled: true }, + }), + ); + await settle(); + expect(directories).toEqual(["\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts"]); + expect(send).toHaveBeenCalledWith( + "Browser.setDownloadBehavior", + { + behavior: "allowAndName", + downloadPath: "\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts", + eventsEnabled: true, + }, + undefined, + ); + }); }); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..325420b1450d1 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -15,9 +15,11 @@ export interface CdpRelayTarget { readonly title: () => string; readonly userAgent: () => string; /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. + * Where this app reaches a path on the server's disk: the path itself when it + * launched the server on its own disk, a `\\wsl.localhost` path for WSL. */ + readonly serverPath: (path: string) => string; + /** Downloads land there, so the server's Playwright reads them where it asked. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -82,11 +84,18 @@ export function createCdpRelayConnection( if (ACKNOWLEDGED.has(command.method)) return {}; switch (command.method) { case "Browser.setDownloadBehavior": { - const directory = command.params?.["downloadPath"]; - const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; - target.setDownloadDirectory(allowed ? directory : null); + const requested = command.params?.["downloadPath"]; + const directory = + command.params?.["behavior"] !== "deny" && typeof requested === "string" + ? target.serverPath(requested) + : null; + target.setDownloadDirectory(directory); const { browserContextId: _ignored, ...params } = command.params ?? {}; - return target.send("Browser.setDownloadBehavior", params, undefined); + return target.send( + "Browser.setDownloadBehavior", + directory === null ? params : { ...params, downloadPath: directory }, + undefined, + ); } case "Browser.getVersion": return { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2d..c2b40cf3dbeef 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -11,6 +11,7 @@ import { import * as Context from "effect/Context"; import * as NodePath from "node:path"; import * as Effect from "effect/Effect"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -61,7 +62,11 @@ export class DesktopBrowserHost extends Context.Service< DesktopBrowserHost, { readonly events: Stream.Stream; - readonly handleCommandLine: (line: string) => Effect.Effect; + /** `serverPath` maps a path on the server's disk to one this app can write, as for WSL. */ + readonly handleCommandLine: ( + line: string, + serverPath?: (path: string) => string, + ) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; @@ -122,7 +127,7 @@ export const make = Effect.gen(function* () { else tab.waitingScreenshots.push(start); }); - const relayFor = (tab: AttachedTab) => { + const relayFor = (tab: AttachedTab, serverPath: (path: string) => string) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => @@ -142,6 +147,7 @@ export const make = Effect.gen(function* () { url: () => webContents.getURL(), title: () => webContents.getTitle(), userAgent: () => webContents.getUserAgent(), + serverPath, setDownloadDirectory: (directory) => { tab.downloadDirectory = directory; }, @@ -213,7 +219,7 @@ export const make = Effect.gen(function* () { emit({ type: "attached", ...key }); }; - const handleCommandLine = (line: string) => + const handleCommandLine = (line: string, serverPath: (path: string) => string = identity) => Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; @@ -229,7 +235,7 @@ export const make = Effect.gen(function* () { return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); - relayFor(tab).receive(command.value.message); + relayFor(tab, serverPath).receive(command.value.message); }); const humanStartedDownload = (source: Electron.WebContents) => { diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index e4f7aab394bf8..78b3dc7744202 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -47,6 +47,10 @@ export function extractDistroFromUncPath(windowsPath: string): string | null { return DISTRO_NAME_PATTERN.test(candidate) ? candidate : null; } +export function linuxPathToWslUncPath(distro: string, linuxPath: string): string { + return `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`; +} + export function wslUncPathToLinuxPath(windowsPath: string): string | null { const match = /^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)(?:\\(.*))?$/i.exec(windowsPath.trim()); if (!match) return null; @@ -94,8 +98,7 @@ export function resolveWslPickFolderDefaultPath( return homePath; } - const toUncPath = (linuxPath: string) => - `\\\\wsl.localhost\\${distroName}${linuxPath.replaceAll("/", "\\")}`; + const toUncPath = (linuxPath: string) => linuxPathToWslUncPath(distroName, linuxPath); const normalizedUserHome = userHome && userHome.startsWith("/") ? userHome : null; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 0ac80602221b7..352288ecb4094 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -369,6 +369,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; + const desktopBrowserSocketSecret = bootstrap?.desktopBrowserSocketSecret; const resourceMonitorPath = bootstrap?.resourceMonitorPath; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( @@ -488,6 +489,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct desktopTelemetryControlFd, desktopBrowserFd, desktopBrowserControlFd, + ...(desktopBrowserSocketSecret === undefined ? {} : { desktopBrowserSocketSecret }), resourceMonitorPath, autoBootstrapProjectFromCwd, logWebSocketEvents, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index c5178547b43d1..9444289f483a8 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,7 @@ export class ServerConfig extends Context.Service< readonly desktopTelemetryControlFd?: number | undefined; readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; + readonly desktopBrowserSocketSecret?: string | undefined; readonly resourceMonitorPath?: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; @@ -102,6 +103,16 @@ export class ServerConfig extends Context.Service< export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); +/** Whether the desktop app that launched this server can reach its browser channel. */ +export const hasDesktopBrowserChannel = ( + config: Pick< + ServerConfig["Service"], + "desktopBrowserFd" | "desktopBrowserControlFd" | "desktopBrowserSocketSecret" + >, +) => + (config.desktopBrowserFd !== undefined && config.desktopBrowserControlFd !== undefined) || + config.desktopBrowserSocketSecret !== undefined; + export const otlpResource = ( config: Pick, hostName: string, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..746ae5f11d1ab 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -122,6 +122,38 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); + it.effect("advertises desktopBrowser only when the desktop can reach the browser channel", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + const descriptorFor = (overrides: Partial) => + Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide(ServerConfig.layer({ ...config, mode: "desktop", ...overrides })), + ), + ), + ); + + const withChannel = yield* descriptorFor({ + desktopBrowserFd: 6, + desktopBrowserControlFd: 7, + }); + expect(withChannel.capabilities.desktopBrowser).toBe(true); + // A WSL primary gets no fds through wsl.exe; the desktop dials it with this secret instead. + const wslPrimary = yield* descriptorFor({ desktopBrowserSocketSecret: "secret" }); + expect(wslPrimary.capabilities.desktopBrowser).toBe(true); + const withoutChannel = yield* descriptorFor({}); + expect(withoutChannel.capabilities.serverBrowser).toBe(true); + expect(withoutChannel.capabilities.desktopBrowser).toBeUndefined(); + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index fbb5970560bc6..6ab23214aaba1 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -266,6 +266,7 @@ export const make = Effect.gen(function* () { : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), serverBrowser: true, + ...(ServerConfig.hasDesktopBrowserChannel(serverConfig) ? { desktopBrowser: true } : {}), }, }; diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts new file mode 100644 index 0000000000000..64f2838026820 --- /dev/null +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -0,0 +1,122 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Socket from "effect/socket/Socket"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; + +const SECRET = "desktop-browser-socket-secret"; +const key = { threadId: "thread-1", tabId: "tab-1" }; +const attached = JSON.stringify({ type: "attached", ...key }); + +const layer = DesktopBrowserChannel.layer.pipe( + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return ServerConfig.make({ ...config, desktopBrowserSocketSecret: SECRET }); + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" }))), + ), + Layer.provide(NodeServices.layer), +); + +const fakeDesktop = Effect.gen(function* () { + const frames = yield* Queue.unbounded(); + // One entry per read; the server reads again only once it has handled the previous frame. + const pulls = yield* Queue.unbounded(); + const written: Array = []; + const socket = Socket.make({ + reader: Effect.succeed({ + pull: Queue.offer(pulls, undefined).pipe( + Effect.andThen(Queue.take(frames)), + Effect.orDie, + Effect.map((frame) => [frame] as const), + ), + upgrade: () => Effect.die("unused"), + }), + writer: Effect.succeed({ + write: (chunk) => Effect.sync(() => void written.push(chunk)), + writeAll: (chunks) => Effect.sync(() => void written.push(...chunks)), + }), + }); + const commands = () => + written.filter((chunk) => typeof chunk === "string").map((line) => JSON.parse(line)); + const awaitPulls = (count: number) => + Effect.forEach(Array.from({ length: count }), () => Queue.take(pulls), { discard: true }); + return { frames, written, socket, commands, awaitPulls }; +}); + +it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { + it.effect("authorizes only an upgrade that presents the bootstrap secret", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + + expect(channel.acceptsSocket).toBe(true); + expect(channel.authorizesSocket(SECRET)).toBe(true); + expect(channel.authorizesSocket(`${SECRET}x`)).toBe(false); + expect(channel.authorizesSocket(undefined)).toBe(false); + }), + ); + + it.effect("carries tabs and commands until the desktop disconnects", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const desktop = yield* fakeDesktop; + yield* Queue.offer(desktop.frames, attached); + const session = yield* Effect.scoped(channel.serveSocket(desktop.socket)).pipe( + Effect.forkChild, + ); + + expect(yield* channel.awaitAttached(key, "1 second")).toBe(true); + yield* channel.pointer(key, { phase: "click", x: 1, y: 2 }); + expect(desktop.commands()).toContainEqual({ + type: "pointer", + ...key, + phase: "click", + x: 1, + y: 2, + }); + + // A dropped socket reports no detaches; the desktop announces its tabs again on reconnect. + yield* Fiber.interrupt(session); + expect(yield* channel.isAttached(key)).toBe(false); + }), + ); + + it.effect("closes the socket a reconnecting desktop replaces and ignores its frames", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const stale = yield* fakeDesktop; + const fresh = yield* fakeDesktop; + yield* Queue.offer(stale.frames, attached); + const staleSession = yield* Effect.scoped(channel.serveSocket(stale.socket)).pipe( + Effect.forkChild, + ); + yield* stale.awaitPulls(2); + expect(yield* channel.isAttached(key)).toBe(true); + + const freshSession = yield* Effect.scoped(channel.serveSocket(fresh.socket)).pipe( + Effect.forkChild, + ); + yield* fresh.awaitPulls(1); + expect(stale.written).toContainEqual(expect.objectContaining({ code: 1000 })); + expect(yield* channel.isAttached(key)).toBe(false); + + yield* Queue.offer(stale.frames, attached); + yield* stale.awaitPulls(1); + expect(yield* channel.isAttached(key)).toBe(false); + yield* channel.pointer(key, { phase: "move", x: 3, y: 4 }); + expect(fresh.commands()).toContainEqual(expect.objectContaining({ type: "pointer" })); + expect(stale.commands()).toEqual([]); + + yield* Fiber.interrupt(staleSession); + yield* Fiber.interrupt(freshSession); + }), + ); +}); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 0d1db856c1e6b..c0b7dcc145337 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,16 +1,21 @@ -// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's fds. +// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's channel. /** * Playwright connects over CDP only through a WebSocket URL, so each attached * desktop tab gets a loopback endpoint with an unguessable path. Its frames - * cross the bootstrap file descriptors to the desktop's relay, which owns the - * tab's `webContents.debugger`. + * cross to the desktop's relay, which owns the tab's `webContents.debugger`: + * over the bootstrap file descriptors, or over a WebSocket the desktop dials + * when it could not hand those down, as for a WSL backend. */ +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import * as NodeStream from "@effect/platform-node/NodeStream"; import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; import { + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBrowserCommand, DesktopBrowserEvent, type DesktopBrowserCommand as DesktopBrowserCommandType, + type DesktopBrowserEvent as DesktopBrowserEventType, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -24,6 +29,8 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; +import * as Socket from "effect/socket/Socket"; import * as NodeCrypto from "node:crypto"; import * as NodeNet from "node:net"; @@ -31,8 +38,11 @@ import * as ServerConfig from "../config.ts"; import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; const decodeEvent = Schema.decodeUnknownEffect(DesktopBrowserEvent); +const decodeEventLine = Schema.decodeUnknownEffect(Schema.fromJsonString(DesktopBrowserEvent)); const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(DesktopBrowserCommand)); +const textDecoder = new TextDecoder(); + export interface DesktopTabKey { readonly threadId: string; readonly tabId: string; @@ -40,6 +50,19 @@ export interface DesktopTabKey { const keyOf = ({ threadId, tabId }: DesktopTabKey) => `${threadId}\u0000${tabId}`; +const frameText = (frame: string | Uint8Array) => + typeof frame === "string" ? frame : textDecoder.decode(frame); + +const secretMatches = (expected: string, presented: string | undefined) => { + if (presented === undefined) return false; + const presentedBytes = Buffer.from(presented); + const expectedBytes = Buffer.from(expected); + return ( + presentedBytes.length === expectedBytes.length && + NodeCrypto.timingSafeEqual(presentedBytes, expectedBytes) + ); +}; + export class DesktopBrowserChannel extends Context.Service< DesktopBrowserChannel, { @@ -57,6 +80,12 @@ export class DesktopBrowserChannel extends Context.Service< key: DesktopTabKey, pointer: { readonly phase: "move" | "click"; readonly x: number; readonly y: number }, ) => Effect.Effect; + /** Whether the desktop reaches this channel over `DESKTOP_BROWSER_SOCKET_PATH`. */ + readonly acceptsSocket: boolean; + /** Whether an upgrade request presenting `secret` may carry the channel. */ + readonly authorizesSocket: (secret: string | undefined) => boolean; + /** Serves one desktop connection until it closes; a newer one replaces it. */ + readonly serveSocket: (socket: Socket.Socket) => Effect.Effect; } >()("t3/preview/DesktopBrowserChannel") {} @@ -64,12 +93,13 @@ const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const inputFd = config.desktopBrowserFd; const controlFd = config.desktopBrowserControlFd; + const socketSecret = config.desktopBrowserSocketSecret; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); - const attachedTabs = new Set(); + const attachedTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); - if (inputFd === undefined || controlFd === undefined) { + if (!ServerConfig.hasDesktopBrowserChannel(config)) { return DesktopBrowserChannel.of({ available: false, awaitAttached: () => Effect.succeed(false), @@ -78,56 +108,120 @@ const make = Effect.gen(function* () { isAttached: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => Effect.void, + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }); } + // Commands sent while no desktop socket is connected are dropped: the desktop + // announces its tabs again when it reconnects, and the server then reattaches. + let writeLine: ((line: string) => Effect.Effect) | null = + inputFd !== undefined && controlFd !== undefined + ? (line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`)) + : null; + const command = (message: DesktopBrowserCommandType) => writeLock.withPermits(1)( encodeCommand(message).pipe( - Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))), + Effect.flatMap((line) => (writeLine === null ? Effect.void : writeLine(line))), Effect.catchCause((cause) => Effect.logWarning("desktop browser command failed", { cause }), ), ), ); - // Socket reads can be cancelled while the desktop keeps its write end open. - // Filesystem reads on this pipe keep Node alive after scope shutdown. - const readable = yield* Effect.acquireRelease( - Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), - (stream) => Effect.sync(() => stream.destroy()), - ); - yield* NodeStream.fromReadable({ - evaluate: () => readable, - closeOnDone: true, - onError: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe( - Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), - Stream.mapEffect((value) => decodeEvent(value).pipe(Effect.option)), - Stream.runForEach((decoded) => { - if (Option.isNone(decoded)) return Effect.void; - const event = decoded.value; - const key = { threadId: event.threadId, tabId: event.tabId }; - const id = keyOf(key); - switch (event.type) { - case "cdp": { - const queue = inbound.get(id); - return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void; - } - case "attached": - attachedTabs.add(id); - return PubSub.publish(changes, { key, attached: true }); - case "detached": { - attachedTabs.delete(id); - const queue = inbound.get(id); - return (queue ? Queue.shutdown(queue) : Effect.void).pipe( - Effect.andThen(PubSub.publish(changes, { key, attached: false })), - ); - } + const receive = (event: DesktopBrowserEventType) => { + const key = { threadId: event.threadId, tabId: event.tabId }; + const id = keyOf(key); + switch (event.type) { + case "cdp": { + const queue = inbound.get(id); + return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void; } - }), - Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), - Effect.forkScoped, + case "attached": + attachedTabs.set(id, key); + return PubSub.publish(changes, { key, attached: true }); + case "detached": + return detach(id, key); + } + }; + + const detach = (id: string, key: DesktopTabKey) => { + attachedTabs.delete(id); + const queue = inbound.get(id); + return (queue ? Queue.shutdown(queue) : Effect.void).pipe( + Effect.andThen(PubSub.publish(changes, { key, attached: false })), + Effect.asVoid, + ); + }; + + const detachAll = Effect.suspend(() => + Effect.forEach([...attachedTabs], ([id, key]) => detach(id, key), { discard: true }), + ); + + if (inputFd !== undefined && controlFd !== undefined) { + // Socket reads can be cancelled while the desktop keeps its write end open. + // Filesystem reads on this pipe keep Node alive after scope shutdown. + const readable = yield* Effect.acquireRelease( + Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), + (stream) => Effect.sync(() => stream.destroy()), + ); + yield* NodeStream.fromReadable({ + evaluate: () => readable, + closeOnDone: true, + onError: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), + Stream.mapEffect((value) => decodeEvent(value).pipe(Effect.option)), + Stream.runForEach((decoded) => + Option.isSome(decoded) ? receive(decoded.value) : Effect.void, + ), + Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), + Effect.forkScoped, + ); + } + + let currentSocket: { readonly close: Effect.Effect } | null = null; + const serveSocket = Effect.fn("DesktopBrowserChannel.serveSocket")( + function* (socket: Socket.Socket) { + if (socketSecret === undefined) return; + const reader = yield* socket.reader; + const writer = yield* socket.writer; + // A desktop only reconnects after losing the old socket, which may not have + // noticed yet. It reports no detaches, and its frames must not reach the new one. + const replaced = currentSocket; + const connection = { + close: writer.write(new Socket.CloseEvent(1000, "replaced")).pipe(Effect.ignore), + }; + currentSocket = connection; + writeLine = (line) => writer.write(line); + if (replaced !== null) yield* replaced.close; + yield* detachAll; + yield* Effect.addFinalizer(() => + currentSocket === connection + ? Effect.suspend(() => { + currentSocket = null; + writeLine = null; + return detachAll; + }) + : Effect.void, + ); + const receiveFrames = (frames: ReadonlyArray) => + Effect.forEach( + frames.flatMap((frame) => frameText(frame).split("\n")), + (line) => + line.trim().length === 0 || currentSocket !== connection + ? Effect.void + : decodeEventLine(line).pipe( + Effect.flatMap(receive), + Effect.catch(() => Effect.void), + ), + { discard: true }, + ); + return yield* reader.pull.pipe(Effect.flatMap(receiveFrames), Effect.forever); + }, + Effect.catchCause((cause) => Effect.logDebug("desktop browser socket closed", { cause })), ); const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) { @@ -165,17 +259,11 @@ const make = Effect.gen(function* () { Stream.runForEach((message) => writer.write(message)), Effect.forkScoped, ); - const decoder = new TextDecoder(); return yield* reader.pull.pipe( Effect.flatMap((frames) => Effect.forEach( frames, - (frame) => - command({ - type: "cdp", - ...key, - message: typeof frame === "string" ? frame : decoder.decode(frame), - }), + (frame) => command({ type: "cdp", ...key, message: frameText(frame) }), { discard: true }, ), ), @@ -216,7 +304,35 @@ const make = Effect.gen(function* () { isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), endpoint, pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), + acceptsSocket: socketSecret !== undefined, + authorizesSocket: (secret) => socketSecret !== undefined && secretMatches(socketSecret, secret), + serveSocket, }); }); export const layer = Layer.effect(DesktopBrowserChannel, make); + +// The upgrade presents the bootstrap secret, so this route skips session auth. +export const socketRouteLayer = HttpRouter.use((router) => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel; + yield* router.add( + "GET", + DESKTOP_BROWSER_SOCKET_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (!channel.acceptsSocket || request.headers.upgrade?.toLowerCase() !== "websocket") { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (!channel.authorizesSocket(request.headers[DESKTOP_BROWSER_SOCKET_SECRET_HEADER])) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); + } + // The desktop sits on this machine, so deflate would only cost CPU. + delete NodeHttpServerRequest.toIncomingMessage(request).headers["sec-websocket-extensions"]; + const socket = yield* request.upgrade; + yield* Effect.scoped(channel.serveSocket(socket)); + return HttpServerResponse.empty(); + }), + ); + }), +); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index ac4a785f207f3..eb2b3f3dba618 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -291,6 +291,9 @@ const dependencies = Layer.mergeAll( Effect.sync(() => releasedDesktopTabs.push(key.tabId)), ), pointer: () => Effect.void, + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 403b30af1c719..05643db415789 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -708,6 +708,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAttachmentUploadRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, + DesktopBrowserChannel.socketRouteLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, ), @@ -722,7 +723,7 @@ const layerMakeRoutes = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(layerPullRequestService), - Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), + Layer.provide(ServerBrowser.layer.pipe(Layer.provideMerge(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 29c217e064adc..6a986483751b9 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -18,10 +18,27 @@ const mocks = vi.hoisted(() => ({ closeTab: vi.fn(), registerWebview: vi.fn(), getPreviewConfig: vi.fn(), + primaryHasDesktopBrowser: true, })); vi.mock(import("~/env"), async (original) => ({ ...(await original()), isElectron: true })); +vi.mock(import("~/state/primaryEnvironment"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryEnvironmentIdAtom: Atom.make(EnvironmentId.make("host-webview-order")) }; +}); + +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useEnvironmentHasDesktopBrowser: () => mocks.primaryHasDesktopBrowser, +})); + +// Background-tab discovery subscribes to the primary over RPC; these tests seed sessions directly. +vi.mock(import("~/components/preview/usePreviewSession"), async (original) => ({ + ...(await original()), + useDesktopHostedPreviewSessions: () => {}, +})); + vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), useEnvironmentScope: () => true, @@ -73,7 +90,8 @@ const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ( updatedAt, }); -function setup(): Disposable { +function setup({ primaryHasDesktopBrowser = true } = {}): Disposable { + mocks.primaryHasDesktopBrowser = primaryHasDesktopBrowser; __resetClientSettingsPersistenceForTests(); mocks.getClientSettings.mockReset().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); mocks.setClientSettings.mockReset().mockResolvedValue(undefined); @@ -160,4 +178,28 @@ describe("ElectronBrowserHost", () => { expect(after).toHaveLength(2); expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); + + // Without the desktop browser channel, a local webview would never reach the primary's server. + it.each([ + { primaryHasDesktopBrowser: true, mounted: ["tab_1"] }, + { primaryHasDesktopBrowser: false, mounted: [] }, + ])( + "mounts a webview for the primary's server tab only with the desktop browser channel ($primaryHasDesktopBrowser)", + async ({ primaryHasDesktopBrowser, mounted }) => { + using _state = setup({ primaryHasDesktopBrowser }); + reconcilePreviewServerSessions(threadRef, { + sessions: [{ ...snapshot("tab_1", "2026-01-01T00:00:01.000Z"), runtime: "server" }], + serverEpoch: "server-a", + revision: 1, + }); + await using screen = await renderHost(); + + const host = screen.container.querySelector("[data-electron-browser-host]")!; + expect( + [...host.querySelectorAll("webview")].map((node) => + node.getAttribute("data-preview-server-tab"), + ), + ).toEqual(mounted); + }, + ); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 8818b377f5e3b..7681f78e559d4 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -13,6 +13,7 @@ import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { useEnvironmentHasDesktopBrowser } from "~/state/entities"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -29,6 +30,7 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef @@ -36,7 +38,12 @@ export function ElectronBrowserHost() { .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ), ) .map((snapshot) => ({ threadRef, diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 3d8e1d0a1f43d..25353ecf16741 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -7,6 +7,7 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { readEnvironmentSupportsServerBrowser, + useEnvironmentHasDesktopBrowser, useEnvironmentSupportsServerBrowser, } from "~/state/entities"; @@ -41,16 +42,20 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea return isPreviewSupportedInRuntime() || serverBrowser; } +// A primary the desktop cannot reach over the browser channel never hears about +// navigation in a local , so its tabs stream like any other environment's. export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, + primaryHasDesktopBrowser: boolean, snapshot: Pick | null | undefined, ): boolean { return ( isElectron && snapshot?.runtime === "server" && primaryEnvironmentId !== null && - environmentId === primaryEnvironmentId + environmentId === primaryEnvironmentId && + primaryHasDesktopBrowser ); } @@ -58,5 +63,12 @@ export function useRendersServerTabNatively( environmentId: EnvironmentId, snapshot: Pick | null | undefined, ): boolean { - return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + return rendersServerTabNatively( + environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index e88422aa54e1a..749147950d4e8 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -308,6 +308,14 @@ export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId ); } +export function useEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.desktopBrowser === true + ); +} + export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean { return ( appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index bc0a723b57b02..e577b4c0d2888 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -33,6 +33,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), desktopBrowserFd: Schema.optionalKey(PositiveInt), desktopBrowserControlFd: Schema.optionalKey(PositiveInt), + // wsl.exe drops the browser channel fds, so a WSL primary gets this instead + // and the desktop dials the channel over a WebSocket that presents it. + // See `DESKTOP_BROWSER_SOCKET_PATH`. + desktopBrowserSocketSecret: Schema.optionalKey(TrimmedNonEmptyString), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), }); diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index c70b374910079..918fa8ce905e3 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -27,3 +27,11 @@ export const DesktopBrowserCommand = Schema.Union([ }), ]); export type DesktopBrowserCommand = typeof DesktopBrowserCommand.Type; + +/** + * Carries the channel, one event or command per frame, when the backend could + * not inherit its fds, as inside WSL. The upgrade request presents the + * bootstrap's `desktopBrowserSocketSecret` in this header. + */ +export const DESKTOP_BROWSER_SOCKET_PATH = "/api/desktop-browser"; +export const DESKTOP_BROWSER_SOCKET_SECRET_HEADER = "x-t3-desktop-browser-secret"; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 18d09c304a83e..b199ed795a6a4 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -152,6 +152,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** The desktop app that launched this server holds its browser channel, so + it can draw this server's tabs in its own ``. Absent on servers + launched without the channel; the desktop app streams their tabs instead. */ + desktopBrowser: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;