diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 33f59497eb2052dcba475bd1d6d3b4cf2d275748..d9b21038cf71ec5139f45c9480a03b824479bdc4 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ Schema.Struct({ type: Schema.Literal("terminal.cleanup"), }), + /** Closes a deleted thread's preview sessions, ending their server browser tabs. */ + Schema.Struct({ + type: Schema.Literal("preview.cleanup"), + }), Schema.Struct({ type: Schema.Literal("attachment.cleanup"), attachmentIds: Schema.Array(Schema.String), @@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "provider-thread.rollback", "checkpoint.capture", "terminal.cleanup", + "preview.cleanup", "attachment.cleanup", "thread-title.generate", "delegated-tasks.stop", diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index b3ee780334d33734735be77b764455d2ca9c081b..9ae94910b7c8d5d287ee27cd19f7120193f7ca8d 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer< }), ), ); + case "preview.cleanup": + return resourceCleanup.cleanupPreviews(effect.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); case "attachment.cleanup": return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index 117d05466fb6ddadff468b9f56bd2435f6aae035..13310b7818923650fd698defefab34c347f096aa 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -6,12 +7,13 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "attachment"]), + operation: Schema.Literals(["terminal", "preview", "attachment"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError Effect.Effect; + /** Closes every preview session of the thread; server browser tabs end with them. */ + readonly cleanupPreviews: (threadId: string) => Effect.Effect; readonly cleanupAttachments: ( attachmentIds: ReadonlyArray, ) => Effect.Effect; }>("t3/orchestration-v2/ResourceCleanupService", { defaultValue: () => ({ cleanupTerminals: () => Effect.void, + cleanupPreviews: () => Effect.void, cleanupAttachments: () => Effect.void, }), }) {} @@ -34,6 +39,7 @@ export const layer = Layer.effect( ResourceCleanupService, Effect.gen(function* () { const terminals = yield* TerminalManager.TerminalManager; + const previews = yield* PreviewManager.PreviewManager; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; return { @@ -45,6 +51,14 @@ export const layer = Layer.effect( (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }), ), ), + cleanupPreviews: (threadId: string) => + previews + .close({ threadId: ThreadId.make(threadId) }) + .pipe( + Effect.mapError( + (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }), + ), + ), cleanupAttachments: (attachmentIds: ReadonlyArray) => Effect.forEach( attachmentIds, diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index 3a502e9e1b96e49157122d963ecdba5eddfd56ff..da24f247f9021f01578ecd96d32fb384d72c3127 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio revokeMcpCredential: true, }, { type: "terminal.cleanup" }, + { type: "preview.cleanup" }, { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, ], ); diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 077aa5726bd09ea2a8b53acf269794bf2b8101b1..fca9c05ae3e7c8b426145e3415f3fcb53ef64dc9 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") threadId: command.threadId, request: { type: "terminal.cleanup" }, }); + effects.push({ + id: `effect:${command.commandId}:preview.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "preview.cleanup" }, + }); const attachmentIds = Array.from(new Set(input.attachmentIds)); if (attachmentIds.length > 0) { effects.push({ diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 3c3094f3aa60fde00b0f1fac09b08fbab494a145..99f89f3544c362a8008b8ab68bf6c5ce9ad064f8 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect; /** Desktop tabs as they detach. */ readonly detached: Stream.Stream; + /** Desktop tabs as they attach, including a tab coming back after its DevTools close. */ + readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; /** * A one-connection CDP endpoint for an attached tab. Closing the scope @@ -83,6 +85,7 @@ const make = Effect.gen(function* () { available: false, awaitAttached: () => Effect.succeed(false), detached: Stream.empty, + attached: Stream.empty, isAttached: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => Effect.void, @@ -216,6 +219,10 @@ const make = Effect.gen(function* () { Stream.filter((change) => !change.attached), Stream.map((change) => change.key), ), + attached: Stream.fromPubSub(changes).pipe( + Stream.filter((change) => change.attached), + Stream.map((change) => change.key), + ), isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), endpoint, pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 7ca1097254853535615f63e6bc25cfb99fb09c3c..9420e07a5a3a7b31fd46b14b81058d7a73986edb 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { title: input.navStatus.title, code: input.navStatus.code, description: input.navStatus.description, + ...(input.navStatus.download === undefined + ? {} + : { download: input.navStatus.download }), } : { type: "navigated", diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 46d3750e9335b7e4d9451419d077089b4fa9db82..fa4fd4402e9391127cd9d30489b810fd8f06b4f9 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; @@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, { findAvailablePort: (preferred) => Effect.succeed(preferred), }); +/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +const layerNoProc = FileSystem.layerNoop({}); + const layerProbeFailure = ( run: ProcessRunner.ProcessRunner["Service"]["run"], fetch: typeof globalThis.fetch = globalThis.fetch, + fileSystem: Layer.Layer = layerNoProc, ) => PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -77,6 +83,7 @@ const layerProbeFailure = ( const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + layerNoProc, layerTestProcessRunner, layerTestIntegrationNet, Layer.succeed(HostProcessPlatform, "win32"), @@ -94,6 +101,7 @@ const layerLsofScanner = (input: { PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ @@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () => }), ); +// /proc/net/tcp: 127.0.0.1:8765 and 0.0.0.0:22 listening, an established +// connection, and a non-loopback listener; /proc/net/tcp6 adds [::]:3001. +const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0 + 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:223D 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5003 1 0000000000000000 20 4 30 10 -1 + 3: 0A00000F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5005 1 0000000000000000 100 0 0 10 0 +`; +const PROC_NET_TCP6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:0BB9 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5004 1 0000000000000000 100 0 0 10 0 +`; + +effectIt.effect( + "reads Linux listeners from /proc when lsof is missing, and stops spawning it", + () => { + let lsofSpawns = 0; + const fdWalks: string[] = []; + const procFiles: Record = { + "/proc/net/tcp": PROC_NET_TCP, + "/proc/net/tcp6": PROC_NET_TCP6, + "/proc/4242/comm": "python3\n", + }; + const procFds: Record> = { + "4242": { "0": "/dev/null", "3": "socket:[5001]" }, + "77": { "5": "socket:[9999]" }, + }; + const missing = FileSystem.makeNoop({}); + const fileSystem = FileSystem.layerNoop({ + readFileString: (path) => { + const content = procFiles[path]; + return content === undefined ? missing.readFileString(path) : Effect.succeed(content); + }, + readDirectory: (path) => { + if (path === "/proc") return Effect.succeed(["self", "77", "4242"]); + const pid = /^\/proc\/(\d+)\/fd$/.exec(path)?.[1]; + const fds = pid === undefined ? undefined : procFds[pid]; + if (pid === undefined || fds === undefined) return missing.readDirectory(path); + fdWalks.push(pid); + return Effect.succeed(Object.keys(fds)); + }, + readLink: (path) => { + const [, pid, fd] = /^\/proc\/(\d+)\/fd\/(\d+)$/.exec(path) ?? []; + const target = pid && fd ? procFds[pid]?.[fd] : undefined; + return target === undefined ? missing.readLink(path) : Effect.succeed(target); + }, + }); + const fetchFn = (() => + Promise.resolve( + new Response("hello", { headers: { "content-type": "text/html" } }), + )) as typeof globalThis.fetch; + const layer = layerProbeFailure( + (input) => { + lsofSpawns += 1; + return processProbeFailure(input); + }, + fetchFn, + fileSystem, + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const first = yield* scanner.scan(); + expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ + { port: 22, pid: null, processName: null }, + { port: 3001, pid: null, processName: null }, + { port: 8765, pid: 4242, processName: "python3" }, + ]); + yield* scanner.scan(); + expect(lsofSpawns).toBe(1); + // Ports 22 and 3001 have no readable owner, so the second scan walks + // again; it stops at 77, which holds no listener, once nothing is left. + expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); + yield* scanner.scan(); + yield* scanner.scan(); + // Unresolved owners are retried a bounded number of times. + expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => { + let lsofSpawns = 0; + const layer = layerProbeFailure((input) => { + lsofSpawns += 1; + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "EAGAIN", + }), + }), + ); + }); + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.scan(); + yield* scanner.scan(); + expect(lsofSpawns).toBe(2); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect("does not swallow process probe interruption", () => Effect.gen(function* () { const layer = layerProbeFailure(() => Effect.interrupt); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 6fb25077df3a5ef0210c6d9551e715b9d412c434..eb573def97e08cf10ec04df30b71d57f4027ce3f 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,8 +5,11 @@ * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * - * Windows / lsof missing: checks a curated list of common dev ports through - * the shared Net service. + * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and + * finds their processes through `/proc//fd`. + * + * Windows listener failures, or neither source: checks a curated list of + * common dev ports through the shared Net service. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -30,8 +33,10 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; 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 Scope from "effect/Scope"; @@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; +/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ +const PROC_FD_WALK_LIMIT = 50_000; +/** Scans that retry the fd walk for a listening socket whose owner was not found. */ +const SOCKET_OWNER_ATTEMPTS = 3; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); @@ -265,6 +274,42 @@ const parseWindowsListenerOutput = ( return [...seen.values()].toSorted((left, right) => left.port - right.port); }; +/** `/proc/net/tcp` state for a listening socket. */ +const PROC_TCP_LISTEN = "0A"; + +/** + * Whether a `/proc/net/tcp{,6}` address is one the preview can reach on + * loopback: the wildcard or a loopback address, as `lsof` parsing accepts. + * Each 32-bit word of the hex address is little-endian. + */ +const procLocalAddress = (hex: string): boolean => { + if (hex.length === 8) { + return hex === "00000000" || hex.endsWith("7F"); + } + if (hex.length !== 32) return false; + if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true; + // IPv4-mapped (::ffff:a.b.c.d): wildcard or 127.0.0.0/8. + return hex.startsWith(`${"0".repeat(16)}FFFF0000`) && procLocalAddress(hex.slice(24)); +}; + +/** Listening loopback ports in `/proc/net/tcp{,6}` content, with their socket inodes. */ +const parseProcNetTcp = ( + raw: string, +): ReadonlyArray<{ readonly port: number; readonly inode: string }> => { + const listeners: Array<{ port: number; inode: string }> = []; + for (const line of raw.split("\n").slice(1)) { + const fields = line.trim().split(/\s+/); + const [address, portHex] = fields[1]?.split(":") ?? []; + if (fields[3] !== PROC_TCP_LISTEN || !address || !portHex) continue; + if (!procLocalAddress(address.toUpperCase())) continue; + const port = Number.parseInt(portHex, 16); + const inode = fields[9]; + if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !inode) continue; + listeners.push({ port, inode }); + } + return listeners; +}; + const serversEqual = ( left: ReadonlyArray, right: ReadonlyArray, @@ -289,10 +334,14 @@ const serversEqual = ( return true; }; +const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => + PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ @@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }); const webProbeCacheRef = yield* Ref.make>(new Map()); const scanSemaphore = yield* Semaphore.make(1); + const lsofMissingRef = yield* Ref.make(false); + /** Confirmed owners of listening socket inodes. */ + const socketOwnersRef = yield* Ref.make< + ReadonlyMap + >(new Map()); + /** Fd walks that found no owner, per socket inode; retried up to `SOCKET_OWNER_ATTEMPTS`. */ + const socketOwnerMissesRef = yield* Ref.make>(new Map()); + + /** Maps socket inodes to the processes holding them, by `/proc//fd`. */ + const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* ( + inodes: ReadonlySet, + ) { + const owners = new Map(); + const pids = (yield* fileSystem + .readDirectory("/proc") + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).filter((entry) => + /^\d+$/.test(entry), + ); + let budget = PROC_FD_WALK_LIMIT; + for (const pidText of pids) { + if (owners.size === inodes.size || budget <= 0) break; + // Other users' fds are unreadable; their ports still list, without a pid. + const fds = yield* fileSystem + .readDirectory(`/proc/${pidText}/fd`) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const walked = fds.slice(0, budget); + budget -= walked.length; + const links = yield* Effect.forEach( + walked, + (fd) => + fileSystem.readLink(`/proc/${pidText}/fd/${fd}`).pipe(Effect.orElseSucceed(() => "")), + { concurrency: 16 }, + ); + const held = links.flatMap((link) => /^socket:\[(\d+)\]$/.exec(link)?.[1] ?? []); + const matched = held.filter((inode) => inodes.has(inode) && !owners.has(inode)); + if (matched.length === 0) continue; + const processName = yield* fileSystem.readFileString(`/proc/${pidText}/comm`).pipe( + Effect.map((name) => name.trim() || null), + Effect.orElseSucceed(() => null), + ); + for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName }); + } + return owners; + }); + + /** + * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null + * when neither file is readable, so discovery falls back to common ports. + */ + const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( + terminalByProcessId: ReadonlyMap, + ) { + if (hostPlatform !== "linux") return null; + const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) => + fileSystem.readFileString(path).pipe(Effect.option), + ); + if (tables.every(Option.isNone)) return null; + const listeners = tables.flatMap((table) => + Option.isSome(table) ? parseProcNetTcp(table.value) : [], + ); + const known = yield* Ref.get(socketOwnersRef); + const knownMisses = yield* Ref.get(socketOwnerMissesRef); + const inodes = new Set(listeners.map((listener) => listener.inode)); + // Listeners rarely change, so the fd walk runs only for sockets without a + // confirmed owner, and gives up on one after a few walks miss it (another + // user's process, or one that exited mid-walk). + const unseen = new Set( + [...inodes].filter( + (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS, + ), + ); + const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen); + const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode))); + const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode))); + for (const inode of unseen) { + if (!found.has(inode)) misses.set(inode, (misses.get(inode) ?? 0) + 1); + } + yield* Ref.set(socketOwnersRef, owners); + yield* Ref.set(socketOwnerMissesRef, misses); + const seen = new Map(); + for (const { port, inode } of listeners) { + const owner = owners.get(inode) ?? null; + const existing = seen.get(port); + if (existing && (existing.pid !== null || owner === null)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: owner?.processName ?? null, + pid: owner?.pid ?? null, + terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); + }); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( @@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); + if (yield* Ref.get(lsofMissingRef)) { + const fromProc = yield* scanProcListeners(terminalByProcessId); + return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + } const lsofResult = yield* processRunner .run({ command: "lsof", @@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), Effect.catchTags({ - ProcessSpawnError: recoverLsofProbeFailure, + // A missing lsof stays missing; later scans skip straight to the fallback. + ProcessSpawnError: (error) => + (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe( + Effect.andThen(recoverLsofProbeFailure(error)), + ), ProcessStdinError: recoverLsofProbeFailure, ProcessOutputLimitError: recoverLsofProbeFailure, ProcessReadError: recoverLsofProbeFailure, @@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + const fromProc = yield* scanProcListeners(terminalByProcessId); + return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 4c42e37ebb71274d070346e675b0343a96dc9528..8fcfddff5cb5dde28c59ffbc3e069d19a2e9e6da 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) const SANDBOX_ABORT = "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/"; -const lddReporting = (stdout: string) => { +// What the real loader prints when the browser's `--version` cannot start it. +const LOADER_ERROR = + "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; + +const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { const commands: Array = []; const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { @@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(stdout)), + stdout: Stream.empty, stderr: Stream.empty, - all: Stream.empty, + all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)), getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); @@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => { return { spawner, commands }; }; -const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => { - const { spawner, commands } = lddReporting(input.ldd); +const diagnose = (input: { + platform: NodeJS.Platform; + output: string; + ldd: string; + version?: string; +}) => { + const { spawner, commands } = hostReporting({ + ldd: input.ldd, + version: input.version ?? LOADER_ERROR, + }); return PreviewBrowserHost.diagnoseLaunchFailure({ executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", output: input.output, @@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => { }), ); + it.effect("trusts the browser's own start over ldd", () => + Effect.gen(function* () { + // NixOS: ldd lists libraries that nix-ld still provides to the real loader. + const { error } = yield* diagnose({ + platform: "linux", + output: "crashed", + ldd: LDD_IN_BARE_DEBIAN, + version: "Google Chrome for Testing 154.0.8037.92\n", + }); + expect(error).toBeUndefined(); + }), + ); + it.effect("leaves other failures alone", () => Effect.gen(function* () { const linux = yield* diagnose({ platform: "linux", output: "crashed", ldd: "\tlibc.so.6 => /lib/libc.so.6\n", + version: "Google Chrome for Testing 154.0.8037.92\n", }); expect(linux.error).toBeUndefined(); const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 0b5d0d204a97495f1f98582c8b06fe4a49695cd8..9ea56c4fa18bdde8b4790a8151156fda9cb70c1f 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError not found$/gm; +/** glibc's loader abort, naming the first library it could not load. */ +const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/; /** * After a launch fails, names the host setup it is missing: the sandbox, from - * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when - * neither explains it. Linux only; other hosts never need either. + * Chrome's own abort message, or shared libraries the loader refuses. Undefined + * when neither explains it. Linux only; other hosts never need either. */ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")( function* (input: { @@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc }, ); -/** Shared libraries the loader cannot find for `executable`, by `ldd`. */ +/** + * Shared libraries the loader cannot find for `executable`. The browser's own + * `--version` decides whether any are missing; `ldd` only lists them after the + * real loader fails, because it can report libraries a host provides another + * way (NixOS's nix-ld). + */ export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( executable: string, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const report = yield* spawner - .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" })) - .pipe( - Effect.timeout("5 seconds"), - Effect.orElseSucceed(() => ""), - ); - return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!); + const run = (command: string, args: ReadonlyArray) => + spawner + .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true }) + .pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => ""), + ); + const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1]; + if (loaderError === undefined) return []; + const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map( + (match) => match[1]!, + ); + return listed.length > 0 ? listed : [loaderError]; }); /** diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index dce0c23b96cf8c5264ab6ad7a0e9752fe6c102aa..45a2167ca0420b10900821a5e629065efbdf4d3a 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import type { BrowserContext, Page } from "playwright-core"; @@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. vi.mock("./ServerBrowserContexts.ts", () => ({ + presentAsChrome: async () => {}, ServerBrowserContexts: class { private readonly onClose: ((context: BrowserContext) => void) | undefined; constructor(options: { onContextClose?: (context: BrowserContext) => void }) { @@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; -/** Pages the fake desktop takes back; the channel's detached stream emits them. */ +/** Pages the fake desktop takes back or returns; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { @@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll( (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), ), ), + attached: Stream.callback<{ threadId: string; tabId: string }>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (key: { threadId: string; tabId: string }) => + Queue.offerUnsafe(queue, key); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => @@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () => ).pipe(Effect.provide(layer)), ); +it.live("a tab opened to a file the browser cannot show reports the file to download", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true }; + // What Chromium reports when a navigation turns into a download. + page.emit("requestfailed", { + ...request, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }); + // Stands in for Chromium, which writes the file itself. + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + written.resolve(); + let status = yield* PubSub.take(events); + while (status.type !== "failed") status = yield* PubSub.take(events); + expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } }); + // The tab shows the file, so no separate download toast is offered. + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( + false, + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const navigation = { + url: () => pdf, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + page.emit("request", navigation); + page.emit("requestfailed", navigation); + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + // The person navigates elsewhere while the file is still being written. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + written.resolve(); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); + expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a download from a superseded navigation leaves the newer navigation loading", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const navigation = { + url: () => pdf, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + page.emit("request", navigation); + page.emit("requestfailed", navigation); + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + // The person navigates elsewhere while the file is still being written. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + written.resolve(); + const status = () => + broker.invoke({ scope, tabId, operation: "status", input: {} }); + while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis"); + expect((yield* status()).loading).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live( + "a late failure from a request that predates tracking leaves a newer navigation loading", + () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const navigation = (url: string) => ({ + url: () => url, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }), + }); + page.emit("request", navigation("https://example.com/next")); + // A popup's first request can start before the tab listens for requests. + page.emit("requestfailed", navigation("https://example.com/first")); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.loading).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an aborted navigation that no download explains stops loading", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const navigation = { + url: () => "https://example.com/cancelled", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + const status = () => + broker.invoke({ scope, tabId, operation: "status", input: {} }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + page.emit("request", navigation); + page.emit("requestfailed", navigation); + expect((yield* status()).loading).toBe(true); + vi.advanceTimersByTime(5_000); + } finally { + vi.useRealTimers(); + } + expect((yield* status()).loading).toBe(false); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a popup from a person's click is shown to that person, with its opener kept", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const opener = contexts[0]!.page; + const popup = makeContext(); + opener.emit("popup", popup.page); + let shown = yield* Queue.take(viewer.output); + while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); + const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; + expect(sessions.map((session) => session.tabId)).toEqual( + expect.arrayContaining([opened.tabId, shown.tabId]), + ); + expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe( + false, + ); + expect(opener.close).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => Effect.scoped( Effect.gen(function* () { @@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( }), ).pipe(Effect.provide(layer)), ); + +it.live("a desktop page that comes back reconnects without waiting for a viewer", () => + Effect.scoped( + Effect.gen(function* () { + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + while (desktopConnections.length === 0) yield* Effect.yieldNow; + while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + // DevTools opened, then closed: the desktop withdraws the page and returns it. + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow; + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients. + while (desktopConnections.length < 2) yield* Effect.yieldNow; + expect(desktopConnections).toHaveLength(2); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 42dc916abb61c8096daa8fe8bef7d546f9b2e98f..7ca2e1ffba8e00a66909a6a5e51272aa5f331176 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -36,7 +36,11 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, type PreviewAppearancePreference, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import * as NodeCrypto from "node:crypto"; @@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; import * as PreviewBrowser from "./PreviewBrowser.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; -import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; +import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; @@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput = readonly accept: string; } | { readonly _tag: "fileChooserClosed"; readonly id: string } + /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */ + | { readonly _tag: "popup"; readonly tabId: string } | { readonly _tag: "download"; readonly id: string; @@ -311,6 +317,17 @@ interface ServerTab { closing: boolean; recording: Recording | null; initialNavigation: Promise | null; + /** Counts main-frame navigation requests, so late events can tell they were superseded. */ + navigationGeneration: number; + /** + * The latest main-frame navigation, when it aborted, to tell a navigation's + * download from a page's. The timer settles it if no download follows. + */ + abortedNavigation: { + readonly url: string; + readonly generation: number; + readonly timer: ReturnType; + } | null; /** The latest queued start, so a stop can find a recording still starting. */ recordingStart: Promise | null; /** Serializes captures and recording start/stop. */ @@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; const IDLE_SWEEP_INTERVAL = "1 minute"; const DOWNLOAD_LIMIT = 20; +/** How long an aborted navigation waits for the download that usually explains it. */ +const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; @@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => { const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture }; const manager = yield* PreviewManager.PreviewManager; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const environment = yield* ServerEnvironment.ServerEnvironment; @@ -646,6 +666,7 @@ const make = Effect.gen(function* () { if (tabs.get(key) !== tab) return; tabs.delete(key); tab.closing = true; + clearAbortedNavigation(tab); // A desktop page outlives the connection unless its session closed with it. const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; for (const viewer of tab.viewers) viewer.push({ _tag: end }); @@ -723,6 +744,9 @@ const make = Effect.gen(function* () { if (!desktop) await prepareContext(context); const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); const cdp = await context.newCDPSession(page); + // The desktop's page is already a normal browser. A popup's first request + // went out before it became a tab; everything after presents as Chrome. + if (!desktop) await presentAsChrome(cdp, host); page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); const control = new SessionControl(snapshot.automationOwner ?? null, () => @@ -755,6 +779,8 @@ const make = Effect.gen(function* () { recording: null, recordingStart: null, initialNavigation: null, + navigationGeneration: 0, + abortedNavigation: null, captureLock: Promise.resolve(), capturing: 0, }; @@ -764,8 +790,11 @@ const make = Effect.gen(function* () { await applyRendering(tab, snapshot); const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); + const navigationGenerations = new WeakMap(); page.on("request", (request) => { if (!isMainNavigation(request)) return; + navigationGenerations.set(request, ++tab.navigationGeneration); + clearAbortedNavigation(tab); tab.loading = true; report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); }); @@ -787,7 +816,28 @@ const make = Effect.gen(function* () { errorText, timestamp: new Date().toISOString(), }); - if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return; + if (!isMainNavigation(request)) return; + // A navigation a newer one replaced leaves the status to the newer one. A + // popup's first request can predate the listener, so it is untracked and + // superseded by any tracked one. + const generation = navigationGenerations.get(request) ?? 0; + if (generation !== tab.navigationGeneration) return; + // An aborted navigation is usually a download; its `download` event settles the status. + if (errorText.includes("ERR_ABORTED")) { + const url = request.url(); + const navigationGeneration = tab.navigationGeneration; + clearAbortedNavigation(tab); + tab.abortedNavigation = { + url, + generation: navigationGeneration, + timer: setTimeout(() => { + if (tab.abortedNavigation?.generation !== navigationGeneration) return; + tab.abortedNavigation = null; + if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined); + }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS), + }; + return; + } tab.loading = false; const { code, description } = ServerBrowserPage.parseNetError(errorText); report(tab, { @@ -854,9 +904,46 @@ const make = Effect.gen(function* () { NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), ); + const clearAbortedNavigation = (tab: ServerTab) => { + if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer); + tab.abortedNavigation = null; + }; + + /** + * Settles the status of a navigation that aborted, usually into a download. + * A tab opened straight to the file has no page to show, so it reports the + * file with its download; a page that linked to it keeps showing itself. + */ + const settleAbortedNavigation = ( + tab: ServerTab, + generation: number, + url: string, + offered: { readonly id: string; readonly fileName: string } | undefined, + ) => { + // A newer navigation owns the status. + if (tab.navigationGeneration !== generation) return; + tab.loading = false; + if (tab.page.url() !== "about:blank") { + void reportLoaded(tab); + return; + } + report(tab, { + _tag: "LoadFailed", + url: url.slice(0, 2048), + title: "", + code: -3, + description: "ERR_ABORTED", + ...(offered === undefined ? {} : { download: offered }), + }); + }; + const saveDownload = async (tab: ServerTab, download: Download) => { const id = NodeCrypto.randomUUID(); const path = NodePath.join(downloadDir(tab), id); + const navigation = + download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null; + if (navigation !== null) clearAbortedNavigation(tab); + let offered: { readonly id: string; readonly fileName: string } | undefined; try { if (await download.failure()) return; await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); @@ -878,6 +965,12 @@ const make = Effect.gen(function* () { for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); } + offered = { id, fileName: saved.fileName }; + // A tab whose own address was the file shows it in place (see + // settleAbortedNavigation), unless it navigated on while the file saved. + const shownInTab = + navigation === tab.navigationGeneration && tab.page.url() === "about:blank"; + if (shownInTab) return; // Only the person driving the page gets the file offered; agents read it from status. const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); controller?.push({ @@ -889,6 +982,10 @@ const make = Effect.gen(function* () { } catch (cause) { await NodeFSP.rm(path, { force: true }).catch(constVoid); runFork(Effect.logWarning("server preview download failed", { cause })); + } finally { + if (navigation !== null && !tab.closing) { + settleAbortedNavigation(tab, navigation, download.url(), offered); + } } }; @@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () { return; } const url = popup.url(); + // Captured now: the person whose click opened the popup gets switched to it. + const controller = [...opener.viewers].find( + (viewer) => viewer.id === opener.control.controller, + ); await Effect.runPromise( manager.open({ threadId: opener.threadId, @@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () { openerTabId: opener.tabId, }), }), - ).catch(async () => { - await popup.close().catch(constVoid); - }); + ).then( + (snapshot) => { + // The opener stays open behind it, so `window.opener` can still report back. + if (opener.control.agentId === null) + controller?.push({ _tag: "popup", tabId: snapshot.tabId }); + }, + async () => { + await popup.close().catch(constVoid); + }, + ); }; const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { @@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () { } await navigation.catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); + if (message.includes("Download is starting")) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`, + ); + } if (/ERR_[A-Z_]+/.test(message)) { throw new ServerBrowserPage.ServerBrowserOperationError( "PreviewAutomationExecutionError", @@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () { ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), ); case "evaluate": - return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput); + return ServerBrowserPage.evaluate( + tab.cdp, + input as PreviewAutomationEvaluateInput, + request.timeoutMs, + ); case "waitFor": return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); case "recordingStart": { @@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); + // The page came back, for example after its DevTools closed. Reconnect now + // rather than on the next viewer or agent, or the tab's URL and title stop + // reaching every client in the meantime. + yield* desktopChannel.attached.pipe( + Stream.runForEach((key) => + Effect.gen(function* () { + if (tabs.has(tabKey(key.threadId, key.tabId))) return; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === key.tabId && session.runtime === "server", + ); + if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); + }).pipe(Effect.ignore), + ), + Effect.forkScoped, + ); yield* Effect.sync(closeIdleAgentTabs).pipe( Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), Effect.forkScoped, diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 3e48bfb6b88c44d016fdb1c334c257a22b175e85..5f5c94b83071129c2712c7b8efc91fa150c6481e 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import type { Browser, BrowserContext } from "playwright-core"; +import type { Browser, BrowserContext, CDPSession } from "playwright-core"; import { sandboxDisabled } from "./PreviewBrowserHost.ts"; @@ -23,6 +24,61 @@ interface Options { readonly onContextClose?: (context: BrowserContext) => void; } +const UA_PLATFORMS: Partial> = { + darwin: "macOS", + linux: "Linux", + win32: "Windows", +}; + +/** + * The headless shell's identity as the Chrome it is built from: its user agent + * without `HeadlessChrome`, and client hints whose brands agree with it. Sites + * block a user agent that disagrees with `Sec-CH-UA`, so both change together. + */ +const chromeIdentity = ( + headlessUserAgent: string, + host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string }, +) => { + const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent); + const fullVersion = version?.[1]; + const major = version?.[2]; + if (!version || !fullVersion || !major) return null; + // Chrome's GREASE brand and order for this major version, as the shell reports them. + const brands = (names: (version: string) => string) => [ + { brand: "Chromium", version: names(major) }, + { brand: "Google Chrome", version: names(major) }, + { brand: "Not A(Brand", version: names("99") }, + ]; + return { + // Chrome reduces its user agent to the major version. + userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`), + userAgentMetadata: { + brands: brands((value) => value), + fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)), + platform: UA_PLATFORMS[host.platform] ?? "Unknown", + platformVersion: host.platform === "linux" ? host.release : "", + architecture: host.arch.startsWith("arm") ? "arm" : "x86", + bitness: host.arch.endsWith("64") ? "64" : "32", + model: "", + mobile: false, + wow64: false, + }, + }; +}; + +/** + * Presents a headless page as plain Chrome. Applies to the page, its frames, + * and its workers; a popup gets its own call once it becomes a tab. + */ +export const presentAsChrome = async ( + cdp: CDPSession, + host: { readonly platform: NodeJS.Platform; readonly arch: string }, +) => { + const { userAgent } = await cdp.send("Browser.getVersion"); + const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() }); + if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); +}; + /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ export class ServerBrowserContexts { private readonly options: Options; @@ -42,7 +98,13 @@ export class ServerBrowserContexts { return { executablePath, env, - args: ["--disable-gpu", "--force-device-scale-factor=2"], + // People drive these tabs, so they must not announce automation (navigator.webdriver). + args: [ + "--disable-gpu", + "--force-device-scale-factor=2", + "--disable-blink-features=AutomationControlled", + ], + ignoreDefaultArgs: ["--enable-automation"], headless: true, // Only an explicit operator opt-out disables sandboxing. Launch errors never do. chromiumSandbox: !sandboxDisabled(env), diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 3ab9d6dc35b8fb862119dd86ea098fae8d8a2a59..291f89e125eb2d8c877cc987a5d80541acdf8394 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -7,6 +7,7 @@ import { } from "playwright-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { presentAsChrome } from "./ServerBrowserContexts.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; describe("server browser element refs", () => { @@ -245,4 +246,34 @@ describe("server browser element refs", () => { ]); expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); }); + + it("presents a headless page as Chrome, with client hints that agree", async () => { + await presentAsChrome(cdp, { platform: "linux", arch: "x64" }); + // userAgentData exists only in secure contexts; https comes from a route. + let secChUa: string | undefined; + await page.route("https://example.test/", (route) => { + secChUa = route.request().headers()["sec-ch-ua"]; + return route.fulfill({ contentType: "text/html", body: "

hi

" }); + }); + await page.goto("https://example.test/"); + const identity = (await page.evaluate(`(async () => ({ + userAgent: navigator.userAgent, + brands: navigator.userAgentData.brands.map((brand) => brand.brand), + full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"])) + .fullVersionList.map((brand) => brand.brand), + }))()`)) as { userAgent: string; brands: string[]; full: string[] }; + expect(identity.userAgent).not.toContain("Headless"); + expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /); + expect(identity.brands).toContain("Google Chrome"); + expect([...identity.brands, ...identity.full].join()).not.toContain("Headless"); + expect(secChUa).toContain('"Google Chrome"'); + expect(secChUa).not.toContain("Headless"); + }); + + it("stops an evaluation at its deadline so the page answers the next one", async () => { + await expect( + ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200), + ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); + }); }); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index b6a397e293a68bc258c77837ebfd671275c9d338..4c2a36478e07b06a6fef7fa4bfc83672eec5e223 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; -export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => { - const result = await cdp.send("Runtime.evaluate", { +/** + * Runs an agent's expression, bounded by its request's deadline. Past it, the + * page's script is terminated and the call fails, so the tab's control queue + * frees for the next request instead of waiting on the script. + */ +export const evaluate = async ( + cdp: CDPSession, + input: PreviewAutomationEvaluateInput, + timeoutMs: number, +) => { + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + // Stops a busy script; an awaited promise is abandoned and settles unread. + void cdp.send("Runtime.terminateExecution").catch(constVoid); + reject( + new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + `Evaluation did not finish within ${timeoutMs}ms and was stopped.`, + ), + ); + }, timeoutMs); + }); + const evaluation = cdp.send("Runtime.evaluate", { expression: input.expression, awaitPromise: input.awaitPromise ?? true, returnByValue: input.returnByValue ?? true, }); + void evaluation.catch(constVoid); + const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); if (result.exceptionDetails) { throw new ServerBrowserOperationError( "PreviewAutomationExecutionError", diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index cfb124c4f9eba952c2948d8dbb25f54153664cb3..964f143f5ef60c5cbf0fc00f11dfbff8ab8f4b38 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => case "download": case "fileChooser": case "fileChooserClosed": + case "popup": case "pointer": case "probe": { const { _tag: type, ...data } = output; diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 746c139f6fc8b831f26ff082858ec2e359c1be42..a64896b637f8f48d50afe10bca4647ca4a5fe20f 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or assert.lengthOf(partialEvents, 1); assert.equal(partialEvents[0]?.stream_id, firstThreadId); assert.equal(partialEvents[0]?.event_type, "thread.deleted"); - assert.lengthOf(partialCleanup, 1); - assert.equal(partialCleanup[0]?.thread_id, firstThreadId); - assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup"); + assert.deepEqual( + partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), + [ + [firstThreadId, "preview.cleanup"], + [firstThreadId, "terminal.cleanup"], + ], + ); const deletedProject = yield* service.delete(input); assert.isNotNull(deletedProject.deletedAt); @@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual(finalEvents[0], partialEvents[0]); assert.equal(finalEvents[2]?.command_id, commandId); const finalCleanup = yield* readCleanup; - assert.lengthOf(finalCleanup, 2); + assert.lengthOf(finalCleanup, 4); assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === firstThreadId), partialCleanup, @@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === threadId), [ + { + effect_id: `effect:${expectedCommandId}:preview.cleanup`, + thread_id: threadId, + command_id: expectedCommandId, + effect_type: "preview.cleanup", + }, { effect_id: `effect:${expectedCommandId}:terminal.cleanup`, thread_id: threadId, diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 26013d720bc59f31482ba55161d51361f45ee0b3..feca1cbaf794c268e8d3415ccbf441394ee4d20f 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: { readonly onFirstFrame?: () => void; readonly onViewport?: (viewport: PreviewStreamViewport) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; + /** The page opened a new tab from this viewer's click; the viewer should switch to it. */ + readonly onPopup?: (tabId: string) => void; readonly className?: string; readonly ref?: Ref; }) { @@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: { onFirstFrame, onViewport, onControl, + onPopup, className, ref, } = props; @@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: { onViewport?.(viewport), ); const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); + const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId)); // Frame cap in device px, fixed per socket. It grows with the surface and // never shrinks, so only outgrowing it reconnects. const [cap, setCap] = useState<{ width: number; height: number } | null>(null); @@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: { }, onClipboard: (text) => void copyPageText(text), onDownload: offerDownload, + onPopup: popupOpened, onFileChooser: setFileChooser, onViewport: (viewport) => { viewportRef.current = viewport; diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index 5570178383739f68116b46ea779e3e5f5bc35a90..f44a33e931d5a6f2cc7b8aa0653bc9a6aa2eaaaf 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -13,6 +13,50 @@ interface Props { onReload: () => void; } +/** + * A server tab's address was a file its browser cannot show (a PDF), so the + * browser downloaded it. Offers the address in the user's own browser, or the + * file the server already saved. + */ +export function PreviewFileNotShown({ + url, + fileName, + downloadUrl, + onOpen, +}: { + url: string; + fileName: string; + downloadUrl: string | null; + onOpen: () => void; +}) { + const host = safeHost(url) ?? url; + return ( +
+
+ +

+ This file can’t be shown here +

+

+ {fileName} from {host} was + downloaded instead. +

+
+
+ + {downloadUrl ? ( + + ) : null} +
+
+
+ ); +} + /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ export function PreviewUnreachable({ url, code, description, onReload }: Props) { const [showDetails, setShowDetails] = useState(false); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index af525344865536d9345ff634653bc2f3519e0951..44a261ac6d789b01f0ee6bc3e2c217cf339af305 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,6 +1,7 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -16,7 +17,7 @@ import { PREVIEW_ZOOM_LEVELS, type PreviewAdjustInput, } from "@t3tools/contracts"; -import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -37,6 +38,7 @@ import { import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { usePreviewStreamAccess } from "~/state/previewStream"; import { useAtomCommand } from "~/state/use-atom-command"; import { browserMiniPlayerSource, @@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; +import { showPreviewPopup } from "./showPreviewPopup"; import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; @@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; -import { PreviewUnreachable } from "./PreviewUnreachable"; +import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { Badge } from "~/components/ui/badge"; @@ -103,6 +106,31 @@ function previewProfileName( const localApi = typeof window === "undefined" ? null : ensureLocalApi(); +/** Resolves stream access only while a server tab shows a file it downloaded. */ +function ServerTabFileNotShown(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly url: string; + readonly download: { readonly id: string; readonly fileName: string }; +}) { + const access = usePreviewStreamAccess(props.threadRef.environmentId); + return ( + void localApi?.shell.openExternal(props.url).catch(() => undefined)} + /> + ); +} + /** * Single-tab preview surface: chrome row on top, one webview below, empty * state when no session exists for the thread. @@ -259,12 +287,12 @@ export function PreviewView({ const handleSubmitUrl = useCallback( async (next: string) => { try { - const normalized = normalizePreviewUrl(next); - if (await navigateToResolvedUrl(normalized)) { - recordVisitForThread(threadRef, normalized); + const resolved = resolveAddressBarInput(next); + if (await navigateToResolvedUrl(resolved)) { + recordVisitForThread(threadRef, resolved); } } catch { - // Server-side `failed` event renders the unreachable view. + // Only empty input or an unsupported scheme lands here; the bar keeps the text. } }, [navigateToResolvedUrl, threadRef], @@ -995,6 +1023,7 @@ export function PreviewView({ onControl={(control) => setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null) } + onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")} // Stays connected under the empty state so a URL picked there reaches the page. className={cn( "absolute inset-0 h-full w-full", @@ -1043,7 +1072,16 @@ export function PreviewView({ controller={controller} /> ) : null} - {navStatus._tag === "LoadFailed" ? ( + {navStatus._tag === "LoadFailed" && navStatus.download ? ( +
+ +
+ ) : navStatus._tag === "LoadFailed" ? (
showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full" />
diff --git a/apps/web/src/components/preview/showPreviewPopup.ts b/apps/web/src/components/preview/showPreviewPopup.ts new file mode 100644 index 0000000000000000000000000000000000000000..56846c28bda1697fbbdd26671d751e2e2c19b6a8 --- /dev/null +++ b/apps/web/src/components/preview/showPreviewPopup.ts @@ -0,0 +1,24 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { whenPreviewTabKnown } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +/** + * Switches the viewer to a tab the page they drive just opened, as a browser + * does for a link click or `window.open`. A floating opener floats the popup; + * otherwise it becomes the panel's active tab. The opener stays open. + */ +export function showPreviewPopup( + threadRef: ScopedThreadRef, + popupTabId: string, + from: "panel" | "floating", +): void { + whenPreviewTabKnown(threadRef, popupTabId, () => { + if (from === "floating") { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(popupTabId)); + } else { + useRightPanelStore.getState().openBrowser(threadRef, popupTabId); + } + }); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cab4dde3dd204ae6ab7c11474c62d5a59373644e..b0093f4a0a128a5abcb367b7e162209a957b7432 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -50,6 +50,7 @@ import { readThreadShells, } from "../state/entities"; import { useUiStateStore } from "../uiStateStore"; +import { clearThreadPreviewState } from "../previewStateStore"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -449,6 +450,7 @@ export function useThreadActions() { }); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); + clearThreadPreviewState(target); } return result; } @@ -555,6 +557,7 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); + clearThreadPreviewState(threadRef); if (shouldNavigateToFallback) { const fallbackThread = fallbackThreadId diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 13ce14a26dfa1339e8c23da9b7071ec05d7d5b4d..f257a8e38c61d08c37945f1dccb1f080835dca54 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven title: event.title, code: event.code, description: event.description, + ...(event.download === undefined ? {} : { download: event.download }), }, updatedAt: event.createdAt, }; @@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { }); } +/** + * Runs `action` once the thread's preview state has the tab, which a popup's + * `opened` event may deliver after the stream that announced it. Gives up + * after `timeoutMs`. Returns a cancel function. + */ +export function whenPreviewTabKnown( + ref: ScopedThreadRef, + tabId: string, + action: () => void, + timeoutMs = 5_000, +): () => void { + const atom = previewStateAtom(scopedThreadKey(ref)); + if (appAtomRegistry.get(atom).sessions[tabId]) { + action(); + return () => {}; + } + const stop = () => { + clearTimeout(timer); + unsubscribe(); + }; + const unsubscribe = appAtomRegistry.subscribe(atom, (state) => { + if (!state.sessions[tabId]) return; + stop(); + action(); + }); + const timer = setTimeout(stop, timeoutMs); + return stop; +} + export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { if (url.trim().length === 0) return; updateThreadPreviewState(ref, (current) => ({ @@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean { return Boolean(window.desktopBridge?.preview); } +/** + * Forgets a deleted thread's previews. The server closes their sessions too, + * but the desktop host keeps a page for every session held here. + */ +export function clearThreadPreviewState(ref: ScopedThreadRef): void { + updateThreadPreviewState(ref, (current) => + Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE, + ); +} + export function resetPreviewStateForTests(): void { for (const threadKey of changedPreviewThreadKeys) { appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 7bfcd9b42cfc89f21a4444ec52d3a5b3d9b2f1fa..34a3786976d070a7731db26ff4c978d83819fa5a 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles( if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); } -const previewStreamDownloadUrl = ( +/** A file a server tab downloaded, served with the stream's own access. */ +export const previewStreamDownloadUrl = ( target: Pick, id: string, ): string => @@ -202,6 +203,8 @@ export interface PreviewStreamEvents { readonly onClipboard?: (text: string) => void; /** A file the page downloaded while this viewer had control. */ readonly onDownload?: (download: PreviewStreamDownload) => void; + /** The page this viewer controls opened a new tab (a link or `window.open`). */ + readonly onPopup?: (tabId: string) => void; /** The page opened a file picker (`null` once answered or replaced). */ readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; /** Input sent while disconnected is dropped. */ @@ -286,6 +289,7 @@ export function createPreviewStreamClient( accept, phase, sequence, + tabId, } = message as Record; if ( type === "fileChooser" && @@ -312,6 +316,8 @@ export function createPreviewStreamClient( typeof sequence === "number" ) { events.onPointer?.({ phase, x, y, sequence }); + } else if (type === "popup" && typeof tabId === "string") { + events.onPopup?.(tabId); } else if (type === "clipboard" && typeof text === "string") { events.onClipboard?.(text); } else if ( diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index e3e9641924718ca93dad67f14d3dcd17244dc50a..e534fff06984fddd4c8d6343dd73bfe2cebf9118 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; +/** A file a server tab downloaded, served by the tab's download route by this id. */ +const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); + export const PreviewNavStatus = Schema.Union([ Schema.TaggedStruct("Idle", {}), Schema.TaggedStruct("Loading", { @@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([ title: Title, code: Schema.Int, description: Schema.String, + /** + * Set when the address was a file the browser downloaded instead of a page + * it could show (a PDF in a server tab). The server tab's download route + * serves it by this id. + */ + download: Schema.optional(PreviewFileDownload), }), ]); export type PreviewNavStatus = typeof PreviewNavStatus.Type; @@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({ title: Title, code: Schema.Int, description: Schema.String, + download: Schema.optional(PreviewFileDownload), }); const PreviewClosedEvent = Schema.Struct({ diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 14139216194e0a30abfd4ab64d53235652dfc743..e228b785927ac695774ad9eae6810c33deb03db1 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -5,8 +5,58 @@ import { newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, + resolveAddressBarInput, } from "./preview.ts"; +describe("resolveAddressBarInput", () => { + it("opens what users type as addresses", () => { + expect(resolveAddressBarInput("localhost:5173")).toBe("http://localhost:5173/"); + expect(resolveAddressBarInput("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); + expect(resolveAddressBarInput("my-box.tailnet.ts.net")).toBe("https://my-box.tailnet.ts.net/"); + expect(resolveAddressBarInput("cnn.com")).toBe("https://cnn.com/"); + expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); + expect(resolveAddressBarInput(" https://example.com/a b ")).toBe("https://example.com/a%20b"); + expect(resolveAddressBarInput("example.com:8080")).toBe("https://example.com:8080/"); + expect(resolveAddressBarInput("[::1]:3000")).toBe("http://[::1]:3000/"); + expect(resolveAddressBarInput("192.168.1.5:3000")).toBe("https://192.168.1.5:3000/"); + }); + + it("searches for text that is not an address", () => { + expect(resolveAddressBarInput("weather")).toBe("https://duckduckgo.com/?q=weather"); + expect(resolveAddressBarInput("how to center a div")).toBe( + "https://duckduckgo.com/?q=how%20to%20center%20a%20div", + ); + expect(resolveAddressBarInput("what is cnn.com")).toBe( + "https://duckduckgo.com/?q=what%20is%20cnn.com", + ); + expect(resolveAddressBarInput("what is https://example.com")).toBe( + "https://duckduckgo.com/?q=what%20is%20https%3A%2F%2Fexample.com", + ); + expect(resolveAddressBarInput("what is 10:30")).toBe( + "https://duckduckgo.com/?q=what%20is%2010%3A30", + ); + expect(resolveAddressBarInput("note: buy milk")).toBe( + "https://duckduckgo.com/?q=note%3A%20buy%20milk", + ); + }); + + it("still rejects empty input and unsupported schemes", () => { + expect(() => resolveAddressBarInput(" ")).toThrow(PreviewUrlNormalizationError); + expect(() => resolveAddressBarInput("ftp://example.com")).toThrow(PreviewUrlNormalizationError); + expect(() => resolveAddressBarInput("mailto:alice@example.com")).toThrow( + PreviewUrlNormalizationError, + ); + expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow( + PreviewUrlNormalizationError, + ); + // A known scheme with a numeric payload is not a host and port. + for (const input of ["ftp:21", "tel:5551234"]) { + expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError); + } + expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); + }); +}); + describe("newPreviewTabId", () => { it("returns a unique tab id every call", () => { const a = newPreviewTabId(); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index c609a3b5db7415a82d188fa53dab32ae7e30f5d5..393fba0a35aaef2ff40174e7b58e0de374cea028 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined { return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); } +const SEARCH_URL = "https://duckduckgo.com/?q="; +/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ +const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; +const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/; +/** + * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234` + * is rejected rather than read as host and port. + */ +const KNOWN_NON_WEB_SCHEMES: ReadonlySet = new Set([ + "about:", + "blob:", + "chrome:", + "data:", + "file:", + "ftp:", + "javascript:", + "mailto:", + "sms:", + "ssh:", + "tel:", + "view-source:", + "ws:", + "wss:", +]); + +/** + * Turns what a user typed in an address bar into the URL to open: an address + * as `normalizePreviewUrl` reads it, or else a web search for the text. Text + * is an address when it has a scheme, or no spaces and a host that has a dot, + * a port, or is localhost or an IP. Throws only for empty input or an + * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`). + */ +export function resolveAddressBarInput(rawInput: string): string { + const trimmed = rawInput.trim(); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0) + return normalizePreviewUrl(trimmed); + if (!/\s/.test(trimmed)) { + // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or + // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read + // `mailto:alice@example.com` as credentials on a bare host, so reject here. + const protocol = previewUrlProtocol(trimmed); + if ( + protocol !== undefined && + (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed)) + ) { + if (protocol !== "http:" && protocol !== "https:") { + throw new PreviewUrlNormalizationError({ + inputLength: rawInput.length, + reason: "unsupported-protocol", + protocol, + }); + } + return normalizePreviewUrl(trimmed); + } + const authority = trimmed.split(/[/?#]/, 1)[0] ?? ""; + const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, ""); + const hasPort = host.length < authority.replace(/^[^@]*@/, "").length; + if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) { + try { + return normalizePreviewUrl(trimmed); + } catch { + // Not a usable address after all; search for it. + } + } + } + return `${SEARCH_URL}${encodeURIComponent(trimmed)}`; +} + /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. *