From f1455e047c25f4159ca0590ae998b2384ada97ff Mon Sep 17 00:00:00 2001 From: narongchai Date: Fri, 13 Nov 2026 10:28:33 +0700 Subject: [PATCH 1/3] fix(preview): restore native browser tabs in WSL-only mode --- .../DesktopBackendConfiguration.test.ts | 42 +++ .../backend/DesktopBackendConfiguration.ts | 7 + .../src/backend/DesktopBackendManager.test.ts | 82 ++++++ .../src/backend/DesktopBackendManager.ts | 106 ++++++- apps/desktop/src/preview/CdpRelay.test.ts | 98 +++++++ apps/desktop/src/preview/CdpRelay.ts | 20 +- .../desktop/src/preview/DesktopBrowserHost.ts | 17 +- .../src/wsl/DesktopWslEnvironment.test.ts | 134 +++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 70 +++++ apps/server/src/cli/config.ts | 2 + apps/server/src/config.ts | 11 + .../src/environment/ServerEnvironment.test.ts | 32 +++ .../src/environment/ServerEnvironment.ts | 1 + .../src/preview/DesktopBrowserChannel.test.ts | 154 ++++++++++ .../src/preview/DesktopBrowserChannel.ts | 266 +++++++++++++----- apps/server/src/preview/ServerBrowser.test.ts | 3 + apps/server/src/server.ts | 6 +- .../ElectronBrowserHost.browser.test.tsx | 50 +++- apps/web/src/browser/ElectronBrowserHost.tsx | 20 +- apps/web/src/browser/previewRuntime.ts | 21 +- .../preview/ThreadPreviewMiniPlayer.tsx | 10 +- apps/web/src/state/entities.ts | 15 + packages/contracts/src/desktopBootstrap.ts | 4 + packages/contracts/src/desktopBrowser.ts | 8 + packages/contracts/src/environment.ts | 2 + 25 files changed, 1088 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 17ba6a674f76c..18829b93515e2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -814,6 +814,48 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.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(Layer.mergeAll(NodeServices.layer, NodeCrypto.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 e7b8405f8124d..1393a0d4383df 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -555,6 +555,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, @@ -583,6 +584,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, + ...(input.desktopBrowserSocketSecret === undefined + ? {} + : { desktopBrowserSocketSecret: input.desktopBrowserSocketSecret }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -770,10 +774,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* random.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.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e96e036b0c88b..dafb8bde9020e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -1,6 +1,8 @@ import { SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, @@ -11,6 +13,7 @@ import { DATABASE_MIGRATIONS_STARTED_MESSAGE, } from "@t3tools/shared/databaseMigrationLog"; import { assert, describe, it } from "@effect/vitest"; +import * as NodeWS from "@effect/platform-node/NodeWS"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -324,6 +327,85 @@ describe("DesktopBackendManager", () => { ), ); + it.live( + "authenticates the browser socket and carries events and commands without inherited fds", + () => + Effect.scoped( + Effect.gen(function* () { + const commandHandled = yield* Deferred.make(); + const eventReceived = yield* Deferred.make<{ + readonly path: string | undefined; + readonly secret: string | string[] | undefined; + readonly event: string; + }>(); + const childExit = yield* Deferred.make(); + const server = yield* Effect.acquireRelease( + Effect.sync(() => new NodeWS.WebSocketServer({ host: "127.0.0.1", port: 0 })), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + for (const client of server.clients) client.terminate(); + server.close(() => resolve()); + }), + ), + ); + if (!server.address()) { + yield* Effect.promise( + () => new Promise((resolve) => server.once("listening", resolve)), + ); + } + const address = server.address(); + if (typeof address !== "object" || address === null) + throw new Error("Missing socket port"); + const secret = "browser-bootstrap-secret"; + const event = '{"type":"attached","threadId":"thread","tabId":"tab"}\n'; + const command = '{"type":"release","threadId":"thread","tabId":"tab"}'; + server.on("connection", (socket, request) => { + socket.once("message", (data) => { + Deferred.doneUnsafe( + eventReceived, + Effect.succeed({ + path: request.url, + secret: request.headers[DESKTOP_BROWSER_SOCKET_SECRET_HEADER], + event: data.toString(), + }), + ); + socket.send(`${command}\n`); + }); + }); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed(makeProcess({ exitCode: Deferred.await(childExit) })), + ), + ); + const running = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + bootstrapDelivery: "stdin", + bootstrap: { ...baseConfig.bootstrap, desktopBrowserSocketSecret: secret }, + httpBaseUrl: new URL(`http://127.0.0.1:${address.port}`), + desktopTelemetryStream: Stream.empty, + desktopBrowserStream: Stream.make(new TextEncoder().encode(event)), + onDesktopBrowserCommand: (line) => + Deferred.succeed(commandHandled, line).pipe(Effect.asVoid), + }).pipe( + Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), + Effect.scoped, + Effect.forkScoped, + ); + assert.deepEqual(yield* Deferred.await(eventReceived), { + path: DESKTOP_BROWSER_SOCKET_PATH, + secret, + event, + }); + assert.equal(yield* Deferred.await(commandHandled), command); + yield* Deferred.succeed(childExit, ChildProcessSpawner.ExitCode(0)); + assert.equal((yield* Fiber.join(running)).code.pipe(Option.getOrUndefined), 0); + }), + ), + ); + it.effect("reports a SIGKILL exit without an exit code", () => Effect.gen(function* () { const exitCause = PlatformError.systemError({ diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 89ebc9e862ecf..88ea74c1a3e68 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -15,10 +16,14 @@ 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 NodeWS from "@effect/platform-node/NodeWS"; import { SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, PRIMARY_LOCAL_ENVIRONMENT_ID, @@ -53,6 +58,10 @@ const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; // Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. const STARTUP_FAILURE_RESTART_ATTEMPTS = 3; const STARTUP_FAILURE_OUTPUT_CHARS = 4_000; +const DESKTOP_BROWSER_SOCKET_RETRY = Duration.seconds(1); +const DESKTOP_BROWSER_SOCKET_MAX_RETRY = Duration.seconds(30); +// Each conversion starts wsl.exe, so multi-file uploads must share a process budget. +const MAX_CONCURRENT_WSL_PATH_CONVERSIONS = 4; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -197,7 +206,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 | Promise, + ) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, ) => Effect.Effect; @@ -405,6 +417,61 @@ function drainBackendOutput( ); } +const socketTextDecoder = new TextDecoder(); + +// wsl.exe cannot inherit the browser fds; reconnect until the backend scope ends. +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)), + ), + ), + ), + ); + }, + // Authentication headers must not depend on Electron's global WebSocket implementation. + Effect.provide(NodeWS.layerWebSocketConstructor), +); + const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), @@ -587,7 +654,42 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); - yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); + const processScope = yield* Scope.Scope; + const pathConversions = yield* Semaphore.make(MAX_CONCURRENT_WSL_PATH_CONVERSIONS); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const desktopBrowserSocketSecret = options.bootstrap.desktopBrowserSocketSecret; + const onDesktopBrowserCommand = options.onDesktopBrowserCommand; + const runningDistro = options.runningDistro; + // DrvFS mounts cannot be re-exported through WSL UNC paths; let the distro resolve its mounts. + const serverPath = + runningDistro === undefined + ? undefined + : (path: string) => + runPromise( + DesktopWslEnvironment.wslToWindowsPath(runningDistro, path).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + pathConversions.withPermit, + Effect.forkIn(processScope), + Effect.flatMap(Fiber.join), + ), + ); + 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.withSpan("desktop.backendProcess.awaitExit"), diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 6e657ba5fc4d7..05becbfce4e95 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -9,12 +9,14 @@ import { 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, }); @@ -210,6 +212,102 @@ describe("CDP relay downloads", () => { ]); }); + it.each([ + { + downloadPath: "/tmp/artifacts", + directoryPath: "/home/user/assets", + windowsDownloads: "\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts", + windowsDirectory: "\\\\wsl.localhost\\Ubuntu\\home\\user\\assets", + }, + { + downloadPath: "/mnt/c/Users/user/My Downloads", + directoryPath: "/windows/Users/user/My Assets", + windowsDownloads: "C:\\Users\\user\\My Downloads", + windowsDirectory: "C:\\Users\\user\\My Assets", + }, + ])( + "awaits WSL path conversion for downloads and directory uploads ($directoryPath)", + async ({ downloadPath, directoryPath, windowsDownloads, windowsDirectory }) => { + const send = vi.fn(async () => ({})); + const directories: Array = []; + const relay = makeRelay( + makeTarget( + send, + (directory) => directories.push(directory), + async (path) => (path === downloadPath ? windowsDownloads : windowsDirectory), + ), + () => {}, + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath, eventsEnabled: true }, + }), + ); + await settle(); + expect(directories).toEqual([windowsDownloads]); + expect(send).toHaveBeenCalledWith( + "Page.setDownloadBehavior", + { behavior: "default" }, + undefined, + ); + relay.receive( + JSON.stringify({ + id: 2, + method: "DOM.setFileInputFiles", + params: { objectId: "input", files: [directoryPath] }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(send).toHaveBeenLastCalledWith( + "DOM.setFileInputFiles", + { objectId: "input", files: [windowsDirectory] }, + undefined, + undefined, + ); + }, + ); + + it.each(["DOM.setFileInputFiles", "Browser.setDownloadBehavior"])( + "reports a failed path conversion without forwarding %s", + async (method) => { + const send = vi.fn(async () => ({})); + const setDirectory = vi.fn(); + const written: Array> = []; + const relay = makeRelay( + makeTarget(send, setDirectory, async () => { + throw new Error("WSL path conversion failed"); + }), + (raw) => written.push(JSON.parse(raw)), + ); + const session = method === "DOM.setFileInputFiles" ? { sessionId: "t3-preview-page" } : {}; + relay.receive( + JSON.stringify({ + id: 1, + method, + params: { + files: ["/mnt/c/assets"], + behavior: "allowAndName", + downloadPath: "/mnt/c/artifacts", + }, + ...session, + }), + ); + await settle(); + expect(written).toEqual([ + { + id: 1, + error: { code: -32000, message: "WSL path conversion failed" }, + ...session, + }, + ]); + expect(send).not.toHaveBeenCalled(); + expect(setDirectory).not.toHaveBeenCalled(); + }, + ); + it("keeps denial scoped to the preview and restores Electron's save handling when allowed", async () => { const send = vi.fn(async () => ({})); const directories: Array = []; diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 94a78f47db686..0a3a6833d861c 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -14,7 +14,9 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** The desktop and its server share a disk; download paths need no translation. */ + /** Where this app reaches a server path, resolving WSL mounts to their Windows paths. */ + readonly serverPath: (path: string) => string | Promise; + /** Downloads land there, so the server's Playwright reads them where it asked. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -123,13 +125,14 @@ export function createCdpRelayConnection( case "Browser.setDownloadBehavior": { const directory = command.params?.["downloadPath"]; const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; + const downloadDirectory = allowed ? await target.serverPath(directory) : null; // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download. const result = await target.send( "Page.setDownloadBehavior", { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" }, undefined, ); - target.setDownloadDirectory(allowed ? directory : null); + target.setDownloadDirectory(downloadDirectory); downloadEventsEnabled = command.params?.["eventsEnabled"] === true; return result; } @@ -190,7 +193,7 @@ export function createCdpRelayConnection( let runtimeReset: Promise | undefined; - const pageCommand = (command: CdpCommand): Promise => { + const pageCommand = async (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; const owner = child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; @@ -208,7 +211,16 @@ export function createCdpRelayConnection( target.send(command.method, command.params ?? {}, undefined, owner), ); } - return target.send(command.method, command.params ?? {}, child, owner); + let params = command.params ?? {}; + if (command.method === "DOM.setFileInputFiles" && Array.isArray(params["files"])) { + const files = await Promise.all( + params["files"].map((path: unknown) => + typeof path === "string" ? target.serverPath(path) : path, + ), + ); + params = { ...params, files }; + } + return target.send(command.method, params, child, owner); }; return { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index a53fc0e3e6ec2..27178c262a015 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -10,6 +10,7 @@ import * as Context from "effect/Context"; import * as NodePath from "node:path"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -92,7 +93,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 | Promise, + ) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void; @@ -315,7 +320,7 @@ export const make = Effect.gen(function* () { } }; - const relayFor = (tab: AttachedTab) => { + const relayFor = (tab: AttachedTab, serverPath: (path: string) => string | Promise) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee, cookiesCleared } = tab.debuggee; const send = async (method: string, params: Record, sessionId?: string) => { @@ -385,6 +390,7 @@ export const make = Effect.gen(function* () { url: () => webContents.getURL(), title: () => webContents.getTitle(), userAgent: () => webContents.getUserAgent(), + serverPath, setDownloadDirectory: (directory) => { tab.downloadDirectory = directory; }, @@ -543,7 +549,10 @@ export const make = Effect.gen(function* () { if (tabs.has(keyOf(key))) emit({ type: "loadFailed", ...key, ...failure }); }; - const handleCommandLine = (line: string) => + const handleCommandLine = ( + line: string, + serverPath: (path: string) => string | Promise = identity, + ) => Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; @@ -564,7 +573,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/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 69962f52e4f18..0dd46271a9740 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,9 +1,17 @@ // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; @@ -18,6 +26,7 @@ import { buildWslRuntimeProbeScript, parseResolvedPath, parseWslRuntimeRoot, + wslToWindowsPath, } from "./DesktopWslEnvironment.ts"; // The install script only fails the way this file cares about when a real shell @@ -459,3 +468,128 @@ describe("makeIsAvailable", () => { }), ); }); + +describe("wslToWindowsPath", () => { + const makeProcess = ( + stdout: Stream.Stream = Stream.empty, + exitCode: Effect.Effect = Effect.succeed( + ChildProcessSpawner.ExitCode(0), + ), + ) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + + it.effect.each([ + ["/mnt/c/Users/test/assets", "C:\\Users\\test\\assets\n", "C:\\Users\\test\\assets"], + [ + "/windows/c/Users/test user's assets ", + "C:\\Users\\test user's assets \r\n", + "C:\\Users\\test user's assets ", + ], + [ + "/home/test/assets", + "\\\\wsl.localhost\\Test Distro\\home\\test\\assets\n", + "\\\\wsl.localhost\\Test Distro\\home\\test\\assets", + ], + ] as const)( + "uses the selected distro's mount resolution for %s", + ([linuxPath, output, expected]) => + Effect.gen(function* () { + let spawned: ChildProcess.Command | undefined; + const spawner = ChildProcessSpawner.make((command) => { + spawned = command; + return Effect.succeed(makeProcess(Stream.encodeText(Stream.make(output)))); + }); + const converted = yield* wslToWindowsPath("Test Distro", linuxPath).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.equal(converted, expected); + assert.isDefined(spawned); + assert.equal(spawned._tag, "StandardCommand"); + if (spawned._tag !== "StandardCommand") return; + assert.equal(spawned.command, "wsl.exe"); + assert.deepEqual(spawned.args, ["-d", "Test Distro", "--", "wslpath", "-w", linuxPath]); + }), + ); + + it.effect.each([ + { output: "", exitCode: 1 }, + { output: "\n", exitCode: 0 }, + ])("refuses an unsuccessful or empty conversion (%s)", ({ output, exitCode }) => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess( + Stream.encodeText(Stream.make(output)), + Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + ), + ), + ); + const error = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flip, + ); + assert.equal(error._tag, "DesktopWslPathConversionError"); + assert.equal(error.stage, "conversion"); + assert.equal(error.distro, "Test Distro"); + assert.equal(error.linuxPath, "/mnt/c/assets"); + }), + ); + + it.effect("reports failure to start the path converter", () => + Effect.gen(function* () { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("wsl.exe missing"), + }); + const error = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ), + Effect.flip, + ); + assert.equal(error.stage, "spawn"); + assert.equal(error.cause, cause); + }), + ); + + it.effect("closes the converter's process scope when it times out", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const released = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.acquireRelease( + Deferred.succeed(started, undefined).pipe( + Effect.as(makeProcess(Stream.never, Effect.never)), + ), + () => Deferred.succeed(released, undefined), + ), + ); + const fiber = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkScoped, + ); + yield* Deferred.await(started); + yield* TestClock.adjust(Duration.seconds(10)); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + yield* Deferred.await(released); + assert.equal(error.stage, "timeout"); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 57d6c61f7c43a..e2c0eeb3febda 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -85,6 +85,21 @@ export class DesktopWslDistroListError extends Schema.TaggedError()( + "DesktopWslPathConversionError", + { + distro: Schema.String, + linuxPath: Schema.String, + stage: Schema.Literals(["spawn", "read", "exit", "conversion", "timeout"]), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not convert the WSL path ${this.linuxPath} in ${this.distro} (${this.stage}).`; + } +} + const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service< @@ -959,6 +974,61 @@ const windowsToWslPathImpl = ( ); }; +export const wslToWindowsPath = Effect.fn("desktop.wsl.wslToWindowsPath")(function* ( + distro: string, + linuxPath: string, +): Effect.fn.Return< + string, + DesktopWslPathConversionError, + ChildProcessSpawner.ChildProcessSpawner +> { + const converted = yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(distro), "--", "wslpath", "-w", linuxPath], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + const failure = (stage: "spawn" | "read" | "exit", cause: unknown) => + new DesktopWslPathConversionError({ distro, linuxPath, stage, cause }); + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => failure("spawn", cause))); + const [stdout, exitCode] = yield* Effect.all( + [ + Stream.runCollect(handle.stdout).pipe(Effect.mapError((cause) => failure("read", cause))), + handle.exitCode.pipe(Effect.mapError((cause) => failure("exit", cause))), + ], + { concurrency: 2 }, + ); + if (exitCode !== ChildProcessSpawner.ExitCode(0)) { + return yield* new DesktopWslPathConversionError({ + distro, + linuxPath, + stage: "conversion", + exitCode, + }); + } + const path = decodeUtf8(concatChunks(stdout)).replace(/\r?\n$/, ""); + if (path.length === 0) { + return yield* new DesktopWslPathConversionError({ distro, linuxPath, stage: "conversion" }); + } + return path; + }), + ).pipe(Effect.timeoutOption(WSLPATH_TIMEOUT)); + return yield* Option.match(converted, { + onNone: () => new DesktopWslPathConversionError({ distro, linuxPath, stage: "timeout" }), + onSome: Effect.succeed, + }); +}); + const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; const getDistroIpImpl = ( diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a7b9584d1a7e6..f460c7332be6f 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -386,6 +386,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const desktopAppUpdate = bootstrap?.desktopAppUpdate; const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; + const desktopBrowserSocketSecret = bootstrap?.desktopBrowserSocketSecret; const resourceMonitorPath = bootstrap?.resourceMonitorPath; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( @@ -520,6 +521,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }), desktopBrowserFd, desktopBrowserControlFd, + ...(desktopBrowserSocketSecret === undefined ? {} : { desktopBrowserSocketSecret }), resourceMonitorPath, autoBootstrapProjectFromCwd, logWebSocketEvents, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1acad23117c8d..9b1fb092c1e5e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -103,6 +103,7 @@ export class ServerConfig extends Context.Service< readonly desktopAppUpdate?: boolean | undefined; readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; + readonly desktopBrowserSocketSecret?: string | undefined; readonly resourceMonitorPath?: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; @@ -118,6 +119,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 3a9bfafb82eaf..782e33d0a55cc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -123,6 +123,38 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen } }), ); + 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).toBe(false); + }), + ); it.effect.each([ { desktopAppUpdate: false, advertised: undefined }, { desktopAppUpdate: true, advertised: true }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 06c9b0cb90551..ef5d8f5bcd4a6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -294,6 +294,7 @@ export const make = Effect.gen(function* () { mcpHostedApproval: true, shellThreadFamilies: true, shellOlderSettled: true, + desktopBrowser: ServerConfig.hasDesktopBrowserChannel(serverConfig), }, }; diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts index 3cadfcb92e47a..477f20961d4ac 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -2,8 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeStream from "@effect/platform-node/NodeStream"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Socket from "effect/socket/Socket"; import * as Stream from "effect/Stream"; import { vi } from "vite-plus/test"; @@ -110,3 +114,153 @@ it.live("closes a tab's relay connection when the desktop detaches the tab", () ), ), ); +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; + const loadFailure = { + url: "https://missing.example/", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }; + yield* Queue.offer( + desktop.frames, + [ + attached, + JSON.stringify({ type: "loadFailed", ...key, ...loadFailure }), + JSON.stringify({ type: "shown", ...key, shown: false }), + ].join("\n"), + ); + const session = yield* Effect.scoped(channel.serveSocket(desktop.socket)).pipe( + Effect.forkChild, + ); + + yield* desktop.awaitPulls(2); + expect(yield* channel.isAttached(key)).toBe(true); + expect(channel.loadFailure(key)).toEqual(loadFailure); + expect(channel.isShown(key)).toBe(false); + 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); + expect(channel.loadFailure(key)).toBeUndefined(); + expect(channel.isShown(key)).toBe(true); + }), + ); + + 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* channel.turn(key.threadId, true); + 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 endpointScope = yield* Scope.make(); + yield* channel.endpoint(key).pipe(Scope.provide(endpointScope)); + + 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(fresh.frames, attached); + yield* fresh.awaitPulls(1); + expect(fresh.commands()).toContainEqual({ + type: "turn", + threadId: key.threadId, + active: true, + }); + yield* Scope.close(endpointScope, Exit.void); + expect(fresh.commands()).not.toContainEqual({ type: "release", ...key }); + + yield* Queue.offer(stale.frames, attached); + yield* stale.awaitPulls(1); + expect(yield* channel.isAttached(key)).toBe(true); + yield* channel.pointer(key, { phase: "move", x: 3, y: 4 }); + expect(fresh.commands()).toContainEqual(expect.objectContaining({ type: "pointer" })); + expect(stale.commands()).toEqual([{ type: "turn", threadId: key.threadId, active: true }]); + + yield* channel.turn(key.threadId, false); + yield* Queue.offer(fresh.frames, attached); + yield* fresh.awaitPulls(1); + expect(fresh.commands().filter((command) => command.type === "turn")).toEqual([ + { type: "turn", threadId: key.threadId, active: true }, + { type: "turn", threadId: key.threadId, active: false }, + ]); + + 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 5d6b55fa882e8..e1b732590edaf 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,11 +1,18 @@ -// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's fds. -/** Playwright needs a CDP WebSocket; the secret loopback endpoint relays desktop debugger frames over bootstrap fds. */ +// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's channel. +/** + * Playwright requires a WebSocket CDP endpoint for each desktop tab. + * The loopback endpoint relays frames over inherited pipes or a WSL socket. + */ +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, type PreviewAutomationLoadError, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -20,6 +27,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"; @@ -27,8 +36,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; @@ -36,6 +48,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, { @@ -56,6 +81,9 @@ export class DesktopBrowserChannel extends Context.Service< ) => Effect.Effect; /** Never fails. A failed write means a broken channel; the next backend starts with no turns. */ readonly turn: (threadId: string, active: boolean) => Effect.Effect; + readonly acceptsSocket: boolean; + readonly authorizesSocket: (secret: string | undefined) => boolean; + readonly serveSocket: (socket: Socket.Socket) => Effect.Effect; } >()("t3/preview/DesktopBrowserChannel") {} @@ -63,15 +91,17 @@ 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 activeThreads = new Set(); // Missing reports protect desktop tabs until the renderer explicitly marks them hidden. const shownTabs = new Map(); const loadFailures = 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.fn("DesktopBrowserChannel.awaitAttached")(function () { @@ -91,78 +121,153 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: Effect.fn("DesktopBrowserChannel.serveSocket")(function () { + return Effect.void; + }), isShown: () => false, loadFailure: () => undefined, }); } - const command = (message: DesktopBrowserCommandType) => + // 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, writer = writeLine) => writeLock.withPermits(1)( encodeCommand(message).pipe( - Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))), + Effect.flatMap((line) => + writer === null || writer !== writeLine ? Effect.void : writer(line), + ), Effect.catchCause((cause) => Effect.logWarning("desktop browser command failed", { cause }), ), ), ); - // Use cancellable socket reads; filesystem pipe reads 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 "loadFailed": - loadFailures.set(id, { - url: event.url.slice(0, 2048), - code: event.code, - description: event.description, - }); - return Effect.void; - case "detached": { - attachedTabs.delete(id); - shownTabs.delete(id); - loadFailures.delete(id); - const queue = inbound.get(id); - return (queue ? Queue.shutdown(queue) : Effect.void).pipe( - Effect.andThen(PubSub.publish(changes, { key, attached: false })), - ); - } - case "shown": - shownTabs.set(id, event.shown); - return Effect.void; + 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.ensuring( - Effect.sync(() => { - shownTabs.clear(); - loadFailures.clear(); - }), - ), - Effect.forkScoped, + case "attached": + attachedTabs.set(id, key); + return PubSub.publish(changes, { key, attached: true }).pipe( + Effect.andThen(() => + activeThreads.has(key.threadId) + ? command({ type: "turn", threadId: key.threadId, active: true }) + : Effect.void, + ), + ); + case "loadFailed": + loadFailures.set(id, { + url: event.url.slice(0, 2048), + code: event.code, + description: event.description, + }); + return Effect.void; + case "detached": + return detach(id, key); + case "shown": + shownTabs.set(id, event.shown); + return Effect.void; + } + }; + + const detach = (id: string, key: DesktopTabKey) => { + attachedTabs.delete(id); + shownTabs.delete(id); + loadFailures.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(() => { + shownTabs.clear(); + loadFailures.clear(); + return 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.ensuring( + Effect.sync(() => { + shownTabs.clear(); + loadFailures.clear(); + }), + ), + 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.ignore), + { 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) { + const channelWriter = writeLine; const id = keyOf(key); const secret = NodeCrypto.randomBytes(24).toString("base64url"); const server = yield* NodeSocketServer.makeWebSocket({ @@ -181,7 +286,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { if (inbound.get(id) === queue) inbound.delete(id); yield* Queue.shutdown(queue); - yield* command({ type: "release", ...key }); + yield* command({ type: "release", ...key }, channelWriter); }), ); // The relay serves one Playwright connection; a second would see the first's sessions. @@ -199,17 +304,12 @@ const make = Effect.gen(function* () { Stream.runForEach((message) => writer.write(message)), Effect.exit, ); - const decoder = new TextDecoder(); const toDesktop = reader.pull.pipe( Effect.flatMap((frames) => Effect.forEach( frames, (frame) => - command({ - type: "cdp", - ...key, - message: typeof frame === "string" ? frame : decoder.decode(frame), - }), + command({ type: "cdp", ...key, message: frameText(frame) }, channelWriter), { discard: true }, ), ), @@ -255,13 +355,43 @@ const make = Effect.gen(function* () { isShown: (key) => shownTabs.get(keyOf(key)) ?? true, loadFailure: (key) => loadFailures.get(keyOf(key)), endpoint, - turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { - return command({ type: "turn", threadId, active }); + turn: Effect.fn("DesktopBrowserChannel.turn")(function* (threadId, active) { + if (active) activeThreads.add(threadId); + else activeThreads.delete(threadId); + yield* command({ type: "turn", threadId, active }); }), pointer: Effect.fn("DesktopBrowserChannel.pointer")(function (key, pointer) { return 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 c527bc93381ef..6db08aadfa995 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -506,6 +506,9 @@ const dependencies = Layer.mergeAll( ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }), PreviewAgentActivity.layer, ).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dae95787afe82..abf8dbb6d790b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -816,6 +816,7 @@ const layerMakeRoutes = Layer.mergeAll( GuestHttp.layer, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, + DesktopBrowserChannel.socketRouteLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, speechStreamRouteLayer, @@ -838,10 +839,11 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(PeerLinkRequests.layer), // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), - // The same activity instance RunExecutionService reports to, by layer memoization. + // The route and browser share the channel; the browser shares agent activity with run execution. Layer.provide( ServerBrowser.layer.pipe( - Layer.provide(Layer.merge(DesktopBrowserChannel.layer, PreviewAgentActivity.layer)), + Layer.provide(PreviewAgentActivity.layer), + Layer.provideMerge(DesktopBrowserChannel.layer), ), ), Layer.provide(PreviewBrowser.layer), diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 29c217e064adc..42bc0fc574551 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -18,10 +18,30 @@ 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 (original) => { + const { Atom } = await import("effect/reactivity"); + return { + ...(await original()), + 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 +93,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 +181,31 @@ 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, headless: false, mounted: ["tab_1"] }, + { primaryHasDesktopBrowser: false, headless: false, mounted: [] }, + { primaryHasDesktopBrowser: true, headless: true, mounted: [] }, + ])( + "gates native server tabs (channel=$primaryHasDesktopBrowser, headless=$headless)", + async ({ primaryHasDesktopBrowser, headless, mounted }) => { + using _state = setup({ primaryHasDesktopBrowser }); + reconcilePreviewServerSessions(threadRef, { + sessions: [ + { ...snapshot("tab_1", "2026-01-01T00:00:01.000Z"), runtime: "server", headless }, + ], + 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 d71b7a2b6b2ba..f0ea0335706dc 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -21,6 +21,10 @@ import { useActivePreviewSessions, } from "~/previewStateStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { + readEnvironmentHasDesktopBrowser, + useEnvironmentHasDesktopBrowser, +} from "~/state/entities"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; @@ -36,6 +40,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; function hostedBrowserSessions( previewByThreadKey: Record, primaryEnvironmentId: EnvironmentId | null, + primaryHasDesktopBrowser: boolean, ) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); @@ -44,7 +49,12 @@ function hostedBrowserSessions( .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ), ) .map((snapshot) => ({ threadRef, @@ -98,6 +108,7 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve const source = hostedBrowserSessions( readActivePreviewSessions(), appAtomRegistry.get(primaryEnvironmentIdAtom), + readEnvironmentHasDesktopBrowser(appAtomRegistry.get(primaryEnvironmentIdAtom)), ).find((session) => session.runtimeTabId === tabId); if (!source) return; void openUrlInPreview({ @@ -129,7 +140,12 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); - const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + const sessions = hostedBrowserSessions( + previewByThreadKey, + primaryEnvironmentId, + primaryHasDesktopBrowser, + ); if (!isElectron) return null; return ( diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 7ca4a8a8f6995..b38b7509c2037 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,9 +42,12 @@ 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 ( @@ -51,13 +55,24 @@ export function rendersServerTabNatively( snapshot?.runtime === "server" && !snapshot.headless && primaryEnvironmentId !== null && - environmentId === primaryEnvironmentId + environmentId === primaryEnvironmentId && + primaryHasDesktopBrowser ); } export function useRendersServerTabNatively( - environmentId: EnvironmentId, + environmentId: EnvironmentId | null, snapshot: Pick | null | undefined, ): boolean { - return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + return ( + environmentId !== null && + rendersServerTabNatively( + environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ) + ); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 8e6c8fc42068a..59bab7366e662 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -23,8 +23,7 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { rendersServerTabNatively } from "~/browser/previewRuntime"; -import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; +import { useRendersServerTabNatively } from "~/browser/previewRuntime"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { closeServerPictureInPicture, @@ -138,11 +137,8 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st const fittedSourceContent = useBrowserSurfaceStore((state) => runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), ); - const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - const serverTab = - threadRef !== null && - snapshot?.runtime === "server" && - !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); + const nativeServerTab = useRendersServerTabNatively(threadRef?.environmentId ?? null, snapshot); + const serverTab = threadRef !== null && snapshot?.runtime === "server" && !nativeServerTab; const streamViewport = useStreamedSourceSize((state) => threadRef && tabId !== null && diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 51760393f92d9..2471a92de1f99 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -349,6 +349,21 @@ export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId ); } +// Older primary servers do not advertise this capability. Preserve their native rendering. +export function useEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + const config = environmentId === null ? undefined : configs.get(environmentId); + return config !== undefined && config.environment.capabilities.desktopBrowser !== false; +} + +export function readEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { + const config = + environmentId === null + ? undefined + : appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); + return config !== undefined && config.environment.capabilities.desktopBrowser !== false; +} + 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 5148aa2c1c82e..ab8f68a45a8db 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -30,6 +30,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), 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 1fa572ca662ae..cb6bca2fe0835 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -46,3 +46,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 d2eaccc0cd4ec..46948bf5ccd37 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -188,6 +188,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ triage: Schema.optionalKey(Schema.Boolean), /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), + /** Whether the launching desktop can reach the browser channel. Absent on older servers. */ + desktopBrowser: Schema.optionalKey(Schema.Boolean), /** Server honors `freshContext` on sends; older servers ignore it and keep the old context. */ freshContext: Schema.optionalKey(Schema.Boolean), /** Server saves and runs scheduled task `threadMode`; older servers ignore it. */ From 31399d7afc4aaa3de13585b5ba8f5e4b5de611c5 Mon Sep 17 00:00:00 2001 From: narongchai Date: Fri, 13 Nov 2026 20:55:58 +0700 Subject: [PATCH 2/3] fix(preview): preserve browser state across overlapping reconnects --- apps/desktop/src/preview/CdpRelay.test.ts | 51 ++++++ apps/desktop/src/preview/CdpRelay.ts | 28 +-- .../src/preview/DesktopBrowserChannel.test.ts | 159 +++++++++++++++--- .../src/preview/DesktopBrowserChannel.ts | 56 ++++-- .../ElectronBrowserHost.browser.test.tsx | 14 +- 5 files changed, 246 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 05becbfce4e95..f2b720a1828f8 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -332,6 +332,57 @@ describe("CDP relay downloads", () => { ]); }); + it("applies download settings in arrival order while a WSL path conversion waits", async () => { + const conversion = Promise.withResolvers(); + const completed = Promise.withResolvers(); + const send = vi.fn(async () => ({})); + const directories: Array = []; + const written: Array> = []; + const replies = new Set(); + const relay = makeRelay( + makeTarget( + send, + (directory) => directories.push(directory), + () => conversion.promise, + ), + (raw) => { + const message = JSON.parse(raw) as Record; + written.push(message); + if (message["id"] === 1 || message["id"] === 2) { + replies.add(message["id"]); + if (replies.size === 2) completed.resolve(); + } + }, + ); + relay.receive(JSON.stringify({ id: 0, method: "Target.setAutoAttach", params: {} })); + relay.receive( + JSON.stringify({ + id: 1, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath: "/mnt/c/artifacts", eventsEnabled: true }, + }), + ); + relay.receive( + JSON.stringify({ + id: 2, + method: "Browser.setDownloadBehavior", + params: { behavior: "deny", eventsEnabled: false }, + }), + ); + conversion.resolve("C:\\artifacts"); + await completed.promise; + + expect(send.mock.calls).toEqual([ + ["Page.setDownloadBehavior", { behavior: "default" }, undefined], + ["Page.setDownloadBehavior", { behavior: "deny" }, undefined], + ]); + expect(directories).toEqual(["C:\\artifacts", null]); + relay.event("Page.downloadWillBegin", { guid: "g1" }, ""); + expect(written.filter((message) => message["method"] === "Browser.downloadWillBegin")).toEqual( + [], + ); + }); + it("does not publish download events when the connection has disabled them", async () => { const written: Array> = []; const relay = makeRelay(makeTarget(vi.fn(async () => ({}))), (raw) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 0a3a6833d861c..e6eda06f5da98 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -101,6 +101,7 @@ export function createCdpRelayConnection( }; let attached = false; let downloadEventsEnabled = false; + let pendingDownloadBehavior = Promise.resolve(undefined); /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; @@ -123,17 +124,22 @@ 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"; - const downloadDirectory = allowed ? await target.serverPath(directory) : null; - // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download. - const result = await target.send( - "Page.setDownloadBehavior", - { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" }, - undefined, - ); - target.setDownloadDirectory(downloadDirectory); - downloadEventsEnabled = command.params?.["eventsEnabled"] === true; + // Path conversion must not let an earlier allow overtake a later deny. + const result = pendingDownloadBehavior.then(async () => { + const directory = command.params?.["downloadPath"]; + const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; + const downloadDirectory = allowed ? await target.serverPath(directory) : null; + // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download. + const result = await target.send( + "Page.setDownloadBehavior", + { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" }, + undefined, + ); + target.setDownloadDirectory(downloadDirectory); + downloadEventsEnabled = command.params?.["eventsEnabled"] === true; + return result; + }); + pendingDownloadBehavior = result.catch(() => undefined); return result; } case "Browser.getVersion": diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts index 477f20961d4ac..eca15ae41554f 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -1,13 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as NetAddress from "effect/net/NetAddress"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Socket from "effect/socket/Socket"; +import type * as SocketServer from "effect/socket/SocketServer"; import * as Stream from "effect/Stream"; import { vi } from "vite-plus/test"; @@ -15,6 +19,10 @@ import * as ServerConfig from "../config.ts"; import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; vi.mock("@effect/platform-node/NodeStream", () => ({ fromReadable: vi.fn() })); +vi.mock("@effect/platform-node/NodeSocketServer", async (importOriginal) => { + const original = await importOriginal(); + return { makeWebSocket: vi.fn(original.makeWebSocket) }; +}); vi.mock("node:net", () => ({ Socket: class { destroy() {} @@ -131,31 +139,35 @@ const layer = DesktopBrowserChannel.layer.pipe( 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 fakeDesktop = (onClose: Effect.Effect = Effect.void) => + 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)).pipe( + Effect.andThen(chunk instanceof Socket.CloseEvent ? onClose : Effect.void), + ), + 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 }; }); - 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", () => @@ -172,7 +184,7 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { it.effect("carries tabs and commands until the desktop disconnects", () => Effect.gen(function* () { const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; - const desktop = yield* fakeDesktop; + const desktop = yield* fakeDesktop(); const loadFailure = { url: "https://missing.example/", code: -105, @@ -214,8 +226,8 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { 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; + const stale = yield* fakeDesktop(); + const fresh = yield* fakeDesktop(); yield* channel.turn(key.threadId, true); yield* Queue.offer(stale.frames, attached); const staleSession = yield* Effect.scoped(channel.serveSocket(stale.socket)).pipe( @@ -263,4 +275,97 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { yield* Fiber.interrupt(freshSession); }), ); + + it.effect("keeps the newest desktop attached when an older replacement finishes closing", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const closeStarted = yield* Deferred.make(); + const resumeClose = yield* Deferred.make(); + const stale = yield* fakeDesktop( + Deferred.succeed(closeStarted, undefined).pipe(Effect.andThen(Deferred.await(resumeClose))), + ); + const middle = 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); + const middleSession = yield* Effect.scoped(channel.serveSocket(middle.socket)).pipe( + Effect.forkChild, + ); + yield* Deferred.await(closeStarted); + const freshSession = yield* Effect.scoped(channel.serveSocket(fresh.socket)).pipe( + Effect.forkChild, + ); + yield* Queue.offer( + fresh.frames, + [attached, JSON.stringify({ type: "shown", ...key, shown: false })].join("\n"), + ); + yield* fresh.awaitPulls(2); + expect(yield* channel.isAttached(key)).toBe(true); + expect(channel.isShown(key)).toBe(false); + + yield* Deferred.succeed(resumeClose, undefined); + yield* Effect.raceFirst(middle.awaitPulls(1), Fiber.join(middleSession)); + expect(yield* channel.isAttached(key)).toBe(true); + expect(channel.isShown(key)).toBe(false); + yield* Fiber.interrupt(middleSession); + expect(yield* channel.isAttached(key)).toBe(true); + yield* Fiber.interrupt(staleSession); + yield* Fiber.interrupt(freshSession); + }), + ); + + it.effect("binds a relay opened across reconnect to the replacement desktop", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const stale = yield* fakeDesktop(); + const fresh = yield* fakeDesktop(); + const relay = yield* fakeDesktop(); + const opening = yield* Deferred.make(); + const resumeOpening = yield* Deferred.make(); + const relayServer: SocketServer.SocketServer["Service"] = { + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 1234), + run: (handler) => handler(relay.socket).pipe(Effect.orDie, Effect.andThen(Effect.never)), + }; + using _relayServer = vi + .spyOn(NodeSocketServer, "makeWebSocket") + .mockReturnValue( + Deferred.succeed(opening, undefined).pipe( + Effect.andThen(Deferred.await(resumeOpening)), + Effect.as(relayServer), + ), + ); + yield* Queue.offer(stale.frames, attached); + const staleSession = yield* Effect.scoped(channel.serveSocket(stale.socket)).pipe( + Effect.forkChild, + ); + yield* stale.awaitPulls(2); + const endpointScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const endpoint = yield* channel + .endpoint(key) + .pipe(Scope.provide(endpointScope), Effect.forkChild); + yield* Deferred.await(opening); + yield* Queue.offer(fresh.frames, attached); + const freshSession = yield* Effect.scoped(channel.serveSocket(fresh.socket)).pipe( + Effect.forkChild, + ); + yield* fresh.awaitPulls(2); + yield* Deferred.succeed(resumeOpening, undefined); + yield* Fiber.join(endpoint); + yield* relay.awaitPulls(1); + const message = JSON.stringify({ id: 1, method: "Page.enable" }); + yield* Queue.offer(relay.frames, message); + yield* relay.awaitPulls(1); + expect(fresh.commands()).toContainEqual({ type: "cdp", ...key, message }); + expect(stale.commands()).not.toContainEqual({ type: "cdp", ...key, message }); + yield* Scope.close(endpointScope, Exit.void); + expect(fresh.commands()).toContainEqual({ type: "release", ...key }); + 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 e1b732590edaf..f172cf46fc212 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -182,22 +182,31 @@ const make = Effect.gen(function* () { } }; - const detach = (id: string, key: DesktopTabKey) => { + const detach = (id: string, key: DesktopTabKey, isCurrent = () => true) => { attachedTabs.delete(id); shownTabs.delete(id); loadFailures.delete(id); const queue = inbound.get(id); return (queue ? Queue.shutdown(queue) : Effect.void).pipe( - Effect.andThen(PubSub.publish(changes, { key, attached: false })), + Effect.andThen(() => + isCurrent() ? PubSub.publish(changes, { key, attached: false }) : Effect.void, + ), Effect.asVoid, ); }; - const detachAll = Effect.suspend(() => { - shownTabs.clear(); - loadFailures.clear(); - return Effect.forEach([...attachedTabs], ([id, key]) => detach(id, key), { discard: true }); - }); + const detachAll = (isCurrent: () => boolean) => + Effect.suspend(() => { + if (!isCurrent()) return Effect.void; + shownTabs.clear(); + loadFailures.clear(); + return Effect.forEach( + [...attachedTabs], + ([id, key]) => + Effect.suspend(() => (isCurrent() ? detach(id, key, isCurrent) : Effect.void)), + { discard: true }, + ); + }); if (inputFd !== undefined && controlFd !== undefined) { // Socket reads can be cancelled while the desktop keeps its write end open. @@ -239,26 +248,37 @@ const make = Effect.gen(function* () { const connection = { close: writer.write(new Socket.CloseEvent(1000, "replaced")).pipe(Effect.ignore), }; + const isCurrent = () => currentSocket === connection; 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, + Effect.suspend(() => { + if (!isCurrent()) return Effect.void; + writeLine = null; + return detachAll(isCurrent).pipe( + Effect.andThen( + Effect.sync(() => { + if (isCurrent()) currentSocket = null; + }), + ), + ); + }), ); + if (replaced !== null) yield* replaced.close; + // Closing the replaced socket can yield while a newer desktop takes ownership. + if (!isCurrent()) return; + yield* detachAll(isCurrent); + if (!isCurrent()) return; 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.ignore), + : decodeEventLine(line).pipe( + Effect.flatMap((event) => (isCurrent() ? receive(event) : Effect.void)), + Effect.ignore, + ), { discard: true }, ); return yield* reader.pull.pipe(Effect.flatMap(receiveFrames), Effect.forever); @@ -267,7 +287,6 @@ const make = Effect.gen(function* () { ); const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) { - const channelWriter = writeLine; const id = keyOf(key); const secret = NodeCrypto.randomBytes(24).toString("base64url"); const server = yield* NodeSocketServer.makeWebSocket({ @@ -282,6 +301,7 @@ const make = Effect.gen(function* () { inbound.delete(id); return yield* Effect.die("The desktop tab detached before the server connected."); } + const channelWriter = writeLine; yield* Effect.addFinalizer(() => Effect.gen(function* () { if (inbound.get(id) === queue) inbound.delete(id); diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 42bc0fc574551..f63fbc6102944 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -200,12 +200,14 @@ describe("ElectronBrowserHost", () => { }); 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); + await vi.waitFor(() => { + const host = screen.container.querySelector("[data-electron-browser-host]")!; + expect( + [...host.querySelectorAll("webview")].map((node) => + node.getAttribute("data-preview-server-tab"), + ), + ).toEqual(mounted); + }); }, ); }); From a344ae5cc813c3d1684650913e7560deda460398 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:59:05 +0000 Subject: [PATCH 3/3] fix(desktop): convert WSL paths without the distro shell wslToWindowsPath now runs wslpath through `--exec`, matching windowsToWslPathImpl on main, so `$`, `&` or `;` in an upload or download path is not expanded by the distro shell. Trims redundant test rows. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/CdpRelay.test.ts | 6 ------ apps/desktop/src/wsl/DesktopWslEnvironment.test.ts | 7 +------ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 2 +- apps/web/src/browser/ElectronBrowserHost.browser.test.tsx | 1 - 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index d31a446b5ef93..7c17c603a30cb 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -220,12 +220,6 @@ describe("CDP relay downloads", () => { windowsDownloads: "\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts", windowsDirectory: "\\\\wsl.localhost\\Ubuntu\\home\\user\\assets", }, - { - downloadPath: "/mnt/c/Users/user/My Downloads", - directoryPath: "/windows/Users/user/My Assets", - windowsDownloads: "C:\\Users\\user\\My Downloads", - windowsDirectory: "C:\\Users\\user\\My Assets", - }, ])( "awaits WSL path conversion for downloads and directory uploads ($directoryPath)", async ({ downloadPath, directoryPath, windowsDownloads, windowsDirectory }) => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 80ad0d065a9e5..43b9a7273894c 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -586,11 +586,6 @@ describe("wslToWindowsPath", () => { "C:\\Users\\test user's assets \r\n", "C:\\Users\\test user's assets ", ], - [ - "/home/test/assets", - "\\\\wsl.localhost\\Test Distro\\home\\test\\assets\n", - "\\\\wsl.localhost\\Test Distro\\home\\test\\assets", - ], ] as const)( "uses the selected distro's mount resolution for %s", ([linuxPath, output, expected]) => @@ -608,7 +603,7 @@ describe("wslToWindowsPath", () => { assert.equal(spawned._tag, "StandardCommand"); if (spawned._tag !== "StandardCommand") return; assert.equal(spawned.command, "wsl.exe"); - assert.deepEqual(spawned.args, ["-d", "Test Distro", "--", "wslpath", "-w", linuxPath]); + assert.deepEqual(spawned.args, ["-d", "Test Distro", "--exec", "wslpath", "-w", linuxPath]); }), ); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ffdbdf24c3a05..83a5942467737 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1098,7 +1098,7 @@ export const wslToWindowsPath = Effect.fn("desktop.wsl.wslToWindowsPath")(functi const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "wslpath", "-w", linuxPath], + [...buildDistroArgs(distro), "--exec", "wslpath", "-w", linuxPath], { stdin: "ignore", stdout: "pipe", diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index f63fbc6102944..0ee18d69939eb 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -186,7 +186,6 @@ describe("ElectronBrowserHost", () => { it.each([ { primaryHasDesktopBrowser: true, headless: false, mounted: ["tab_1"] }, { primaryHasDesktopBrowser: false, headless: false, mounted: [] }, - { primaryHasDesktopBrowser: true, headless: true, mounted: [] }, ])( "gates native server tabs (channel=$primaryHasDesktopBrowser, headless=$headless)", async ({ primaryHasDesktopBrowser, headless, mounted }) => {