| author | |
| committer | |
| log | 5754121c23f73201d47a96feb97eca7b96334a2a |
| tree | 41aeea924b2334a4ce889e4e8454e970f232a64a |
| parent | 741377ce5fb1578916659cbb93fd403b26dbeb62 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>29 files changed, 1202 insertions(+), 44 deletions(-)
apps/server/src/orchestration-v2/EffectOutbox.ts+5| ... | @@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ | ... | @@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ |
| 95 | Schema.Struct({ | 95 | Schema.Struct({ |
| 96 | type: Schema.Literal("terminal.cleanup"), | 96 | type: Schema.Literal("terminal.cleanup"), |
| 97 | }), | 97 | }), |
| 98 | /** Closes a deleted thread's preview sessions, ending their server browser tabs. */ | ||
| 99 | Schema.Struct({ | ||
| 100 | type: Schema.Literal("preview.cleanup"), | ||
| 101 | }), | ||
| 98 | Schema.Struct({ | 102 | Schema.Struct({ |
| 99 | type: Schema.Literal("attachment.cleanup"), | 103 | type: Schema.Literal("attachment.cleanup"), |
| 100 | attachmentIds: Schema.Array(Schema.String), | 104 | attachmentIds: Schema.Array(Schema.String), |
| ... | @@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ | ... | @@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ |
| 120 | "provider-thread.rollback", | 124 | "provider-thread.rollback", |
| 121 | "checkpoint.capture", | 125 | "checkpoint.capture", |
| 122 | "terminal.cleanup", | 126 | "terminal.cleanup", |
| 127 | "preview.cleanup", | ||
| 123 | "attachment.cleanup", | 128 | "attachment.cleanup", |
| 124 | "thread-title.generate", | 129 | "thread-title.generate", |
| 125 | "delegated-tasks.stop", | 130 | "delegated-tasks.stop", |
apps/server/src/orchestration-v2/EffectWorker.ts+11| ... | @@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer< | ... | @@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer< |
| 439 | }), | 439 | }), |
| 440 | ), | 440 | ), |
| 441 | ); | 441 | ); |
| 442 | case "preview.cleanup": | ||
| 443 | return resourceCleanup.cleanupPreviews(effect.threadId).pipe( | ||
| 444 | Effect.mapError( | ||
| 445 | (cause) => | ||
| 446 | new OrchestrationEffectExecutionError({ | ||
| 447 | effectId: effect.id, | ||
| 448 | effectType: effect.request.type, | ||
| 449 | cause, | ||
| 450 | }), | ||
| 451 | ), | ||
| 452 | ); | ||
| 442 | case "attachment.cleanup": | 453 | case "attachment.cleanup": |
| 443 | return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( | 454 | return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( |
| 444 | Effect.mapError( | 455 | Effect.mapError( |
apps/server/src/orchestration-v2/ResourceCleanupService.ts+15-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -6,12 +7,13 @@ import * as Schema from "effect/Schema"; | ... | @@ -6,12 +7,13 @@ import * as Schema from "effect/Schema"; |
| 6 | 7 | ||
| 7 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | 8 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 8 | import * as ServerConfig from "../config.ts"; | 9 | import * as ServerConfig from "../config.ts"; |
| 10 | import * as PreviewManager from "../preview/Manager.ts"; | ||
| 9 | import * as TerminalManager from "../terminal/Manager.ts"; | 11 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 10 | 12 | ||
| 11 | export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()( | 13 | export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()( |
| 12 | "ResourceCleanupError", | 14 | "ResourceCleanupError", |
| 13 | { | 15 | { |
| 14 | operation: Schema.Literals(["terminal", "attachment"]), | 16 | operation: Schema.Literals(["terminal", "preview", "attachment"]), |
| 15 | threadId: Schema.optional(Schema.String), | 17 | threadId: Schema.optional(Schema.String), |
| 16 | attachmentId: Schema.optional(Schema.String), | 18 | attachmentId: Schema.optional(Schema.String), |
| 17 | cause: Schema.Defect(), | 19 | cause: Schema.Defect(), |
| ... | @@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro | ... | @@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro |
| 20 | 22 | ||
| 21 | export class ResourceCleanupService extends Context.Reference<{ | 23 | export class ResourceCleanupService extends Context.Reference<{ |
| 22 | readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>; | 24 | readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>; |
| 25 | /** Closes every preview session of the thread; server browser tabs end with them. */ | ||
| 26 | readonly cleanupPreviews: (threadId: string) => Effect.Effect<void, ResourceCleanupError>; | ||
| 23 | readonly cleanupAttachments: ( | 27 | readonly cleanupAttachments: ( |
| 24 | attachmentIds: ReadonlyArray<string>, | 28 | attachmentIds: ReadonlyArray<string>, |
| 25 | ) => Effect.Effect<void, ResourceCleanupError>; | 29 | ) => Effect.Effect<void, ResourceCleanupError>; |
| 26 | }>("t3/orchestration-v2/ResourceCleanupService", { | 30 | }>("t3/orchestration-v2/ResourceCleanupService", { |
| 27 | defaultValue: () => ({ | 31 | defaultValue: () => ({ |
| 28 | cleanupTerminals: () => Effect.void, | 32 | cleanupTerminals: () => Effect.void, |
| 33 | cleanupPreviews: () => Effect.void, | ||
| 29 | cleanupAttachments: () => Effect.void, | 34 | cleanupAttachments: () => Effect.void, |
| 30 | }), | 35 | }), |
| 31 | }) {} | 36 | }) {} |
| ... | @@ -34,6 +39,7 @@ export const layer = Layer.effect( | ... | @@ -34,6 +39,7 @@ export const layer = Layer.effect( |
| 34 | ResourceCleanupService, | 39 | ResourceCleanupService, |
| 35 | Effect.gen(function* () { | 40 | Effect.gen(function* () { |
| 36 | const terminals = yield* TerminalManager.TerminalManager; | 41 | const terminals = yield* TerminalManager.TerminalManager; |
| 42 | const previews = yield* PreviewManager.PreviewManager; | ||
| 37 | const fileSystem = yield* FileSystem.FileSystem; | 43 | const fileSystem = yield* FileSystem.FileSystem; |
| 38 | const config = yield* ServerConfig.ServerConfig; | 44 | const config = yield* ServerConfig.ServerConfig; |
| 39 | return { | 45 | return { |
| ... | @@ -45,6 +51,14 @@ export const layer = Layer.effect( | ... | @@ -45,6 +51,14 @@ export const layer = Layer.effect( |
| 45 | (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }), | 51 | (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }), |
| 46 | ), | 52 | ), |
| 47 | ), | 53 | ), |
| 54 | cleanupPreviews: (threadId: string) => | ||
| 55 | previews | ||
| 56 | .close({ threadId: ThreadId.make(threadId) }) | ||
| 57 | .pipe( | ||
| 58 | Effect.mapError( | ||
| 59 | (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }), | ||
| 60 | ), | ||
| 61 | ), | ||
| 48 | cleanupAttachments: (attachmentIds: ReadonlyArray<string>) => | 62 | cleanupAttachments: (attachmentIds: ReadonlyArray<string>) => |
| 49 | Effect.forEach( | 63 | Effect.forEach( |
| 50 | attachmentIds, | 64 | attachmentIds, |
apps/server/src/orchestration-v2/ThreadDeletion.test.ts+1| ... | @@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio | ... | @@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio |
| 291 | revokeMcpCredential: true, | 291 | revokeMcpCredential: true, |
| 292 | }, | 292 | }, |
| 293 | { type: "terminal.cleanup" }, | 293 | { type: "terminal.cleanup" }, |
| 294 | { type: "preview.cleanup" }, | ||
| 294 | { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, | 295 | { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, |
| 295 | ], | 296 | ], |
| 296 | ); | 297 | ); |
apps/server/src/orchestration-v2/ThreadDeletion.ts+6| ... | @@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") | ... | @@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") |
| 215 | threadId: command.threadId, | 215 | threadId: command.threadId, |
| 216 | request: { type: "terminal.cleanup" }, | 216 | request: { type: "terminal.cleanup" }, |
| 217 | }); | 217 | }); |
| 218 | effects.push({ | ||
| 219 | id: `effect:${command.commandId}:preview.cleanup`, | ||
| 220 | commandId: command.commandId, | ||
| 221 | threadId: command.threadId, | ||
| 222 | request: { type: "preview.cleanup" }, | ||
| 223 | }); | ||
| 218 | const attachmentIds = Array.from(new Set(input.attachmentIds)); | 224 | const attachmentIds = Array.from(new Set(input.attachmentIds)); |
| 219 | if (attachmentIds.length > 0) { | 225 | if (attachmentIds.length > 0) { |
| 220 | effects.push({ | 226 | effects.push({ |
apps/server/src/preview/DesktopBrowserChannel.ts+7| ... | @@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service< | ... | @@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service< |
| 54 | readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>; | 54 | readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>; |
| 55 | /** Desktop tabs as they detach. */ | 55 | /** Desktop tabs as they detach. */ |
| 56 | readonly detached: Stream.Stream<DesktopTabKey>; | 56 | readonly detached: Stream.Stream<DesktopTabKey>; |
| 57 | /** Desktop tabs as they attach, including a tab coming back after its DevTools close. */ | ||
| 58 | readonly attached: Stream.Stream<DesktopTabKey>; | ||
| 57 | readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>; | 59 | readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>; |
| 58 | /** | 60 | /** |
| 59 | * A one-connection CDP endpoint for an attached tab. Closing the scope | 61 | * A one-connection CDP endpoint for an attached tab. Closing the scope |
| ... | @@ -83,6 +85,7 @@ const make = Effect.gen(function* () { | ... | @@ -83,6 +85,7 @@ const make = Effect.gen(function* () { |
| 83 | available: false, | 85 | available: false, |
| 84 | awaitAttached: () => Effect.succeed(false), | 86 | awaitAttached: () => Effect.succeed(false), |
| 85 | detached: Stream.empty, | 87 | detached: Stream.empty, |
| 88 | attached: Stream.empty, | ||
| 86 | isAttached: () => Effect.succeed(false), | 89 | isAttached: () => Effect.succeed(false), |
| 87 | endpoint: () => Effect.die("No desktop app is attached to this server."), | 90 | endpoint: () => Effect.die("No desktop app is attached to this server."), |
| 88 | pointer: () => Effect.void, | 91 | pointer: () => Effect.void, |
| ... | @@ -216,6 +219,10 @@ const make = Effect.gen(function* () { | ... | @@ -216,6 +219,10 @@ const make = Effect.gen(function* () { |
| 216 | Stream.filter((change) => !change.attached), | 219 | Stream.filter((change) => !change.attached), |
| 217 | Stream.map((change) => change.key), | 220 | Stream.map((change) => change.key), |
| 218 | ), | 221 | ), |
| 222 | attached: Stream.fromPubSub(changes).pipe( | ||
| 223 | Stream.filter((change) => change.attached), | ||
| 224 | Stream.map((change) => change.key), | ||
| 225 | ), | ||
| 219 | isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), | 226 | isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), |
| 220 | endpoint, | 227 | endpoint, |
| 221 | pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), | 228 | pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), |
apps/server/src/preview/Manager.ts+3| ... | @@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 317 | title: input.navStatus.title, | 317 | title: input.navStatus.title, |
| 318 | code: input.navStatus.code, | 318 | code: input.navStatus.code, |
| 319 | description: input.navStatus.description, | 319 | description: input.navStatus.description, |
| 320 | ...(input.navStatus.download === undefined | ||
| 321 | ? {} | ||
| 322 | : { download: input.navStatus.download }), | ||
| 320 | } | 323 | } |
| 321 | : { | 324 | : { |
| 322 | type: "navigated", | 325 | type: "navigated", |
apps/server/src/preview/PortScanner.test.ts+114| ... | @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; | ... | @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Exit from "effect/Exit"; | 15 | import * as Exit from "effect/Exit"; |
| 16 | import * as Fiber from "effect/Fiber"; | 16 | import * as Fiber from "effect/Fiber"; |
| 17 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Layer from "effect/Layer"; | 18 | import * as Layer from "effect/Layer"; |
| 18 | import * as PlatformError from "effect/PlatformError"; | 19 | import * as PlatformError from "effect/PlatformError"; |
| 19 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| ... | @@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, { | ... | @@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, { |
| 53 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 54 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 54 | }); | 55 | }); |
| 55 | 56 | ||
| 57 | /** A host without `/proc`, so a missing `lsof` falls back to common ports. */ | ||
| 58 | const layerNoProc = FileSystem.layerNoop({}); | ||
| 59 | |||
| 56 | const layerProbeFailure = ( | 60 | const layerProbeFailure = ( |
| 57 | run: ProcessRunner.ProcessRunner["Service"]["run"], | 61 | run: ProcessRunner.ProcessRunner["Service"]["run"], |
| 58 | fetch: typeof globalThis.fetch = globalThis.fetch, | 62 | fetch: typeof globalThis.fetch = globalThis.fetch, |
| 63 | fileSystem: Layer.Layer<FileSystem.FileSystem> = layerNoProc, | ||
| 59 | ) => | 64 | ) => |
| 60 | PortScanner.layer.pipe( | 65 | PortScanner.layer.pipe( |
| 61 | Layer.provide( | 66 | Layer.provide( |
| 62 | Layer.mergeAll( | 67 | Layer.mergeAll( |
| 68 | fileSystem, | ||
| 63 | Layer.succeed(ProcessRunner.ProcessRunner, { run }), | 69 | Layer.succeed(ProcessRunner.ProcessRunner, { run }), |
| 64 | Layer.succeed(Net.NetService, { | 70 | Layer.succeed(Net.NetService, { |
| 65 | canListenOnHost: () => Effect.succeed(true), | 71 | canListenOnHost: () => Effect.succeed(true), |
| ... | @@ -77,6 +83,7 @@ const layerProbeFailure = ( | ... | @@ -77,6 +83,7 @@ const layerProbeFailure = ( |
| 77 | const layerTestPortDiscovery = PortScanner.layer.pipe( | 83 | const layerTestPortDiscovery = PortScanner.layer.pipe( |
| 78 | Layer.provide( | 84 | Layer.provide( |
| 79 | Layer.mergeAll( | 85 | Layer.mergeAll( |
| 86 | layerNoProc, | ||
| 80 | layerTestProcessRunner, | 87 | layerTestProcessRunner, |
| 81 | layerTestIntegrationNet, | 88 | layerTestIntegrationNet, |
| 82 | Layer.succeed(HostProcessPlatform, "win32"), | 89 | Layer.succeed(HostProcessPlatform, "win32"), |
| ... | @@ -94,6 +101,7 @@ const layerLsofScanner = (input: { | ... | @@ -94,6 +101,7 @@ const layerLsofScanner = (input: { |
| 94 | PortScanner.layer.pipe( | 101 | PortScanner.layer.pipe( |
| 95 | Layer.provide( | 102 | Layer.provide( |
| 96 | Layer.mergeAll( | 103 | Layer.mergeAll( |
| 104 | layerNoProc, | ||
| 97 | Layer.succeed(ProcessRunner.ProcessRunner, { | 105 | Layer.succeed(ProcessRunner.ProcessRunner, { |
| 98 | run: () => | 106 | run: () => |
| 99 | Effect.succeed({ | 107 | Effect.succeed({ |
| ... | @@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () => | ... | @@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () => |
| 671 | }), | 679 | }), |
| 672 | ); | 680 | ); |
| 673 | 681 | ||
| 682 | // /proc/net/tcp: 127.0.0.1:8765 and 0.0.0.0:22 listening, an established | ||
| 683 | // connection, and a non-loopback listener; /proc/net/tcp6 adds [::]:3001. | ||
| 684 | const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode | ||
| 685 | 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0 | ||
| 686 | 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0 | ||
| 687 | 2: 0100007F:223D 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5003 1 0000000000000000 20 4 30 10 -1 | ||
| 688 | 3: 0A00000F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5005 1 0000000000000000 100 0 0 10 0 | ||
| 689 | `; | ||
| 690 | const PROC_NET_TCP6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode | ||
| 691 | 0: 00000000000000000000000000000000:0BB9 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5004 1 0000000000000000 100 0 0 10 0 | ||
| 692 | `; | ||
| 693 | |||
| 694 | effectIt.effect( | ||
| 695 | "reads Linux listeners from /proc when lsof is missing, and stops spawning it", | ||
| 696 | () => { | ||
| 697 | let lsofSpawns = 0; | ||
| 698 | const fdWalks: string[] = []; | ||
| 699 | const procFiles: Record<string, string> = { | ||
| 700 | "/proc/net/tcp": PROC_NET_TCP, | ||
| 701 | "/proc/net/tcp6": PROC_NET_TCP6, | ||
| 702 | "/proc/4242/comm": "python3\n", | ||
| 703 | }; | ||
| 704 | const procFds: Record<string, Record<string, string>> = { | ||
| 705 | "4242": { "0": "/dev/null", "3": "socket:[5001]" }, | ||
| 706 | "77": { "5": "socket:[9999]" }, | ||
| 707 | }; | ||
| 708 | const missing = FileSystem.makeNoop({}); | ||
| 709 | const fileSystem = FileSystem.layerNoop({ | ||
| 710 | readFileString: (path) => { | ||
| 711 | const content = procFiles[path]; | ||
| 712 | return content === undefined ? missing.readFileString(path) : Effect.succeed(content); | ||
| 713 | }, | ||
| 714 | readDirectory: (path) => { | ||
| 715 | if (path === "/proc") return Effect.succeed(["self", "77", "4242"]); | ||
| 716 | const pid = /^\/proc\/(\d+)\/fd$/.exec(path)?.[1]; | ||
| 717 | const fds = pid === undefined ? undefined : procFds[pid]; | ||
| 718 | if (pid === undefined || fds === undefined) return missing.readDirectory(path); | ||
| 719 | fdWalks.push(pid); | ||
| 720 | return Effect.succeed(Object.keys(fds)); | ||
| 721 | }, | ||
| 722 | readLink: (path) => { | ||
| 723 | const [, pid, fd] = /^\/proc\/(\d+)\/fd\/(\d+)$/.exec(path) ?? []; | ||
| 724 | const target = pid && fd ? procFds[pid]?.[fd] : undefined; | ||
| 725 | return target === undefined ? missing.readLink(path) : Effect.succeed(target); | ||
| 726 | }, | ||
| 727 | }); | ||
| 728 | const fetchFn = (() => | ||
| 729 | Promise.resolve( | ||
| 730 | new Response("hello", { headers: { "content-type": "text/html" } }), | ||
| 731 | )) as typeof globalThis.fetch; | ||
| 732 | const layer = layerProbeFailure( | ||
| 733 | (input) => { | ||
| 734 | lsofSpawns += 1; | ||
| 735 | return processProbeFailure(input); | ||
| 736 | }, | ||
| 737 | fetchFn, | ||
| 738 | fileSystem, | ||
| 739 | ); | ||
| 740 | |||
| 741 | return Effect.gen(function* () { | ||
| 742 | const scanner = yield* PortScanner.PortDiscovery; | ||
| 743 | const first = yield* scanner.scan(); | ||
| 744 | expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ | ||
| 745 | { port: 22, pid: null, processName: null }, | ||
| 746 | { port: 3001, pid: null, processName: null }, | ||
| 747 | { port: 8765, pid: 4242, processName: "python3" }, | ||
| 748 | ]); | ||
| 749 | yield* scanner.scan(); | ||
| 750 | expect(lsofSpawns).toBe(1); | ||
| 751 | // Ports 22 and 3001 have no readable owner, so the second scan walks | ||
| 752 | // again; it stops at 77, which holds no listener, once nothing is left. | ||
| 753 | expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); | ||
| 754 | yield* scanner.scan(); | ||
| 755 | yield* scanner.scan(); | ||
| 756 | // Unresolved owners are retried a bounded number of times. | ||
| 757 | expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); | ||
| 758 | }).pipe(Effect.provide(layer)); | ||
| 759 | }, | ||
| 760 | ); | ||
| 761 | |||
| 762 | effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => { | ||
| 763 | let lsofSpawns = 0; | ||
| 764 | const layer = layerProbeFailure((input) => { | ||
| 765 | lsofSpawns += 1; | ||
| 766 | return Effect.fail( | ||
| 767 | new ProcessRunner.ProcessSpawnError({ | ||
| 768 | command: input.command, | ||
| 769 | argumentCount: input.args.length, | ||
| 770 | cwd: input.cwd, | ||
| 771 | cause: PlatformError.systemError({ | ||
| 772 | _tag: "Unknown", | ||
| 773 | module: "ChildProcess", | ||
| 774 | method: "spawn", | ||
| 775 | description: "EAGAIN", | ||
| 776 | }), | ||
| 777 | }), | ||
| 778 | ); | ||
| 779 | }); | ||
| 780 | return Effect.gen(function* () { | ||
| 781 | const scanner = yield* PortScanner.PortDiscovery; | ||
| 782 | yield* scanner.scan(); | ||
| 783 | yield* scanner.scan(); | ||
| 784 | expect(lsofSpawns).toBe(2); | ||
| 785 | }).pipe(Effect.provide(layer)); | ||
| 786 | }); | ||
| 787 | |||
| 674 | effectIt.effect("does not swallow process probe interruption", () => | 788 | effectIt.effect("does not swallow process probe interruption", () => |
| 675 | Effect.gen(function* () { | 789 | Effect.gen(function* () { |
| 676 | const layer = layerProbeFailure(() => Effect.interrupt); | 790 | const layer = layerProbeFailure(() => Effect.interrupt); |
apps/server/src/preview/PortScanner.ts+157-4| ... | @@ -5,8 +5,11 @@ | ... | @@ -5,8 +5,11 @@ |
| 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely | 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely |
| 6 | * on). | 6 | * on). |
| 7 | * | 7 | * |
| 8 | * Windows / lsof missing: checks a curated list of common dev ports through | 8 | * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and |
| 9 | * the shared Net service. | 9 | * finds their processes through `/proc/<pid>/fd`. |
| 10 | * | ||
| 11 | * Windows listener failures, or neither source: checks a curated list of | ||
| 12 | * common dev ports through the shared Net service. | ||
| 10 | * | 13 | * |
| 11 | * Listening ports are published only after a bounded HTTP(S) probe finds a | 14 | * Listening ports are published only after a bounded HTTP(S) probe finds a |
| 12 | * successful HTML document or a redirect to one. | 15 | * successful HTML document or a redirect to one. |
| ... | @@ -30,8 +33,10 @@ import * as Clock from "effect/Clock"; | ... | @@ -30,8 +33,10 @@ import * as Clock from "effect/Clock"; |
| 30 | import * as Context from "effect/Context"; | 33 | import * as Context from "effect/Context"; |
| 31 | import * as Duration from "effect/Duration"; | 34 | import * as Duration from "effect/Duration"; |
| 32 | import * as Effect from "effect/Effect"; | 35 | import * as Effect from "effect/Effect"; |
| 36 | import * as FileSystem from "effect/FileSystem"; | ||
| 33 | import * as Layer from "effect/Layer"; | 37 | import * as Layer from "effect/Layer"; |
| 34 | import * as Option from "effect/Option"; | 38 | import * as Option from "effect/Option"; |
| 39 | import * as PlatformError from "effect/PlatformError"; | ||
| 35 | import * as Ref from "effect/Ref"; | 40 | import * as Ref from "effect/Ref"; |
| 36 | import * as Schedule from "effect/Schedule"; | 41 | import * as Schedule from "effect/Schedule"; |
| 37 | import * as Scope from "effect/Scope"; | 42 | import * as Scope from "effect/Scope"; |
| ... | @@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ | ... | @@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ |
| 72 | 77 | ||
| 73 | const POLL_INTERVAL = Duration.seconds(3); | 78 | const POLL_INTERVAL = Duration.seconds(3); |
| 74 | const LSOF_TIMEOUT_MS = 5_000; | 79 | const LSOF_TIMEOUT_MS = 5_000; |
| 80 | /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ | ||
| 81 | const PROC_FD_WALK_LIMIT = 50_000; | ||
| 82 | /** Scans that retry the fd walk for a listening socket whose owner was not found. */ | ||
| 83 | const SOCKET_OWNER_ATTEMPTS = 3; | ||
| 75 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | 84 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; |
| 76 | const WEB_PROBE_TIMEOUT = Duration.seconds(1); | 85 | const WEB_PROBE_TIMEOUT = Duration.seconds(1); |
| 77 | const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); | 86 | const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); |
| ... | @@ -265,6 +274,42 @@ const parseWindowsListenerOutput = ( | ... | @@ -265,6 +274,42 @@ const parseWindowsListenerOutput = ( |
| 265 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | 274 | return [...seen.values()].toSorted((left, right) => left.port - right.port); |
| 266 | }; | 275 | }; |
| 267 | 276 | ||
| 277 | /** `/proc/net/tcp` state for a listening socket. */ | ||
| 278 | const PROC_TCP_LISTEN = "0A"; | ||
| 279 | |||
| 280 | /** | ||
| 281 | * Whether a `/proc/net/tcp{,6}` address is one the preview can reach on | ||
| 282 | * loopback: the wildcard or a loopback address, as `lsof` parsing accepts. | ||
| 283 | * Each 32-bit word of the hex address is little-endian. | ||
| 284 | */ | ||
| 285 | const procLocalAddress = (hex: string): boolean => { | ||
| 286 | if (hex.length === 8) { | ||
| 287 | return hex === "00000000" || hex.endsWith("7F"); | ||
| 288 | } | ||
| 289 | if (hex.length !== 32) return false; | ||
| 290 | if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true; | ||
| 291 | // IPv4-mapped (::ffff:a.b.c.d): wildcard or 127.0.0.0/8. | ||
| 292 | return hex.startsWith(`${"0".repeat(16)}FFFF0000`) && procLocalAddress(hex.slice(24)); | ||
| 293 | }; | ||
| 294 | |||
| 295 | /** Listening loopback ports in `/proc/net/tcp{,6}` content, with their socket inodes. */ | ||
| 296 | const parseProcNetTcp = ( | ||
| 297 | raw: string, | ||
| 298 | ): ReadonlyArray<{ readonly port: number; readonly inode: string }> => { | ||
| 299 | const listeners: Array<{ port: number; inode: string }> = []; | ||
| 300 | for (const line of raw.split("\n").slice(1)) { | ||
| 301 | const fields = line.trim().split(/\s+/); | ||
| 302 | const [address, portHex] = fields[1]?.split(":") ?? []; | ||
| 303 | if (fields[3] !== PROC_TCP_LISTEN || !address || !portHex) continue; | ||
| 304 | if (!procLocalAddress(address.toUpperCase())) continue; | ||
| 305 | const port = Number.parseInt(portHex, 16); | ||
| 306 | const inode = fields[9]; | ||
| 307 | if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !inode) continue; | ||
| 308 | listeners.push({ port, inode }); | ||
| 309 | } | ||
| 310 | return listeners; | ||
| 311 | }; | ||
| 312 | |||
| 268 | const serversEqual = ( | 313 | const serversEqual = ( |
| 269 | left: ReadonlyArray<DiscoveredLocalServer>, | 314 | left: ReadonlyArray<DiscoveredLocalServer>, |
| 270 | right: ReadonlyArray<DiscoveredLocalServer>, | 315 | right: ReadonlyArray<DiscoveredLocalServer>, |
| ... | @@ -289,10 +334,14 @@ const serversEqual = ( | ... | @@ -289,10 +334,14 @@ const serversEqual = ( |
| 289 | return true; | 334 | return true; |
| 290 | }; | 335 | }; |
| 291 | 336 | ||
| 337 | const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => | ||
| 338 | PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; | ||
| 339 | |||
| 292 | /** @public Service construction is part of the canonical Effect module API. */ | 340 | /** @public Service construction is part of the canonical Effect module API. */ |
| 293 | export const make = Effect.gen(function* PortDiscoveryMake() { | 341 | export const make = Effect.gen(function* PortDiscoveryMake() { |
| 294 | const net = yield* Net.NetService; | 342 | const net = yield* Net.NetService; |
| 295 | const processRunner = yield* ProcessRunner.ProcessRunner; | 343 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 344 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 296 | const hostPlatform = yield* HostProcessPlatform; | 345 | const hostPlatform = yield* HostProcessPlatform; |
| 297 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); | 346 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); |
| 298 | const stateRef = yield* Ref.make<ScannerState>({ | 347 | const stateRef = yield* Ref.make<ScannerState>({ |
| ... | @@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 302 | }); | 351 | }); |
| 303 | const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map()); | 352 | const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map()); |
| 304 | const scanSemaphore = yield* Semaphore.make(1); | 353 | const scanSemaphore = yield* Semaphore.make(1); |
| 354 | const lsofMissingRef = yield* Ref.make(false); | ||
| 355 | /** Confirmed owners of listening socket inodes. */ | ||
| 356 | const socketOwnersRef = yield* Ref.make< | ||
| 357 | ReadonlyMap<string, { readonly pid: number; readonly processName: string | null }> | ||
| 358 | >(new Map()); | ||
| 359 | /** Fd walks that found no owner, per socket inode; retried up to `SOCKET_OWNER_ATTEMPTS`. */ | ||
| 360 | const socketOwnerMissesRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | ||
| 361 | |||
| 362 | /** Maps socket inodes to the processes holding them, by `/proc/<pid>/fd`. */ | ||
| 363 | const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* ( | ||
| 364 | inodes: ReadonlySet<string>, | ||
| 365 | ) { | ||
| 366 | const owners = new Map<string, { pid: number; processName: string | null }>(); | ||
| 367 | const pids = (yield* fileSystem | ||
| 368 | .readDirectory("/proc") | ||
| 369 | .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter((entry) => | ||
| 370 | /^\d+$/.test(entry), | ||
| 371 | ); | ||
| 372 | let budget = PROC_FD_WALK_LIMIT; | ||
| 373 | for (const pidText of pids) { | ||
| 374 | if (owners.size === inodes.size || budget <= 0) break; | ||
| 375 | // Other users' fds are unreadable; their ports still list, without a pid. | ||
| 376 | const fds = yield* fileSystem | ||
| 377 | .readDirectory(`/proc/${pidText}/fd`) | ||
| 378 | .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])); | ||
| 379 | const walked = fds.slice(0, budget); | ||
| 380 | budget -= walked.length; | ||
| 381 | const links = yield* Effect.forEach( | ||
| 382 | walked, | ||
| 383 | (fd) => | ||
| 384 | fileSystem.readLink(`/proc/${pidText}/fd/${fd}`).pipe(Effect.orElseSucceed(() => "")), | ||
| 385 | { concurrency: 16 }, | ||
| 386 | ); | ||
| 387 | const held = links.flatMap((link) => /^socket:\[(\d+)\]$/.exec(link)?.[1] ?? []); | ||
| 388 | const matched = held.filter((inode) => inodes.has(inode) && !owners.has(inode)); | ||
| 389 | if (matched.length === 0) continue; | ||
| 390 | const processName = yield* fileSystem.readFileString(`/proc/${pidText}/comm`).pipe( | ||
| 391 | Effect.map((name) => name.trim() || null), | ||
| 392 | Effect.orElseSucceed(() => null), | ||
| 393 | ); | ||
| 394 | for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName }); | ||
| 395 | } | ||
| 396 | return owners; | ||
| 397 | }); | ||
| 398 | |||
| 399 | /** | ||
| 400 | * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null | ||
| 401 | * when neither file is readable, so discovery falls back to common ports. | ||
| 402 | */ | ||
| 403 | const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( | ||
| 404 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>, | ||
| 405 | ) { | ||
| 406 | if (hostPlatform !== "linux") return null; | ||
| 407 | const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) => | ||
| 408 | fileSystem.readFileString(path).pipe(Effect.option), | ||
| 409 | ); | ||
| 410 | if (tables.every(Option.isNone)) return null; | ||
| 411 | const listeners = tables.flatMap((table) => | ||
| 412 | Option.isSome(table) ? parseProcNetTcp(table.value) : [], | ||
| 413 | ); | ||
| 414 | const known = yield* Ref.get(socketOwnersRef); | ||
| 415 | const knownMisses = yield* Ref.get(socketOwnerMissesRef); | ||
| 416 | const inodes = new Set(listeners.map((listener) => listener.inode)); | ||
| 417 | // Listeners rarely change, so the fd walk runs only for sockets without a | ||
| 418 | // confirmed owner, and gives up on one after a few walks miss it (another | ||
| 419 | // user's process, or one that exited mid-walk). | ||
| 420 | const unseen = new Set( | ||
| 421 | [...inodes].filter( | ||
| 422 | (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS, | ||
| 423 | ), | ||
| 424 | ); | ||
| 425 | const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen); | ||
| 426 | const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode))); | ||
| 427 | const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode))); | ||
| 428 | for (const inode of unseen) { | ||
| 429 | if (!found.has(inode)) misses.set(inode, (misses.get(inode) ?? 0) + 1); | ||
| 430 | } | ||
| 431 | yield* Ref.set(socketOwnersRef, owners); | ||
| 432 | yield* Ref.set(socketOwnerMissesRef, misses); | ||
| 433 | const seen = new Map<number, DiscoveredLocalServer>(); | ||
| 434 | for (const { port, inode } of listeners) { | ||
| 435 | const owner = owners.get(inode) ?? null; | ||
| 436 | const existing = seen.get(port); | ||
| 437 | if (existing && (existing.pid !== null || owner === null)) continue; | ||
| 438 | seen.set(port, { | ||
| 439 | host: "localhost", | ||
| 440 | port, | ||
| 441 | url: `http://localhost:${port}`, | ||
| 442 | processName: owner?.processName ?? null, | ||
| 443 | pid: owner?.pid ?? null, | ||
| 444 | terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), | ||
| 445 | }); | ||
| 446 | } | ||
| 447 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | ||
| 448 | }); | ||
| 305 | 449 | ||
| 306 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { | 450 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { |
| 307 | const results = yield* Effect.forEach( | 451 | const results = yield* Effect.forEach( |
| ... | @@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 514 | return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); | 658 | return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); |
| 515 | } | 659 | } |
| 516 | const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); | 660 | const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); |
| 661 | if (yield* Ref.get(lsofMissingRef)) { | ||
| 662 | const fromProc = yield* scanProcListeners(terminalByProcessId); | ||
| 663 | return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); | ||
| 664 | } | ||
| 517 | const lsofResult = yield* processRunner | 665 | const lsofResult = yield* processRunner |
| 518 | .run({ | 666 | .run({ |
| 519 | command: "lsof", | 667 | command: "lsof", |
| ... | @@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 525 | .pipe( | 673 | .pipe( |
| 526 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), | 674 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), |
| 527 | Effect.catchTags({ | 675 | Effect.catchTags({ |
| 528 | ProcessSpawnError: recoverLsofProbeFailure, | 676 | // A missing lsof stays missing; later scans skip straight to the fallback. |
| 677 | ProcessSpawnError: (error) => | ||
| 678 | (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe( | ||
| 679 | Effect.andThen(recoverLsofProbeFailure(error)), | ||
| 680 | ), | ||
| 529 | ProcessStdinError: recoverLsofProbeFailure, | 681 | ProcessStdinError: recoverLsofProbeFailure, |
| 530 | ProcessOutputLimitError: recoverLsofProbeFailure, | 682 | ProcessOutputLimitError: recoverLsofProbeFailure, |
| 531 | ProcessReadError: recoverLsofProbeFailure, | 683 | ProcessReadError: recoverLsofProbeFailure, |
| ... | @@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 533 | }), | 685 | }), |
| 534 | ); | 686 | ); |
| 535 | if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); | 687 | if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); |
| 536 | return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); | 688 | const fromProc = yield* scanProcListeners(terminalByProcessId); |
| 689 | return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); | ||
| 537 | }); | 690 | }); |
| 538 | 691 | ||
| 539 | const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( | 692 | const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( |
apps/server/src/preview/PreviewBrowserHost.test.ts+31-5| ... | @@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) | ... | @@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) |
| 20 | const SANDBOX_ABORT = | 20 | const SANDBOX_ABORT = |
| 21 | "[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/"; | 21 | "[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/"; |
| 22 | 22 | ||
| 23 | const lddReporting = (stdout: string) => { | 23 | // What the real loader prints when the browser's `--version` cannot start it. |
| 24 | const LOADER_ERROR = | ||
| 25 | "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; | ||
| 26 | |||
| 27 | const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { | ||
| 24 | const commands: Array<string> = []; | 28 | const commands: Array<string> = []; |
| 25 | const spawner = ChildProcessSpawner.make((command) => | 29 | const spawner = ChildProcessSpawner.make((command) => |
| 26 | Effect.sync(() => { | 30 | Effect.sync(() => { |
| ... | @@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => { | ... | @@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => { |
| 36 | kill: () => Effect.void, | 40 | kill: () => Effect.void, |
| 37 | unref: Effect.succeed(Effect.void), | 41 | unref: Effect.succeed(Effect.void), |
| 38 | stdin: Sink.drain, | 42 | stdin: Sink.drain, |
| 39 | stdout: Stream.make(new TextEncoder().encode(stdout)), | 43 | stdout: Stream.empty, |
| 40 | stderr: Stream.empty, | 44 | stderr: Stream.empty, |
| 41 | all: Stream.empty, | 45 | all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)), |
| 42 | getInputFd: () => Sink.drain, | 46 | getInputFd: () => Sink.drain, |
| 43 | getOutputFd: () => Stream.empty, | 47 | getOutputFd: () => Stream.empty, |
| 44 | }); | 48 | }); |
| ... | @@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => { | ... | @@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => { |
| 47 | return { spawner, commands }; | 51 | return { spawner, commands }; |
| 48 | }; | 52 | }; |
| 49 | 53 | ||
| 50 | const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => { | 54 | const diagnose = (input: { |
| 51 | const { spawner, commands } = lddReporting(input.ldd); | 55 | platform: NodeJS.Platform; |
| 56 | output: string; | ||
| 57 | ldd: string; | ||
| 58 | version?: string; | ||
| 59 | }) => { | ||
| 60 | const { spawner, commands } = hostReporting({ | ||
| 61 | ldd: input.ldd, | ||
| 62 | version: input.version ?? LOADER_ERROR, | ||
| 63 | }); | ||
| 52 | return PreviewBrowserHost.diagnoseLaunchFailure({ | 64 | return PreviewBrowserHost.diagnoseLaunchFailure({ |
| 53 | executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", | 65 | executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", |
| 54 | output: input.output, | 66 | output: input.output, |
| ... | @@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => { | ... | @@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => { |
| 89 | }), | 101 | }), |
| 90 | ); | 102 | ); |
| 91 | 103 | ||
| 104 | it.effect("trusts the browser's own start over ldd", () => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | // NixOS: ldd lists libraries that nix-ld still provides to the real loader. | ||
| 107 | const { error } = yield* diagnose({ | ||
| 108 | platform: "linux", | ||
| 109 | output: "crashed", | ||
| 110 | ldd: LDD_IN_BARE_DEBIAN, | ||
| 111 | version: "Google Chrome for Testing 154.0.8037.92\n", | ||
| 112 | }); | ||
| 113 | expect(error).toBeUndefined(); | ||
| 114 | }), | ||
| 115 | ); | ||
| 116 | |||
| 92 | it.effect("leaves other failures alone", () => | 117 | it.effect("leaves other failures alone", () => |
| 93 | Effect.gen(function* () { | 118 | Effect.gen(function* () { |
| 94 | const linux = yield* diagnose({ | 119 | const linux = yield* diagnose({ |
| 95 | platform: "linux", | 120 | platform: "linux", |
| 96 | output: "crashed", | 121 | output: "crashed", |
| 97 | ldd: "\tlibc.so.6 => /lib/libc.so.6\n", | 122 | ldd: "\tlibc.so.6 => /lib/libc.so.6\n", |
| 123 | version: "Google Chrome for Testing 154.0.8037.92\n", | ||
| 98 | }); | 124 | }); |
| 99 | expect(linux.error).toBeUndefined(); | 125 | expect(linux.error).toBeUndefined(); |
| 100 | const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); | 126 | const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); |
apps/server/src/preview/PreviewBrowserHost.ts+23-10| ... | @@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrow | ... | @@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrow |
| 90 | export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError; | 90 | export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError; |
| 91 | 91 | ||
| 92 | const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm; | 92 | const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm; |
| 93 | /** glibc's loader abort, naming the first library it could not load. */ | ||
| 94 | const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/; | ||
| 93 | 95 | ||
| 94 | /** | 96 | /** |
| 95 | * After a launch fails, names the host setup it is missing: the sandbox, from | 97 | * After a launch fails, names the host setup it is missing: the sandbox, from |
| 96 | * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when | 98 | * Chrome's own abort message, or shared libraries the loader refuses. Undefined |
| 97 | * neither explains it. Linux only; other hosts never need either. | 99 | * when neither explains it. Linux only; other hosts never need either. |
| 98 | */ | 100 | */ |
| 99 | export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")( | 101 | export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")( |
| 100 | function* (input: { | 102 | function* (input: { |
| ... | @@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc | ... | @@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc |
| 114 | }, | 116 | }, |
| 115 | ); | 117 | ); |
| 116 | 118 | ||
| 117 | /** Shared libraries the loader cannot find for `executable`, by `ldd`. */ | 119 | /** |
| 120 | * Shared libraries the loader cannot find for `executable`. The browser's own | ||
| 121 | * `--version` decides whether any are missing; `ldd` only lists them after the | ||
| 122 | * real loader fails, because it can report libraries a host provides another | ||
| 123 | * way (NixOS's nix-ld). | ||
| 124 | */ | ||
| 118 | export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( | 125 | export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( |
| 119 | executable: string, | 126 | executable: string, |
| 120 | ) { | 127 | ) { |
| 121 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 128 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 122 | const report = yield* spawner | 129 | const run = (command: string, args: ReadonlyArray<string>) => |
| 123 | .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" })) | 130 | spawner |
| 124 | .pipe( | 131 | .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true }) |
| 125 | Effect.timeout("5 seconds"), | 132 | .pipe( |
| 126 | Effect.orElseSucceed(() => ""), | 133 | Effect.timeout("5 seconds"), |
| 127 | ); | 134 | Effect.orElseSucceed(() => ""), |
| 128 | return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!); | 135 | ); |
| 136 | const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1]; | ||
| 137 | if (loaderError === undefined) return []; | ||
| 138 | const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map( | ||
| 139 | (match) => match[1]!, | ||
| 140 | ); | ||
| 141 | return listed.length > 0 ? listed : [loaderError]; | ||
| 129 | }); | 142 | }); |
| 130 | 143 | ||
| 131 | /** | 144 | /** |
apps/server/src/preview/ServerBrowser.test.ts+254-1| ... | @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; | ... | @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | 16 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 19 | import * as PubSub from "effect/PubSub"; | ||
| 19 | import * as Queue from "effect/Queue"; | 20 | import * as Queue from "effect/Queue"; |
| 20 | import * as Stream from "effect/Stream"; | 21 | import * as Stream from "effect/Stream"; |
| 21 | import type { BrowserContext, Page } from "playwright-core"; | 22 | import type { BrowserContext, Page } from "playwright-core"; |
| ... | @@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; | ... | @@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; |
| 31 | 32 | ||
| 32 | // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. | 33 | // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. |
| 33 | vi.mock("./ServerBrowserContexts.ts", () => ({ | 34 | vi.mock("./ServerBrowserContexts.ts", () => ({ |
| 35 | presentAsChrome: async () => {}, | ||
| 34 | ServerBrowserContexts: class { | 36 | ServerBrowserContexts: class { |
| 35 | private readonly onClose: ((context: BrowserContext) => void) | undefined; | 37 | private readonly onClose: ((context: BrowserContext) => void) | undefined; |
| 36 | constructor(options: { onContextClose?: (context: BrowserContext) => void }) { | 38 | constructor(options: { onContextClose?: (context: BrowserContext) => void }) { |
| ... | @@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null; | ... | @@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null; |
| 153 | let contextFailure: Error | null = null; | 155 | let contextFailure: Error | null = null; |
| 154 | /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ | 156 | /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ |
| 155 | let desktopRendersNext = false; | 157 | let desktopRendersNext = false; |
| 156 | /** Pages the fake desktop takes back; the channel's detached stream emits them. */ | 158 | /** Pages the fake desktop takes back or returns; the channel's streams emit them. */ |
| 157 | const desktopDetaches = new NodeEvents.EventEmitter(); | 159 | const desktopDetaches = new NodeEvents.EventEmitter(); |
| 158 | const desktopTabs = new Set<string>(); | 160 | const desktopTabs = new Set<string>(); |
| 159 | const desktopRenders = (tabId: string) => { | 161 | const desktopRenders = (tabId: string) => { |
| ... | @@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll( | ... | @@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll( |
| 209 | (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), | 211 | (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), |
| 210 | ), | 212 | ), |
| 211 | ), | 213 | ), |
| 214 | attached: Stream.callback<{ threadId: string; tabId: string }>((queue) => | ||
| 215 | Effect.acquireRelease( | ||
| 216 | Effect.sync(() => { | ||
| 217 | const onAttach = (key: { threadId: string; tabId: string }) => | ||
| 218 | Queue.offerUnsafe(queue, key); | ||
| 219 | desktopDetaches.on("attach", onAttach); | ||
| 220 | return onAttach; | ||
| 221 | }), | ||
| 222 | (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), | ||
| 223 | ), | ||
| 224 | ), | ||
| 212 | isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), | 225 | isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), |
| 213 | endpoint: (key) => | 226 | endpoint: (key) => |
| 214 | Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => | 227 | 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", () => | ... | @@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () => |
| 726 | ).pipe(Effect.provide(layer)), | 739 | ).pipe(Effect.provide(layer)), |
| 727 | ); | 740 | ); |
| 728 | 741 | ||
| 742 | it.live("a tab opened to a file the browser cannot show reports the file to download", () => | ||
| 743 | Effect.scoped( | ||
| 744 | Effect.gen(function* () { | ||
| 745 | const browser = yield* ServerBrowser.ServerBrowser; | ||
| 746 | const manager = yield* Manager.PreviewManager; | ||
| 747 | const events = yield* manager.subscribeEvents; | ||
| 748 | yield* Effect.yieldNow; | ||
| 749 | const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); | ||
| 750 | const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); | ||
| 751 | const page = contexts[0]!.page; | ||
| 752 | const pdf = "https://example.com/paper.pdf"; | ||
| 753 | const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true }; | ||
| 754 | // What Chromium reports when a navigation turns into a download. | ||
| 755 | page.emit("requestfailed", { | ||
| 756 | ...request, | ||
| 757 | frame: () => page, | ||
| 758 | failure: () => ({ errorText: "net::ERR_ABORTED" }), | ||
| 759 | }); | ||
| 760 | // Stands in for Chromium, which writes the file itself. | ||
| 761 | const saved = yield* Queue.unbounded<string>(); | ||
| 762 | const written = Promise.withResolvers<void>(); | ||
| 763 | page.emit("download", { | ||
| 764 | failure: async () => null, | ||
| 765 | saveAs: (path: string) => { | ||
| 766 | Queue.offerUnsafe(saved, path); | ||
| 767 | return written.promise; | ||
| 768 | }, | ||
| 769 | suggestedFilename: () => "paper.pdf", | ||
| 770 | url: () => pdf, | ||
| 771 | }); | ||
| 772 | const fs = yield* FileSystem.FileSystem; | ||
| 773 | yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); | ||
| 774 | written.resolve(); | ||
| 775 | let status = yield* PubSub.take(events); | ||
| 776 | while (status.type !== "failed") status = yield* PubSub.take(events); | ||
| 777 | expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } }); | ||
| 778 | // The tab shows the file, so no separate download toast is offered. | ||
| 779 | expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( | ||
| 780 | false, | ||
| 781 | ); | ||
| 782 | }), | ||
| 783 | ).pipe(Effect.provide(layer)), | ||
| 784 | ); | ||
| 785 | |||
| 786 | it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () => | ||
| 787 | Effect.scoped( | ||
| 788 | Effect.gen(function* () { | ||
| 789 | const browser = yield* ServerBrowser.ServerBrowser; | ||
| 790 | const manager = yield* Manager.PreviewManager; | ||
| 791 | yield* Effect.yieldNow; | ||
| 792 | const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); | ||
| 793 | const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); | ||
| 794 | yield* viewer.input({ type: "takeControl" }); | ||
| 795 | const page = contexts[0]!.page; | ||
| 796 | const pdf = "https://example.com/paper.pdf"; | ||
| 797 | const navigation = { | ||
| 798 | url: () => pdf, | ||
| 799 | method: () => "GET", | ||
| 800 | isNavigationRequest: () => true, | ||
| 801 | frame: () => page, | ||
| 802 | failure: () => ({ errorText: "net::ERR_ABORTED" }), | ||
| 803 | }; | ||
| 804 | page.emit("request", navigation); | ||
| 805 | page.emit("requestfailed", navigation); | ||
| 806 | const saved = yield* Queue.unbounded<string>(); | ||
| 807 | const written = Promise.withResolvers<void>(); | ||
| 808 | page.emit("download", { | ||
| 809 | failure: async () => null, | ||
| 810 | saveAs: (path: string) => { | ||
| 811 | Queue.offerUnsafe(saved, path); | ||
| 812 | return written.promise; | ||
| 813 | }, | ||
| 814 | suggestedFilename: () => "paper.pdf", | ||
| 815 | url: () => pdf, | ||
| 816 | }); | ||
| 817 | const fs = yield* FileSystem.FileSystem; | ||
| 818 | yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); | ||
| 819 | // The person navigates elsewhere while the file is still being written. | ||
| 820 | page.emit("request", { | ||
| 821 | url: () => "https://example.com/next", | ||
| 822 | method: () => "GET", | ||
| 823 | isNavigationRequest: () => true, | ||
| 824 | frame: () => page, | ||
| 825 | }); | ||
| 826 | written.resolve(); | ||
| 827 | let offered = yield* Queue.take(viewer.output); | ||
| 828 | while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); | ||
| 829 | expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" }); | ||
| 830 | }), | ||
| 831 | ).pipe(Effect.provide(layer)), | ||
| 832 | ); | ||
| 833 | |||
| 834 | it.live("a download from a superseded navigation leaves the newer navigation loading", () => | ||
| 835 | Effect.scoped( | ||
| 836 | Effect.gen(function* () { | ||
| 837 | const { broker, tabId } = yield* ready; | ||
| 838 | const page = contexts[0]!.page; | ||
| 839 | const pdf = "https://example.com/paper.pdf"; | ||
| 840 | const navigation = { | ||
| 841 | url: () => pdf, | ||
| 842 | method: () => "GET", | ||
| 843 | isNavigationRequest: () => true, | ||
| 844 | frame: () => page, | ||
| 845 | failure: () => ({ errorText: "net::ERR_ABORTED" }), | ||
| 846 | }; | ||
| 847 | page.emit("request", navigation); | ||
| 848 | page.emit("requestfailed", navigation); | ||
| 849 | const saved = yield* Queue.unbounded<string>(); | ||
| 850 | const written = Promise.withResolvers<void>(); | ||
| 851 | page.emit("download", { | ||
| 852 | failure: async () => null, | ||
| 853 | saveAs: (path: string) => { | ||
| 854 | Queue.offerUnsafe(saved, path); | ||
| 855 | return written.promise; | ||
| 856 | }, | ||
| 857 | suggestedFilename: () => "paper.pdf", | ||
| 858 | url: () => pdf, | ||
| 859 | }); | ||
| 860 | const fs = yield* FileSystem.FileSystem; | ||
| 861 | yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); | ||
| 862 | // The person navigates elsewhere while the file is still being written. | ||
| 863 | page.emit("request", { | ||
| 864 | url: () => "https://example.com/next", | ||
| 865 | method: () => "GET", | ||
| 866 | isNavigationRequest: () => true, | ||
| 867 | frame: () => page, | ||
| 868 | }); | ||
| 869 | written.resolve(); | ||
| 870 | const status = () => | ||
| 871 | broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} }); | ||
| 872 | while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis"); | ||
| 873 | expect((yield* status()).loading).toBe(true); | ||
| 874 | }), | ||
| 875 | ).pipe(Effect.provide(layer)), | ||
| 876 | ); | ||
| 877 | |||
| 878 | it.live( | ||
| 879 | "a late failure from a request that predates tracking leaves a newer navigation loading", | ||
| 880 | () => | ||
| 881 | Effect.scoped( | ||
| 882 | Effect.gen(function* () { | ||
| 883 | const { broker, tabId } = yield* ready; | ||
| 884 | const page = contexts[0]!.page; | ||
| 885 | const navigation = (url: string) => ({ | ||
| 886 | url: () => url, | ||
| 887 | method: () => "GET", | ||
| 888 | isNavigationRequest: () => true, | ||
| 889 | frame: () => page, | ||
| 890 | failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }), | ||
| 891 | }); | ||
| 892 | page.emit("request", navigation("https://example.com/next")); | ||
| 893 | // A popup's first request can start before the tab listens for requests. | ||
| 894 | page.emit("requestfailed", navigation("https://example.com/first")); | ||
| 895 | const status = yield* broker.invoke<PreviewAutomationStatus>({ | ||
| 896 | scope, | ||
| 897 | tabId, | ||
| 898 | operation: "status", | ||
| 899 | input: {}, | ||
| 900 | }); | ||
| 901 | expect(status.loading).toBe(true); | ||
| 902 | }), | ||
| 903 | ).pipe(Effect.provide(layer)), | ||
| 904 | ); | ||
| 905 | |||
| 906 | it.live("an aborted navigation that no download explains stops loading", () => | ||
| 907 | Effect.scoped( | ||
| 908 | Effect.gen(function* () { | ||
| 909 | const { broker, tabId } = yield* ready; | ||
| 910 | const page = contexts[0]!.page; | ||
| 911 | const navigation = { | ||
| 912 | url: () => "https://example.com/cancelled", | ||
| 913 | method: () => "GET", | ||
| 914 | isNavigationRequest: () => true, | ||
| 915 | frame: () => page, | ||
| 916 | failure: () => ({ errorText: "net::ERR_ABORTED" }), | ||
| 917 | }; | ||
| 918 | const status = () => | ||
| 919 | broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} }); | ||
| 920 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | ||
| 921 | try { | ||
| 922 | page.emit("request", navigation); | ||
| 923 | page.emit("requestfailed", navigation); | ||
| 924 | expect((yield* status()).loading).toBe(true); | ||
| 925 | vi.advanceTimersByTime(5_000); | ||
| 926 | } finally { | ||
| 927 | vi.useRealTimers(); | ||
| 928 | } | ||
| 929 | expect((yield* status()).loading).toBe(false); | ||
| 930 | }), | ||
| 931 | ).pipe(Effect.provide(layer)), | ||
| 932 | ); | ||
| 933 | |||
| 934 | it.live("a popup from a person's click is shown to that person, with its opener kept", () => | ||
| 935 | Effect.scoped( | ||
| 936 | Effect.gen(function* () { | ||
| 937 | const browser = yield* ServerBrowser.ServerBrowser; | ||
| 938 | const manager = yield* Manager.PreviewManager; | ||
| 939 | yield* Effect.yieldNow; | ||
| 940 | const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); | ||
| 941 | const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false)); | ||
| 942 | const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); | ||
| 943 | yield* viewer.input({ type: "takeControl" }); | ||
| 944 | const opener = contexts[0]!.page; | ||
| 945 | const popup = makeContext(); | ||
| 946 | opener.emit("popup", popup.page); | ||
| 947 | let shown = yield* Queue.take(viewer.output); | ||
| 948 | while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); | ||
| 949 | const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; | ||
| 950 | expect(sessions.map((session) => session.tabId)).toEqual( | ||
| 951 | expect.arrayContaining([opened.tabId, shown.tabId]), | ||
| 952 | ); | ||
| 953 | expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe( | ||
| 954 | false, | ||
| 955 | ); | ||
| 956 | expect(opener.close).not.toHaveBeenCalled(); | ||
| 957 | }), | ||
| 958 | ).pipe(Effect.provide(layer)), | ||
| 959 | ); | ||
| 960 | |||
| 729 | it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => | 961 | it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => |
| 730 | Effect.scoped( | 962 | Effect.scoped( |
| 731 | Effect.gen(function* () { | 963 | Effect.gen(function* () { |
| ... | @@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( | ... | @@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( |
| 1090 | }), | 1322 | }), |
| 1091 | ).pipe(Effect.provide(layer)), | 1323 | ).pipe(Effect.provide(layer)), |
| 1092 | ); | 1324 | ); |
| 1325 | |||
| 1326 | it.live("a desktop page that comes back reconnects without waiting for a viewer", () => | ||
| 1327 | Effect.scoped( | ||
| 1328 | Effect.gen(function* () { | ||
| 1329 | const manager = yield* Manager.PreviewManager; | ||
| 1330 | yield* ServerBrowser.ServerBrowser; | ||
| 1331 | yield* Effect.yieldNow; | ||
| 1332 | desktopRendersNext = true; | ||
| 1333 | const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); | ||
| 1334 | while (desktopConnections.length === 0) yield* Effect.yieldNow; | ||
| 1335 | while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; | ||
| 1336 | // DevTools opened, then closed: the desktop withdraws the page and returns it. | ||
| 1337 | desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); | ||
| 1338 | while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow; | ||
| 1339 | desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId }); | ||
| 1340 | // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients. | ||
| 1341 | while (desktopConnections.length < 2) yield* Effect.yieldNow; | ||
| 1342 | expect(desktopConnections).toHaveLength(2); | ||
| 1343 | }), | ||
| 1344 | ).pipe(Effect.provide(layer)), | ||
| 1345 | ); |
apps/server/src/preview/ServerBrowser.ts+141-7| ... | @@ -36,7 +36,11 @@ import { | ... | @@ -36,7 +36,11 @@ import { |
| 36 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, | 36 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, |
| 37 | type PreviewAppearancePreference, | 37 | type PreviewAppearancePreference, |
| 38 | } from "@t3tools/contracts"; | 38 | } from "@t3tools/contracts"; |
| 39 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 39 | import { |
| 40 | HostProcessArchitecture, | ||
| 41 | HostProcessEnvironment, | ||
| 42 | HostProcessPlatform, | ||
| 43 | } from "@t3tools/shared/hostProcess"; | ||
| 40 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 44 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 41 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | 45 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 42 | import * as NodeCrypto from "node:crypto"; | 46 | import * as NodeCrypto from "node:crypto"; |
| ... | @@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts"; | ... | @@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts"; |
| 73 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; | 77 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; |
| 74 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | 78 | import * as PreviewBrowser from "./PreviewBrowser.ts"; |
| 75 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; | 79 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; |
| 76 | import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; | 80 | import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; |
| 77 | import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; | 81 | import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; |
| 78 | 82 | ||
| 79 | const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; | 83 | const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; |
| ... | @@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput = | ... | @@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput = |
| 185 | readonly accept: string; | 189 | readonly accept: string; |
| 186 | } | 190 | } |
| 187 | | { readonly _tag: "fileChooserClosed"; readonly id: string } | 191 | | { readonly _tag: "fileChooserClosed"; readonly id: string } |
| 192 | /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */ | ||
| 193 | | { readonly _tag: "popup"; readonly tabId: string } | ||
| 188 | | { | 194 | | { |
| 189 | readonly _tag: "download"; | 195 | readonly _tag: "download"; |
| 190 | readonly id: string; | 196 | readonly id: string; |
| ... | @@ -311,6 +317,17 @@ interface ServerTab { | ... | @@ -311,6 +317,17 @@ interface ServerTab { |
| 311 | closing: boolean; | 317 | closing: boolean; |
| 312 | recording: Recording | null; | 318 | recording: Recording | null; |
| 313 | initialNavigation: Promise<void> | null; | 319 | initialNavigation: Promise<void> | null; |
| 320 | /** Counts main-frame navigation requests, so late events can tell they were superseded. */ | ||
| 321 | navigationGeneration: number; | ||
| 322 | /** | ||
| 323 | * The latest main-frame navigation, when it aborted, to tell a navigation's | ||
| 324 | * download from a page's. The timer settles it if no download follows. | ||
| 325 | */ | ||
| 326 | abortedNavigation: { | ||
| 327 | readonly url: string; | ||
| 328 | readonly generation: number; | ||
| 329 | readonly timer: ReturnType<typeof setTimeout>; | ||
| 330 | } | null; | ||
| 314 | /** The latest queued start, so a stop can find a recording still starting. */ | 331 | /** The latest queued start, so a stop can find a recording still starting. */ |
| 315 | recordingStart: Promise<Recording> | null; | 332 | recordingStart: Promise<Recording> | null; |
| 316 | /** Serializes captures and recording start/stop. */ | 333 | /** Serializes captures and recording start/stop. */ |
| ... | @@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; | ... | @@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; |
| 336 | const IDLE_SWEEP_INTERVAL = "1 minute"; | 353 | const IDLE_SWEEP_INTERVAL = "1 minute"; |
| 337 | 354 | ||
| 338 | const DOWNLOAD_LIMIT = 20; | 355 | const DOWNLOAD_LIMIT = 20; |
| 356 | /** How long an aborted navigation waits for the download that usually explains it. */ | ||
| 357 | const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; | ||
| 339 | const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; | 358 | const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; |
| 340 | 359 | ||
| 341 | const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; | 360 | const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; |
| ... | @@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => { | ... | @@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => { |
| 435 | 454 | ||
| 436 | const make = Effect.gen(function* () { | 455 | const make = Effect.gen(function* () { |
| 437 | const config = yield* ServerConfig.ServerConfig; | 456 | const config = yield* ServerConfig.ServerConfig; |
| 457 | const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture }; | ||
| 438 | const manager = yield* PreviewManager.PreviewManager; | 458 | const manager = yield* PreviewManager.PreviewManager; |
| 439 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 459 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 440 | const environment = yield* ServerEnvironment.ServerEnvironment; | 460 | const environment = yield* ServerEnvironment.ServerEnvironment; |
| ... | @@ -646,6 +666,7 @@ const make = Effect.gen(function* () { | ... | @@ -646,6 +666,7 @@ const make = Effect.gen(function* () { |
| 646 | if (tabs.get(key) !== tab) return; | 666 | if (tabs.get(key) !== tab) return; |
| 647 | tabs.delete(key); | 667 | tabs.delete(key); |
| 648 | tab.closing = true; | 668 | tab.closing = true; |
| 669 | clearAbortedNavigation(tab); | ||
| 649 | // A desktop page outlives the connection unless its session closed with it. | 670 | // A desktop page outlives the connection unless its session closed with it. |
| 650 | const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; | 671 | const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; |
| 651 | for (const viewer of tab.viewers) viewer.push({ _tag: end }); | 672 | for (const viewer of tab.viewers) viewer.push({ _tag: end }); |
| ... | @@ -723,6 +744,9 @@ const make = Effect.gen(function* () { | ... | @@ -723,6 +744,9 @@ const make = Effect.gen(function* () { |
| 723 | if (!desktop) await prepareContext(context); | 744 | if (!desktop) await prepareContext(context); |
| 724 | const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); | 745 | const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); |
| 725 | const cdp = await context.newCDPSession(page); | 746 | const cdp = await context.newCDPSession(page); |
| 747 | // The desktop's page is already a normal browser. A popup's first request | ||
| 748 | // went out before it became a tab; everything after presents as Chrome. | ||
| 749 | if (!desktop) await presentAsChrome(cdp, host); | ||
| 726 | page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); | 750 | page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); |
| 727 | page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); | 751 | page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); |
| 728 | const control = new SessionControl(snapshot.automationOwner ?? null, () => | 752 | const control = new SessionControl(snapshot.automationOwner ?? null, () => |
| ... | @@ -755,6 +779,8 @@ const make = Effect.gen(function* () { | ... | @@ -755,6 +779,8 @@ const make = Effect.gen(function* () { |
| 755 | recording: null, | 779 | recording: null, |
| 756 | recordingStart: null, | 780 | recordingStart: null, |
| 757 | initialNavigation: null, | 781 | initialNavigation: null, |
| 782 | navigationGeneration: 0, | ||
| 783 | abortedNavigation: null, | ||
| 758 | captureLock: Promise.resolve(), | 784 | captureLock: Promise.resolve(), |
| 759 | capturing: 0, | 785 | capturing: 0, |
| 760 | }; | 786 | }; |
| ... | @@ -764,8 +790,11 @@ const make = Effect.gen(function* () { | ... | @@ -764,8 +790,11 @@ const make = Effect.gen(function* () { |
| 764 | await applyRendering(tab, snapshot); | 790 | await applyRendering(tab, snapshot); |
| 765 | const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => | 791 | const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => |
| 766 | request.isNavigationRequest() && request.frame() === page.mainFrame(); | 792 | request.isNavigationRequest() && request.frame() === page.mainFrame(); |
| 793 | const navigationGenerations = new WeakMap<object, number>(); | ||
| 767 | page.on("request", (request) => { | 794 | page.on("request", (request) => { |
| 768 | if (!isMainNavigation(request)) return; | 795 | if (!isMainNavigation(request)) return; |
| 796 | navigationGenerations.set(request, ++tab.navigationGeneration); | ||
| 797 | clearAbortedNavigation(tab); | ||
| 769 | tab.loading = true; | 798 | tab.loading = true; |
| 770 | report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); | 799 | report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); |
| 771 | }); | 800 | }); |
| ... | @@ -787,7 +816,28 @@ const make = Effect.gen(function* () { | ... | @@ -787,7 +816,28 @@ const make = Effect.gen(function* () { |
| 787 | errorText, | 816 | errorText, |
| 788 | timestamp: new Date().toISOString(), | 817 | timestamp: new Date().toISOString(), |
| 789 | }); | 818 | }); |
| 790 | if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return; | 819 | if (!isMainNavigation(request)) return; |
| 820 | // A navigation a newer one replaced leaves the status to the newer one. A | ||
| 821 | // popup's first request can predate the listener, so it is untracked and | ||
| 822 | // superseded by any tracked one. | ||
| 823 | const generation = navigationGenerations.get(request) ?? 0; | ||
| 824 | if (generation !== tab.navigationGeneration) return; | ||
| 825 | // An aborted navigation is usually a download; its `download` event settles the status. | ||
| 826 | if (errorText.includes("ERR_ABORTED")) { | ||
| 827 | const url = request.url(); | ||
| 828 | const navigationGeneration = tab.navigationGeneration; | ||
| 829 | clearAbortedNavigation(tab); | ||
| 830 | tab.abortedNavigation = { | ||
| 831 | url, | ||
| 832 | generation: navigationGeneration, | ||
| 833 | timer: setTimeout(() => { | ||
| 834 | if (tab.abortedNavigation?.generation !== navigationGeneration) return; | ||
| 835 | tab.abortedNavigation = null; | ||
| 836 | if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined); | ||
| 837 | }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS), | ||
| 838 | }; | ||
| 839 | return; | ||
| 840 | } | ||
| 791 | tab.loading = false; | 841 | tab.loading = false; |
| 792 | const { code, description } = ServerBrowserPage.parseNetError(errorText); | 842 | const { code, description } = ServerBrowserPage.parseNetError(errorText); |
| 793 | report(tab, { | 843 | report(tab, { |
| ... | @@ -854,9 +904,46 @@ const make = Effect.gen(function* () { | ... | @@ -854,9 +904,46 @@ const make = Effect.gen(function* () { |
| 854 | NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), | 904 | NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), |
| 855 | ); | 905 | ); |
| 856 | 906 | ||
| 907 | const clearAbortedNavigation = (tab: ServerTab) => { | ||
| 908 | if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer); | ||
| 909 | tab.abortedNavigation = null; | ||
| 910 | }; | ||
| 911 | |||
| 912 | /** | ||
| 913 | * Settles the status of a navigation that aborted, usually into a download. | ||
| 914 | * A tab opened straight to the file has no page to show, so it reports the | ||
| 915 | * file with its download; a page that linked to it keeps showing itself. | ||
| 916 | */ | ||
| 917 | const settleAbortedNavigation = ( | ||
| 918 | tab: ServerTab, | ||
| 919 | generation: number, | ||
| 920 | url: string, | ||
| 921 | offered: { readonly id: string; readonly fileName: string } | undefined, | ||
| 922 | ) => { | ||
| 923 | // A newer navigation owns the status. | ||
| 924 | if (tab.navigationGeneration !== generation) return; | ||
| 925 | tab.loading = false; | ||
| 926 | if (tab.page.url() !== "about:blank") { | ||
| 927 | void reportLoaded(tab); | ||
| 928 | return; | ||
| 929 | } | ||
| 930 | report(tab, { | ||
| 931 | _tag: "LoadFailed", | ||
| 932 | url: url.slice(0, 2048), | ||
| 933 | title: "", | ||
| 934 | code: -3, | ||
| 935 | description: "ERR_ABORTED", | ||
| 936 | ...(offered === undefined ? {} : { download: offered }), | ||
| 937 | }); | ||
| 938 | }; | ||
| 939 | |||
| 857 | const saveDownload = async (tab: ServerTab, download: Download) => { | 940 | const saveDownload = async (tab: ServerTab, download: Download) => { |
| 858 | const id = NodeCrypto.randomUUID(); | 941 | const id = NodeCrypto.randomUUID(); |
| 859 | const path = NodePath.join(downloadDir(tab), id); | 942 | const path = NodePath.join(downloadDir(tab), id); |
| 943 | const navigation = | ||
| 944 | download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null; | ||
| 945 | if (navigation !== null) clearAbortedNavigation(tab); | ||
| 946 | let offered: { readonly id: string; readonly fileName: string } | undefined; | ||
| 860 | try { | 947 | try { |
| 861 | if (await download.failure()) return; | 948 | if (await download.failure()) return; |
| 862 | await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); | 949 | await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); |
| ... | @@ -878,6 +965,12 @@ const make = Effect.gen(function* () { | ... | @@ -878,6 +965,12 @@ const make = Effect.gen(function* () { |
| 878 | for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { | 965 | for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { |
| 879 | await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); | 966 | await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); |
| 880 | } | 967 | } |
| 968 | offered = { id, fileName: saved.fileName }; | ||
| 969 | // A tab whose own address was the file shows it in place (see | ||
| 970 | // settleAbortedNavigation), unless it navigated on while the file saved. | ||
| 971 | const shownInTab = | ||
| 972 | navigation === tab.navigationGeneration && tab.page.url() === "about:blank"; | ||
| 973 | if (shownInTab) return; | ||
| 881 | // Only the person driving the page gets the file offered; agents read it from status. | 974 | // Only the person driving the page gets the file offered; agents read it from status. |
| 882 | const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); | 975 | const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); |
| 883 | controller?.push({ | 976 | controller?.push({ |
| ... | @@ -889,6 +982,10 @@ const make = Effect.gen(function* () { | ... | @@ -889,6 +982,10 @@ const make = Effect.gen(function* () { |
| 889 | } catch (cause) { | 982 | } catch (cause) { |
| 890 | await NodeFSP.rm(path, { force: true }).catch(constVoid); | 983 | await NodeFSP.rm(path, { force: true }).catch(constVoid); |
| 891 | runFork(Effect.logWarning("server preview download failed", { cause })); | 984 | runFork(Effect.logWarning("server preview download failed", { cause })); |
| 985 | } finally { | ||
| 986 | if (navigation !== null && !tab.closing) { | ||
| 987 | settleAbortedNavigation(tab, navigation, download.url(), offered); | ||
| 988 | } | ||
| 892 | } | 989 | } |
| 893 | }; | 990 | }; |
| 894 | 991 | ||
| ... | @@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () { | ... | @@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () { |
| 1009 | return; | 1106 | return; |
| 1010 | } | 1107 | } |
| 1011 | const url = popup.url(); | 1108 | const url = popup.url(); |
| 1109 | // Captured now: the person whose click opened the popup gets switched to it. | ||
| 1110 | const controller = [...opener.viewers].find( | ||
| 1111 | (viewer) => viewer.id === opener.control.controller, | ||
| 1112 | ); | ||
| 1012 | await Effect.runPromise( | 1113 | await Effect.runPromise( |
| 1013 | manager.open({ | 1114 | manager.open({ |
| 1014 | threadId: opener.threadId, | 1115 | threadId: opener.threadId, |
| ... | @@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () { | ... | @@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () { |
| 1025 | openerTabId: opener.tabId, | 1126 | openerTabId: opener.tabId, |
| 1026 | }), | 1127 | }), |
| 1027 | }), | 1128 | }), |
| 1028 | ).catch(async () => { | 1129 | ).then( |
| 1029 | await popup.close().catch(constVoid); | 1130 | (snapshot) => { |
| 1030 | }); | 1131 | // The opener stays open behind it, so `window.opener` can still report back. |
| 1132 | if (opener.control.agentId === null) | ||
| 1133 | controller?.push({ _tag: "popup", tabId: snapshot.tabId }); | ||
| 1134 | }, | ||
| 1135 | async () => { | ||
| 1136 | await popup.close().catch(constVoid); | ||
| 1137 | }, | ||
| 1138 | ); | ||
| 1031 | }; | 1139 | }; |
| 1032 | 1140 | ||
| 1033 | const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => { | 1141 | const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => { |
| ... | @@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () { | ... | @@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () { |
| 1187 | } | 1295 | } |
| 1188 | await navigation.catch((cause: unknown) => { | 1296 | await navigation.catch((cause: unknown) => { |
| 1189 | const message = cause instanceof Error ? cause.message : String(cause); | 1297 | const message = cause instanceof Error ? cause.message : String(cause); |
| 1298 | if (message.includes("Download is starting")) { | ||
| 1299 | throw new ServerBrowserPage.ServerBrowserOperationError( | ||
| 1300 | "PreviewAutomationExecutionError", | ||
| 1301 | `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`, | ||
| 1302 | ); | ||
| 1303 | } | ||
| 1190 | if (/ERR_[A-Z_]+/.test(message)) { | 1304 | if (/ERR_[A-Z_]+/.test(message)) { |
| 1191 | throw new ServerBrowserPage.ServerBrowserOperationError( | 1305 | throw new ServerBrowserPage.ServerBrowserOperationError( |
| 1192 | "PreviewAutomationExecutionError", | 1306 | "PreviewAutomationExecutionError", |
| ... | @@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () { | ... | @@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () { |
| 1693 | ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), | 1807 | ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), |
| 1694 | ); | 1808 | ); |
| 1695 | case "evaluate": | 1809 | case "evaluate": |
| 1696 | return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput); | 1810 | return ServerBrowserPage.evaluate( |
| 1811 | tab.cdp, | ||
| 1812 | input as PreviewAutomationEvaluateInput, | ||
| 1813 | request.timeoutMs, | ||
| 1814 | ); | ||
| 1697 | case "waitFor": | 1815 | case "waitFor": |
| 1698 | return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); | 1816 | return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); |
| 1699 | case "recordingStart": { | 1817 | case "recordingStart": { |
| ... | @@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () { | ... | @@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () { |
| 2142 | ), | 2260 | ), |
| 2143 | Effect.forkScoped, | 2261 | Effect.forkScoped, |
| 2144 | ); | 2262 | ); |
| 2263 | // The page came back, for example after its DevTools closed. Reconnect now | ||
| 2264 | // rather than on the next viewer or agent, or the tab's URL and title stop | ||
| 2265 | // reaching every client in the meantime. | ||
| 2266 | yield* desktopChannel.attached.pipe( | ||
| 2267 | Stream.runForEach((key) => | ||
| 2268 | Effect.gen(function* () { | ||
| 2269 | if (tabs.has(tabKey(key.threadId, key.tabId))) return; | ||
| 2270 | const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) }); | ||
| 2271 | const snapshot = sessions.find( | ||
| 2272 | (session) => session.tabId === key.tabId && session.runtime === "server", | ||
| 2273 | ); | ||
| 2274 | if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); | ||
| 2275 | }).pipe(Effect.ignore), | ||
| 2276 | ), | ||
| 2277 | Effect.forkScoped, | ||
| 2278 | ); | ||
| 2145 | yield* Effect.sync(closeIdleAgentTabs).pipe( | 2279 | yield* Effect.sync(closeIdleAgentTabs).pipe( |
| 2146 | Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), | 2280 | Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), |
| 2147 | Effect.forkScoped, | 2281 | Effect.forkScoped, |
apps/server/src/preview/ServerBrowserContexts.ts+64-2| ... | @@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; | ... | @@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; |
| 3 | import { constVoid } from "effect/Function"; | 3 | import { constVoid } from "effect/Function"; |
| 4 | import * as NodeFSP from "node:fs/promises"; | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeModule from "node:module"; | 5 | import * as NodeModule from "node:module"; |
| 6 | import * as NodeOS from "node:os"; | ||
| 6 | import * as NodePath from "node:path"; | 7 | import * as NodePath from "node:path"; |
| 7 | import type { Browser, BrowserContext } from "playwright-core"; | 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; |
| 8 | 9 | ||
| 9 | import { sandboxDisabled } from "./PreviewBrowserHost.ts"; | 10 | import { sandboxDisabled } from "./PreviewBrowserHost.ts"; |
| 10 | 11 | ||
| ... | @@ -23,6 +24,61 @@ interface Options { | ... | @@ -23,6 +24,61 @@ interface Options { |
| 23 | readonly onContextClose?: (context: BrowserContext) => void; | 24 | readonly onContextClose?: (context: BrowserContext) => void; |
| 24 | } | 25 | } |
| 25 | 26 | ||
| 27 | const UA_PLATFORMS: Partial<Record<NodeJS.Platform, string>> = { | ||
| 28 | darwin: "macOS", | ||
| 29 | linux: "Linux", | ||
| 30 | win32: "Windows", | ||
| 31 | }; | ||
| 32 | |||
| 33 | /** | ||
| 34 | * The headless shell's identity as the Chrome it is built from: its user agent | ||
| 35 | * without `HeadlessChrome`, and client hints whose brands agree with it. Sites | ||
| 36 | * block a user agent that disagrees with `Sec-CH-UA`, so both change together. | ||
| 37 | */ | ||
| 38 | const chromeIdentity = ( | ||
| 39 | headlessUserAgent: string, | ||
| 40 | host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string }, | ||
| 41 | ) => { | ||
| 42 | const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent); | ||
| 43 | const fullVersion = version?.[1]; | ||
| 44 | const major = version?.[2]; | ||
| 45 | if (!version || !fullVersion || !major) return null; | ||
| 46 | // Chrome's GREASE brand and order for this major version, as the shell reports them. | ||
| 47 | const brands = (names: (version: string) => string) => [ | ||
| 48 | { brand: "Chromium", version: names(major) }, | ||
| 49 | { brand: "Google Chrome", version: names(major) }, | ||
| 50 | { brand: "Not A(Brand", version: names("99") }, | ||
| 51 | ]; | ||
| 52 | return { | ||
| 53 | // Chrome reduces its user agent to the major version. | ||
| 54 | userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`), | ||
| 55 | userAgentMetadata: { | ||
| 56 | brands: brands((value) => value), | ||
| 57 | fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)), | ||
| 58 | platform: UA_PLATFORMS[host.platform] ?? "Unknown", | ||
| 59 | platformVersion: host.platform === "linux" ? host.release : "", | ||
| 60 | architecture: host.arch.startsWith("arm") ? "arm" : "x86", | ||
| 61 | bitness: host.arch.endsWith("64") ? "64" : "32", | ||
| 62 | model: "", | ||
| 63 | mobile: false, | ||
| 64 | wow64: false, | ||
| 65 | }, | ||
| 66 | }; | ||
| 67 | }; | ||
| 68 | |||
| 69 | /** | ||
| 70 | * Presents a headless page as plain Chrome. Applies to the page, its frames, | ||
| 71 | * and its workers; a popup gets its own call once it becomes a tab. | ||
| 72 | */ | ||
| 73 | export const presentAsChrome = async ( | ||
| 74 | cdp: CDPSession, | ||
| 75 | host: { readonly platform: NodeJS.Platform; readonly arch: string }, | ||
| 76 | ) => { | ||
| 77 | const { userAgent } = await cdp.send("Browser.getVersion"); | ||
| 78 | const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() }); | ||
| 79 | if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); | ||
| 80 | }; | ||
| 81 | |||
| 26 | /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ | 82 | /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ |
| 27 | export class ServerBrowserContexts { | 83 | export class ServerBrowserContexts { |
| 28 | private readonly options: Options; | 84 | private readonly options: Options; |
| ... | @@ -42,7 +98,13 @@ export class ServerBrowserContexts { | ... | @@ -42,7 +98,13 @@ export class ServerBrowserContexts { |
| 42 | return { | 98 | return { |
| 43 | executablePath, | 99 | executablePath, |
| 44 | env, | 100 | env, |
| 45 | args: ["--disable-gpu", "--force-device-scale-factor=2"], | 101 | // People drive these tabs, so they must not announce automation (navigator.webdriver). |
| 102 | args: [ | ||
| 103 | "--disable-gpu", | ||
| 104 | "--force-device-scale-factor=2", | ||
| 105 | "--disable-blink-features=AutomationControlled", | ||
| 106 | ], | ||
| 107 | ignoreDefaultArgs: ["--enable-automation"], | ||
| 46 | headless: true, | 108 | headless: true, |
| 47 | // Only an explicit operator opt-out disables sandboxing. Launch errors never do. | 109 | // Only an explicit operator opt-out disables sandboxing. Launch errors never do. |
| 48 | chromiumSandbox: !sandboxDisabled(env), | 110 | chromiumSandbox: !sandboxDisabled(env), |
apps/server/src/preview/ServerBrowserPage.test.ts+31| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | } from "playwright-core"; | 7 | } from "playwright-core"; |
| 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; | 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 9 | 9 | ||
| 10 | import { presentAsChrome } from "./ServerBrowserContexts.ts"; | ||
| 10 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; | 11 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; |
| 11 | 12 | ||
| 12 | describe("server browser element refs", () => { | 13 | describe("server browser element refs", () => { |
| ... | @@ -245,4 +246,34 @@ describe("server browser element refs", () => { | ... | @@ -245,4 +246,34 @@ describe("server browser element refs", () => { |
| 245 | ]); | 246 | ]); |
| 246 | expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); | 247 | expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); |
| 247 | }); | 248 | }); |
| 249 | |||
| 250 | it("presents a headless page as Chrome, with client hints that agree", async () => { | ||
| 251 | await presentAsChrome(cdp, { platform: "linux", arch: "x64" }); | ||
| 252 | // userAgentData exists only in secure contexts; https comes from a route. | ||
| 253 | let secChUa: string | undefined; | ||
| 254 | await page.route("https://example.test/", (route) => { | ||
| 255 | secChUa = route.request().headers()["sec-ch-ua"]; | ||
| 256 | return route.fulfill({ contentType: "text/html", body: "<p>hi</p>" }); | ||
| 257 | }); | ||
| 258 | await page.goto("https://example.test/"); | ||
| 259 | const identity = (await page.evaluate(`(async () => ({ | ||
| 260 | userAgent: navigator.userAgent, | ||
| 261 | brands: navigator.userAgentData.brands.map((brand) => brand.brand), | ||
| 262 | full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"])) | ||
| 263 | .fullVersionList.map((brand) => brand.brand), | ||
| 264 | }))()`)) as { userAgent: string; brands: string[]; full: string[] }; | ||
| 265 | expect(identity.userAgent).not.toContain("Headless"); | ||
| 266 | expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /); | ||
| 267 | expect(identity.brands).toContain("Google Chrome"); | ||
| 268 | expect([...identity.brands, ...identity.full].join()).not.toContain("Headless"); | ||
| 269 | expect(secChUa).toContain('"Google Chrome"'); | ||
| 270 | expect(secChUa).not.toContain("Headless"); | ||
| 271 | }); | ||
| 272 | |||
| 273 | it("stops an evaluation at its deadline so the page answers the next one", async () => { | ||
| 274 | await expect( | ||
| 275 | ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200), | ||
| 276 | ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" }); | ||
| 277 | expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); | ||
| 278 | }); | ||
| 248 | }); | 279 | }); |
apps/server/src/preview/ServerBrowserPage.ts+26-2| ... | @@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => | ... | @@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => |
| 410 | await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); | 410 | await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); |
| 411 | }; | 411 | }; |
| 412 | 412 | ||
| 413 | export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => { | 413 | /** |
| 414 | const result = await cdp.send("Runtime.evaluate", { | 414 | * Runs an agent's expression, bounded by its request's deadline. Past it, the |
| 415 | * page's script is terminated and the call fails, so the tab's control queue | ||
| 416 | * frees for the next request instead of waiting on the script. | ||
| 417 | */ | ||
| 418 | export const evaluate = async ( | ||
| 419 | cdp: CDPSession, | ||
| 420 | input: PreviewAutomationEvaluateInput, | ||
| 421 | timeoutMs: number, | ||
| 422 | ) => { | ||
| 423 | let timer: ReturnType<typeof setTimeout> | undefined; | ||
| 424 | const expired = new Promise<never>((_, reject) => { | ||
| 425 | timer = setTimeout(() => { | ||
| 426 | // Stops a busy script; an awaited promise is abandoned and settles unread. | ||
| 427 | void cdp.send("Runtime.terminateExecution").catch(constVoid); | ||
| 428 | reject( | ||
| 429 | new ServerBrowserOperationError( | ||
| 430 | "PreviewAutomationTimeoutError", | ||
| 431 | `Evaluation did not finish within ${timeoutMs}ms and was stopped.`, | ||
| 432 | ), | ||
| 433 | ); | ||
| 434 | }, timeoutMs); | ||
| 435 | }); | ||
| 436 | const evaluation = cdp.send("Runtime.evaluate", { | ||
| 415 | expression: input.expression, | 437 | expression: input.expression, |
| 416 | awaitPromise: input.awaitPromise ?? true, | 438 | awaitPromise: input.awaitPromise ?? true, |
| 417 | returnByValue: input.returnByValue ?? true, | 439 | returnByValue: input.returnByValue ?? true, |
| 418 | }); | 440 | }); |
| 441 | void evaluation.catch(constVoid); | ||
| 442 | const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); | ||
| 419 | if (result.exceptionDetails) { | 443 | if (result.exceptionDetails) { |
| 420 | throw new ServerBrowserOperationError( | 444 | throw new ServerBrowserOperationError( |
| 421 | "PreviewAutomationExecutionError", | 445 | "PreviewAutomationExecutionError", |
apps/server/src/preview/ServerBrowserStream.ts+1| ... | @@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => | ... | @@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => |
| 156 | case "download": | 156 | case "download": |
| 157 | case "fileChooser": | 157 | case "fileChooser": |
| 158 | case "fileChooserClosed": | 158 | case "fileChooserClosed": |
| 159 | case "popup": | ||
| 159 | case "pointer": | 160 | case "pointer": |
| 160 | case "probe": { | 161 | case "probe": { |
| 161 | const { _tag: type, ...data } = output; | 162 | const { _tag: type, ...data } = output; |
apps/server/src/project/ProjectService.deletion.test.ts+14-4| ... | @@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or | ... | @@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or |
| 197 | assert.lengthOf(partialEvents, 1); | 197 | assert.lengthOf(partialEvents, 1); |
| 198 | assert.equal(partialEvents[0]?.stream_id, firstThreadId); | 198 | assert.equal(partialEvents[0]?.stream_id, firstThreadId); |
| 199 | assert.equal(partialEvents[0]?.event_type, "thread.deleted"); | 199 | assert.equal(partialEvents[0]?.event_type, "thread.deleted"); |
| 200 | assert.lengthOf(partialCleanup, 1); | 200 | assert.deepEqual( |
| 201 | assert.equal(partialCleanup[0]?.thread_id, firstThreadId); | 201 | partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), |
| 202 | assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup"); | 202 | [ |
| 203 | [firstThreadId, "preview.cleanup"], | ||
| 204 | [firstThreadId, "terminal.cleanup"], | ||
| 205 | ], | ||
| 206 | ); | ||
| 203 | 207 | ||
| 204 | const deletedProject = yield* service.delete(input); | 208 | const deletedProject = yield* service.delete(input); |
| 205 | assert.isNotNull(deletedProject.deletedAt); | 209 | assert.isNotNull(deletedProject.deletedAt); |
| ... | @@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or | ... | @@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or |
| 220 | assert.deepEqual(finalEvents[0], partialEvents[0]); | 224 | assert.deepEqual(finalEvents[0], partialEvents[0]); |
| 221 | assert.equal(finalEvents[2]?.command_id, commandId); | 225 | assert.equal(finalEvents[2]?.command_id, commandId); |
| 222 | const finalCleanup = yield* readCleanup; | 226 | const finalCleanup = yield* readCleanup; |
| 223 | assert.lengthOf(finalCleanup, 2); | 227 | assert.lengthOf(finalCleanup, 4); |
| 224 | assert.deepEqual( | 228 | assert.deepEqual( |
| 225 | finalCleanup.filter((effect) => effect.thread_id === firstThreadId), | 229 | finalCleanup.filter((effect) => effect.thread_id === firstThreadId), |
| 226 | partialCleanup, | 230 | partialCleanup, |
| ... | @@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or | ... | @@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or |
| 230 | assert.deepEqual( | 234 | assert.deepEqual( |
| 231 | finalCleanup.filter((effect) => effect.thread_id === threadId), | 235 | finalCleanup.filter((effect) => effect.thread_id === threadId), |
| 232 | [ | 236 | [ |
| 237 | { | ||
| 238 | effect_id: `effect:${expectedCommandId}:preview.cleanup`, | ||
| 239 | thread_id: threadId, | ||
| 240 | command_id: expectedCommandId, | ||
| 241 | effect_type: "preview.cleanup", | ||
| 242 | }, | ||
| 233 | { | 243 | { |
| 234 | effect_id: `effect:${expectedCommandId}:terminal.cleanup`, | 244 | effect_id: `effect:${expectedCommandId}:terminal.cleanup`, |
| 235 | thread_id: threadId, | 245 | thread_id: threadId, |
apps/web/src/browser/ServerBrowserSurface.tsx+5| ... | @@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: { | ... | @@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: { |
| 153 | readonly onFirstFrame?: () => void; | 153 | readonly onFirstFrame?: () => void; |
| 154 | readonly onViewport?: (viewport: PreviewStreamViewport) => void; | 154 | readonly onViewport?: (viewport: PreviewStreamViewport) => void; |
| 155 | readonly onControl?: (control: PreviewStreamControl | null) => void; | 155 | readonly onControl?: (control: PreviewStreamControl | null) => void; |
| 156 | /** The page opened a new tab from this viewer's click; the viewer should switch to it. */ | ||
| 157 | readonly onPopup?: (tabId: string) => void; | ||
| 156 | readonly className?: string; | 158 | readonly className?: string; |
| 157 | readonly ref?: Ref<ServerBrowserHandle>; | 159 | readonly ref?: Ref<ServerBrowserHandle>; |
| 158 | }) { | 160 | }) { |
| ... | @@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: { | ... | @@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: { |
| 166 | onFirstFrame, | 168 | onFirstFrame, |
| 167 | onViewport, | 169 | onViewport, |
| 168 | onControl, | 170 | onControl, |
| 171 | onPopup, | ||
| 169 | className, | 172 | className, |
| 170 | ref, | 173 | ref, |
| 171 | } = props; | 174 | } = props; |
| ... | @@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: { | ... | @@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: { |
| 218 | onViewport?.(viewport), | 221 | onViewport?.(viewport), |
| 219 | ); | 222 | ); |
| 220 | const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); | 223 | const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); |
| 224 | const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId)); | ||
| 221 | // Frame cap in device px, fixed per socket. It grows with the surface and | 225 | // Frame cap in device px, fixed per socket. It grows with the surface and |
| 222 | // never shrinks, so only outgrowing it reconnects. | 226 | // never shrinks, so only outgrowing it reconnects. |
| 223 | const [cap, setCap] = useState<{ width: number; height: number } | null>(null); | 227 | const [cap, setCap] = useState<{ width: number; height: number } | null>(null); |
| ... | @@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: { | ... | @@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: { |
| 397 | }, | 401 | }, |
| 398 | onClipboard: (text) => void copyPageText(text), | 402 | onClipboard: (text) => void copyPageText(text), |
| 399 | onDownload: offerDownload, | 403 | onDownload: offerDownload, |
| 404 | onPopup: popupOpened, | ||
| 400 | onFileChooser: setFileChooser, | 405 | onFileChooser: setFileChooser, |
| 401 | onViewport: (viewport) => { | 406 | onViewport: (viewport) => { |
| 402 | viewportRef.current = viewport; | 407 | viewportRef.current = viewport; |
apps/web/src/components/preview/PreviewUnreachable.tsx+44| ... | @@ -13,6 +13,50 @@ interface Props { | ... | @@ -13,6 +13,50 @@ interface Props { |
| 13 | onReload: () => void; | 13 | onReload: () => void; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | /** | ||
| 17 | * A server tab's address was a file its browser cannot show (a PDF), so the | ||
| 18 | * browser downloaded it. Offers the address in the user's own browser, or the | ||
| 19 | * file the server already saved. | ||
| 20 | */ | ||
| 21 | export function PreviewFileNotShown({ | ||
| 22 | url, | ||
| 23 | fileName, | ||
| 24 | downloadUrl, | ||
| 25 | onOpen, | ||
| 26 | }: { | ||
| 27 | url: string; | ||
| 28 | fileName: string; | ||
| 29 | downloadUrl: string | null; | ||
| 30 | onOpen: () => void; | ||
| 31 | }) { | ||
| 32 | const host = safeHost(url) ?? url; | ||
| 33 | return ( | ||
| 34 | <div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background"> | ||
| 35 | <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16"> | ||
| 36 | <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" /> | ||
| 37 | <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground"> | ||
| 38 | This file can&rsquo;t be shown here | ||
| 39 | </h1> | ||
| 40 | <p className="text-sm leading-relaxed text-muted-foreground"> | ||
| 41 | <span className="font-semibold text-foreground">{fileName}</span> from {host} was | ||
| 42 | downloaded instead. | ||
| 43 | </p> | ||
| 44 | <div className="mt-auto flex items-center gap-2 pt-8"> | ||
| 45 | <div className="flex-1" /> | ||
| 46 | <Button type="button" variant="outline" size="sm" onClick={onOpen}> | ||
| 47 | Open in browser | ||
| 48 | </Button> | ||
| 49 | {downloadUrl ? ( | ||
| 50 | <Button type="button" size="sm" render={<a href={downloadUrl} download={fileName} />}> | ||
| 51 | Download | ||
| 52 | </Button> | ||
| 53 | ) : null} | ||
| 54 | </div> | ||
| 55 | </div> | ||
| 56 | </div> | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 16 | /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ | 60 | /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ |
| 17 | export function PreviewUnreachable({ url, code, description, onReload }: Props) { | 61 | export function PreviewUnreachable({ url, code, description, onReload }: Props) { |
| 18 | const [showDetails, setShowDetails] = useState(false); | 62 | const [showDetails, setShowDetails] = useState(false); |
apps/web/src/components/preview/PreviewView.tsx+45-7| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; | ||
| 4 | import { | 5 | import { |
| 5 | isAtomCommandInterrupted, | 6 | isAtomCommandInterrupted, |
| 6 | squashAtomCommandFailure, | 7 | squashAtomCommandFailure, |
| ... | @@ -16,7 +17,7 @@ import { | ... | @@ -16,7 +17,7 @@ import { |
| 16 | PREVIEW_ZOOM_LEVELS, | 17 | PREVIEW_ZOOM_LEVELS, |
| 17 | type PreviewAdjustInput, | 18 | type PreviewAdjustInput, |
| 18 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 19 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 20 | import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview"; |
| 20 | import { useCallback, useEffect, useRef, useState } from "react"; | 21 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 21 | 22 | ||
| 22 | import { | 23 | import { |
| ... | @@ -37,6 +38,7 @@ import { | ... | @@ -37,6 +38,7 @@ import { |
| 37 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 38 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 38 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; | 39 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 39 | import { previewEnvironment } from "~/state/preview"; | 40 | import { previewEnvironment } from "~/state/preview"; |
| 41 | import { usePreviewStreamAccess } from "~/state/previewStream"; | ||
| 40 | import { useAtomCommand } from "~/state/use-atom-command"; | 42 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 41 | import { | 43 | import { |
| 42 | browserMiniPlayerSource, | 44 | browserMiniPlayerSource, |
| ... | @@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; | ... | @@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; |
| 49 | import { previewBridge } from "./previewBridge"; | 51 | import { previewBridge } from "./previewBridge"; |
| 50 | import { subscribePreviewAction } from "./previewActionBus"; | 52 | import { subscribePreviewAction } from "./previewActionBus"; |
| 51 | import { openPreviewSession } from "./openPreviewSession"; | 53 | import { openPreviewSession } from "./openPreviewSession"; |
| 54 | import { showPreviewPopup } from "./showPreviewPopup"; | ||
| 52 | import { PreviewChromeRow } from "./PreviewChromeRow"; | 55 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 53 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 56 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 54 | import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; | 57 | import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; |
| ... | @@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; | ... | @@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; |
| 61 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; | 64 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; |
| 62 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | 65 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 63 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; | 66 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; |
| 64 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 67 | import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable"; |
| 65 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | 68 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 66 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | 69 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 67 | import { Badge } from "~/components/ui/badge"; | 70 | import { Badge } from "~/components/ui/badge"; |
| ... | @@ -103,6 +106,31 @@ function previewProfileName( | ... | @@ -103,6 +106,31 @@ function previewProfileName( |
| 103 | 106 | ||
| 104 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | 107 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 105 | 108 | ||
| 109 | /** Resolves stream access only while a server tab shows a file it downloaded. */ | ||
| 110 | function ServerTabFileNotShown(props: { | ||
| 111 | readonly threadRef: ScopedThreadRef; | ||
| 112 | readonly tabId: string; | ||
| 113 | readonly url: string; | ||
| 114 | readonly download: { readonly id: string; readonly fileName: string }; | ||
| 115 | }) { | ||
| 116 | const access = usePreviewStreamAccess(props.threadRef.environmentId); | ||
| 117 | return ( | ||
| 118 | <PreviewFileNotShown | ||
| 119 | url={props.url} | ||
| 120 | fileName={props.download.fileName} | ||
| 121 | downloadUrl={ | ||
| 122 | access | ||
| 123 | ? previewStreamDownloadUrl( | ||
| 124 | { access, threadId: props.threadRef.threadId, tabId: props.tabId }, | ||
| 125 | props.download.id, | ||
| 126 | ) | ||
| 127 | : null | ||
| 128 | } | ||
| 129 | onOpen={() => void localApi?.shell.openExternal(props.url).catch(() => undefined)} | ||
| 130 | /> | ||
| 131 | ); | ||
| 132 | } | ||
| 133 | |||
| 106 | /** | 134 | /** |
| 107 | * Single-tab preview surface: chrome row on top, one webview below, empty | 135 | * Single-tab preview surface: chrome row on top, one webview below, empty |
| 108 | * state when no session exists for the thread. | 136 | * state when no session exists for the thread. |
| ... | @@ -259,12 +287,12 @@ export function PreviewView({ | ... | @@ -259,12 +287,12 @@ export function PreviewView({ |
| 259 | const handleSubmitUrl = useCallback( | 287 | const handleSubmitUrl = useCallback( |
| 260 | async (next: string) => { | 288 | async (next: string) => { |
| 261 | try { | 289 | try { |
| 262 | const normalized = normalizePreviewUrl(next); | 290 | const resolved = resolveAddressBarInput(next); |
| 263 | if (await navigateToResolvedUrl(normalized)) { | 291 | if (await navigateToResolvedUrl(resolved)) { |
| 264 | recordVisitForThread(threadRef, normalized); | 292 | recordVisitForThread(threadRef, resolved); |
| 265 | } | 293 | } |
| 266 | } catch { | 294 | } catch { |
| 267 | // Server-side `failed` event renders the unreachable view. | 295 | // Only empty input or an unsupported scheme lands here; the bar keeps the text. |
| 268 | } | 296 | } |
| 269 | }, | 297 | }, |
| 270 | [navigateToResolvedUrl, threadRef], | 298 | [navigateToResolvedUrl, threadRef], |
| ... | @@ -995,6 +1023,7 @@ export function PreviewView({ | ... | @@ -995,6 +1023,7 @@ export function PreviewView({ |
| 995 | onControl={(control) => | 1023 | onControl={(control) => |
| 996 | setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null) | 1024 | setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null) |
| 997 | } | 1025 | } |
| 1026 | onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")} | ||
| 998 | // Stays connected under the empty state so a URL picked there reaches the page. | 1027 | // Stays connected under the empty state so a URL picked there reaches the page. |
| 999 | className={cn( | 1028 | className={cn( |
| 1000 | "absolute inset-0 h-full w-full", | 1029 | "absolute inset-0 h-full w-full", |
| ... | @@ -1043,7 +1072,16 @@ export function PreviewView({ | ... | @@ -1043,7 +1072,16 @@ export function PreviewView({ |
| 1043 | controller={controller} | 1072 | controller={controller} |
| 1044 | /> | 1073 | /> |
| 1045 | ) : null} | 1074 | ) : null} |
| 1046 | {navStatus._tag === "LoadFailed" ? ( | 1075 | {navStatus._tag === "LoadFailed" && navStatus.download ? ( |
| 1076 | <div className="absolute inset-0 z-10 bg-background"> | ||
| 1077 | <ServerTabFileNotShown | ||
| 1078 | threadRef={threadRef} | ||
| 1079 | tabId={snapshot?.tabId ?? ""} | ||
| 1080 | url={navStatus.url} | ||
| 1081 | download={navStatus.download} | ||
| 1082 | /> | ||
| 1083 | </div> | ||
| 1084 | ) : navStatus._tag === "LoadFailed" ? ( | ||
| 1047 | <div className="absolute inset-0 z-10 bg-background"> | 1085 | <div className="absolute inset-0 z-10 bg-background"> |
| 1048 | <PreviewUnreachable | 1086 | <PreviewUnreachable |
| 1049 | url={navStatus.url} | 1087 | url={navStatus.url} |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+2| ... | @@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView"; | ... | @@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView"; |
| 49 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; | 49 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 50 | import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; | 50 | import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; |
| 51 | import { previewBridge } from "./previewBridge"; | 51 | import { previewBridge } from "./previewBridge"; |
| 52 | import { showPreviewPopup } from "./showPreviewPopup"; | ||
| 52 | import { | 53 | import { |
| 53 | clampPreviewMiniPlayerPosition, | 54 | clampPreviewMiniPlayerPosition, |
| 54 | NO_PREVIEW_MINI_PLAYER_OBSTACLES, | 55 | NO_PREVIEW_MINI_PLAYER_OBSTACLES, |
| ... | @@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly | ... | @@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly |
| 226 | followSize={false} | 227 | followSize={false} |
| 227 | controlPosition="bottom" | 228 | controlPosition="bottom" |
| 228 | onViewport={setStreamViewport} | 229 | onViewport={setStreamViewport} |
| 230 | onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} | ||
| 229 | className="size-full" | 231 | className="size-full" |
| 230 | /> | 232 | /> |
| 231 | </div> | 233 | </div> |
apps/web/src/components/preview/showPreviewPopup.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | ||
| 4 | import { whenPreviewTabKnown } from "~/previewStateStore"; | ||
| 5 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Switches the viewer to a tab the page they drive just opened, as a browser | ||
| 9 | * does for a link click or `window.open`. A floating opener floats the popup; | ||
| 10 | * otherwise it becomes the panel's active tab. The opener stays open. | ||
| 11 | */ | ||
| 12 | export function showPreviewPopup( | ||
| 13 | threadRef: ScopedThreadRef, | ||
| 14 | popupTabId: string, | ||
| 15 | from: "panel" | "floating", | ||
| 16 | ): void { | ||
| 17 | whenPreviewTabKnown(threadRef, popupTabId, () => { | ||
| 18 | if (from === "floating") { | ||
| 19 | usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(popupTabId)); | ||
| 20 | } else { | ||
| 21 | useRightPanelStore.getState().openBrowser(threadRef, popupTabId); | ||
| 22 | } | ||
| 23 | }); | ||
| 24 | } | ||
apps/web/src/hooks/useThreadActions.ts+3| ... | @@ -50,6 +50,7 @@ import { | ... | @@ -50,6 +50,7 @@ import { |
| 50 | readThreadShells, | 50 | readThreadShells, |
| 51 | } from "../state/entities"; | 51 | } from "../state/entities"; |
| 52 | import { useUiStateStore } from "../uiStateStore"; | 52 | import { useUiStateStore } from "../uiStateStore"; |
| 53 | import { clearThreadPreviewState } from "../previewStateStore"; | ||
| 53 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | 54 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 54 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | 55 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 55 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 56 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| ... | @@ -449,6 +450,7 @@ export function useThreadActions() { | ... | @@ -449,6 +450,7 @@ export function useThreadActions() { |
| 449 | }); | 450 | }); |
| 450 | if (result._tag === "Success") { | 451 | if (result._tag === "Success") { |
| 451 | refreshArchivedThreadsForEnvironment(target.environmentId); | 452 | refreshArchivedThreadsForEnvironment(target.environmentId); |
| 453 | clearThreadPreviewState(target); | ||
| 452 | } | 454 | } |
| 453 | return result; | 455 | return result; |
| 454 | } | 456 | } |
| ... | @@ -555,6 +557,7 @@ export function useThreadActions() { | ... | @@ -555,6 +557,7 @@ export function useThreadActions() { |
| 555 | threadRef, | 557 | threadRef, |
| 556 | ); | 558 | ); |
| 557 | clearTerminalUiState(threadRef); | 559 | clearTerminalUiState(threadRef); |
| 560 | clearThreadPreviewState(threadRef); | ||
| 558 | 561 | ||
| 559 | if (shouldNavigateToFallback) { | 562 | if (shouldNavigateToFallback) { |
| 560 | const fallbackThread = fallbackThreadId | 563 | const fallbackThread = fallbackThreadId |
apps/web/src/previewStateStore.ts+40| ... | @@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven | ... | @@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven |
| 214 | title: event.title, | 214 | title: event.title, |
| 215 | code: event.code, | 215 | code: event.code, |
| 216 | description: event.description, | 216 | description: event.description, |
| 217 | ...(event.download === undefined ? {} : { download: event.download }), | ||
| 217 | }, | 218 | }, |
| 218 | updatedAt: event.createdAt, | 219 | updatedAt: event.createdAt, |
| 219 | }; | 220 | }; |
| ... | @@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { | ... | @@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { |
| 457 | }); | 458 | }); |
| 458 | } | 459 | } |
| 459 | 460 | ||
| 461 | /** | ||
| 462 | * Runs `action` once the thread's preview state has the tab, which a popup's | ||
| 463 | * `opened` event may deliver after the stream that announced it. Gives up | ||
| 464 | * after `timeoutMs`. Returns a cancel function. | ||
| 465 | */ | ||
| 466 | export function whenPreviewTabKnown( | ||
| 467 | ref: ScopedThreadRef, | ||
| 468 | tabId: string, | ||
| 469 | action: () => void, | ||
| 470 | timeoutMs = 5_000, | ||
| 471 | ): () => void { | ||
| 472 | const atom = previewStateAtom(scopedThreadKey(ref)); | ||
| 473 | if (appAtomRegistry.get(atom).sessions[tabId]) { | ||
| 474 | action(); | ||
| 475 | return () => {}; | ||
| 476 | } | ||
| 477 | const stop = () => { | ||
| 478 | clearTimeout(timer); | ||
| 479 | unsubscribe(); | ||
| 480 | }; | ||
| 481 | const unsubscribe = appAtomRegistry.subscribe(atom, (state) => { | ||
| 482 | if (!state.sessions[tabId]) return; | ||
| 483 | stop(); | ||
| 484 | action(); | ||
| 485 | }); | ||
| 486 | const timer = setTimeout(stop, timeoutMs); | ||
| 487 | return stop; | ||
| 488 | } | ||
| 489 | |||
| 460 | export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { | 490 | export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { |
| 461 | if (url.trim().length === 0) return; | 491 | if (url.trim().length === 0) return; |
| 462 | updateThreadPreviewState(ref, (current) => ({ | 492 | updateThreadPreviewState(ref, (current) => ({ |
| ... | @@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean { | ... | @@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean { |
| 470 | return Boolean(window.desktopBridge?.preview); | 500 | return Boolean(window.desktopBridge?.preview); |
| 471 | } | 501 | } |
| 472 | 502 | ||
| 503 | /** | ||
| 504 | * Forgets a deleted thread's previews. The server closes their sessions too, | ||
| 505 | * but the desktop host keeps a page for every session held here. | ||
| 506 | */ | ||
| 507 | export function clearThreadPreviewState(ref: ScopedThreadRef): void { | ||
| 508 | updateThreadPreviewState(ref, (current) => | ||
| 509 | Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE, | ||
| 510 | ); | ||
| 511 | } | ||
| 512 | |||
| 473 | export function resetPreviewStateForTests(): void { | 513 | export function resetPreviewStateForTests(): void { |
| 474 | for (const threadKey of changedPreviewThreadKeys) { | 514 | for (const threadKey of changedPreviewThreadKeys) { |
| 475 | appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); | 515 | appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); |
packages/client-runtime/src/preview/serverBrowserStream.ts+7-1| ... | @@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles( | ... | @@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles( |
| 139 | if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); | 139 | if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); |
| 140 | } | 140 | } |
| 141 | 141 | ||
| 142 | const previewStreamDownloadUrl = ( | 142 | /** A file a server tab downloaded, served with the stream's own access. */ |
| 143 | export const previewStreamDownloadUrl = ( | ||
| 143 | target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">, | 144 | target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">, |
| 144 | id: string, | 145 | id: string, |
| 145 | ): string => | 146 | ): string => |
| ... | @@ -202,6 +203,8 @@ export interface PreviewStreamEvents { | ... | @@ -202,6 +203,8 @@ export interface PreviewStreamEvents { |
| 202 | readonly onClipboard?: (text: string) => void; | 203 | readonly onClipboard?: (text: string) => void; |
| 203 | /** A file the page downloaded while this viewer had control. */ | 204 | /** A file the page downloaded while this viewer had control. */ |
| 204 | readonly onDownload?: (download: PreviewStreamDownload) => void; | 205 | readonly onDownload?: (download: PreviewStreamDownload) => void; |
| 206 | /** The page this viewer controls opened a new tab (a link or `window.open`). */ | ||
| 207 | readonly onPopup?: (tabId: string) => void; | ||
| 205 | /** The page opened a file picker (`null` once answered or replaced). */ | 208 | /** The page opened a file picker (`null` once answered or replaced). */ |
| 206 | readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; | 209 | readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; |
| 207 | /** Input sent while disconnected is dropped. */ | 210 | /** Input sent while disconnected is dropped. */ |
| ... | @@ -286,6 +289,7 @@ export function createPreviewStreamClient( | ... | @@ -286,6 +289,7 @@ export function createPreviewStreamClient( |
| 286 | accept, | 289 | accept, |
| 287 | phase, | 290 | phase, |
| 288 | sequence, | 291 | sequence, |
| 292 | tabId, | ||
| 289 | } = message as Record<string, unknown>; | 293 | } = message as Record<string, unknown>; |
| 290 | if ( | 294 | if ( |
| 291 | type === "fileChooser" && | 295 | type === "fileChooser" && |
| ... | @@ -312,6 +316,8 @@ export function createPreviewStreamClient( | ... | @@ -312,6 +316,8 @@ export function createPreviewStreamClient( |
| 312 | typeof sequence === "number" | 316 | typeof sequence === "number" |
| 313 | ) { | 317 | ) { |
| 314 | events.onPointer?.({ phase, x, y, sequence }); | 318 | events.onPointer?.({ phase, x, y, sequence }); |
| 319 | } else if (type === "popup" && typeof tabId === "string") { | ||
| 320 | events.onPopup?.(tabId); | ||
| 315 | } else if (type === "clipboard" && typeof text === "string") { | 321 | } else if (type === "clipboard" && typeof text === "string") { |
| 316 | events.onClipboard?.(text); | 322 | events.onClipboard?.(text); |
| 317 | } else if ( | 323 | } else if ( |
packages/contracts/src/preview.ts+10| ... | @@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ | ... | @@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ |
| 143 | 143 | ||
| 144 | export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; | 144 | export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; |
| 145 | 145 | ||
| 146 | /** A file a server tab downloaded, served by the tab's download route by this id. */ | ||
| 147 | const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); | ||
| 148 | |||
| 146 | export const PreviewNavStatus = Schema.Union([ | 149 | export const PreviewNavStatus = Schema.Union([ |
| 147 | Schema.TaggedStruct("Idle", {}), | 150 | Schema.TaggedStruct("Idle", {}), |
| 148 | Schema.TaggedStruct("Loading", { | 151 | Schema.TaggedStruct("Loading", { |
| ... | @@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([ | ... | @@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([ |
| 158 | title: Title, | 161 | title: Title, |
| 159 | code: Schema.Int, | 162 | code: Schema.Int, |
| 160 | description: Schema.String, | 163 | description: Schema.String, |
| 164 | /** | ||
| 165 | * Set when the address was a file the browser downloaded instead of a page | ||
| 166 | * it could show (a PDF in a server tab). The server tab's download route | ||
| 167 | * serves it by this id. | ||
| 168 | */ | ||
| 169 | download: Schema.optional(PreviewFileDownload), | ||
| 161 | }), | 170 | }), |
| 162 | ]); | 171 | ]); |
| 163 | export type PreviewNavStatus = typeof PreviewNavStatus.Type; | 172 | export type PreviewNavStatus = typeof PreviewNavStatus.Type; |
| ... | @@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({ | ... | @@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({ |
| 347 | title: Title, | 356 | title: Title, |
| 348 | code: Schema.Int, | 357 | code: Schema.Int, |
| 349 | description: Schema.String, | 358 | description: Schema.String, |
| 359 | download: Schema.optional(PreviewFileDownload), | ||
| 350 | }); | 360 | }); |
| 351 | 361 | ||
| 352 | const PreviewClosedEvent = Schema.Struct({ | 362 | const PreviewClosedEvent = Schema.Struct({ |
packages/shared/src/preview.test.ts+50| ... | @@ -5,8 +5,58 @@ import { | ... | @@ -5,8 +5,58 @@ import { |
| 5 | newPreviewTabId, | 5 | newPreviewTabId, |
| 6 | normalizePreviewUrl, | 6 | normalizePreviewUrl, |
| 7 | PreviewUrlNormalizationError, | 7 | PreviewUrlNormalizationError, |
| 8 | resolveAddressBarInput, | ||
| 8 | } from "./preview.ts"; | 9 | } from "./preview.ts"; |
| 9 | 10 | ||
| 11 | describe("resolveAddressBarInput", () => { | ||
| 12 | it("opens what users type as addresses", () => { | ||
| 13 | expect(resolveAddressBarInput("localhost:5173")).toBe("http://localhost:5173/"); | ||
| 14 | expect(resolveAddressBarInput("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); | ||
| 15 | expect(resolveAddressBarInput("my-box.tailnet.ts.net")).toBe("https://my-box.tailnet.ts.net/"); | ||
| 16 | expect(resolveAddressBarInput("cnn.com")).toBe("https://cnn.com/"); | ||
| 17 | expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); | ||
| 18 | expect(resolveAddressBarInput(" https://example.com/a b ")).toBe("https://example.com/a%20b"); | ||
| 19 | expect(resolveAddressBarInput("example.com:8080")).toBe("https://example.com:8080/"); | ||
| 20 | expect(resolveAddressBarInput("[::1]:3000")).toBe("http://[::1]:3000/"); | ||
| 21 | expect(resolveAddressBarInput("192.168.1.5:3000")).toBe("https://192.168.1.5:3000/"); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("searches for text that is not an address", () => { | ||
| 25 | expect(resolveAddressBarInput("weather")).toBe("https://duckduckgo.com/?q=weather"); | ||
| 26 | expect(resolveAddressBarInput("how to center a div")).toBe( | ||
| 27 | "https://duckduckgo.com/?q=how%20to%20center%20a%20div", | ||
| 28 | ); | ||
| 29 | expect(resolveAddressBarInput("what is cnn.com")).toBe( | ||
| 30 | "https://duckduckgo.com/?q=what%20is%20cnn.com", | ||
| 31 | ); | ||
| 32 | expect(resolveAddressBarInput("what is https://example.com")).toBe( | ||
| 33 | "https://duckduckgo.com/?q=what%20is%20https%3A%2F%2Fexample.com", | ||
| 34 | ); | ||
| 35 | expect(resolveAddressBarInput("what is 10:30")).toBe( | ||
| 36 | "https://duckduckgo.com/?q=what%20is%2010%3A30", | ||
| 37 | ); | ||
| 38 | expect(resolveAddressBarInput("note: buy milk")).toBe( | ||
| 39 | "https://duckduckgo.com/?q=note%3A%20buy%20milk", | ||
| 40 | ); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("still rejects empty input and unsupported schemes", () => { | ||
| 44 | expect(() => resolveAddressBarInput(" ")).toThrow(PreviewUrlNormalizationError); | ||
| 45 | expect(() => resolveAddressBarInput("ftp://example.com")).toThrow(PreviewUrlNormalizationError); | ||
| 46 | expect(() => resolveAddressBarInput("mailto:alice@example.com")).toThrow( | ||
| 47 | PreviewUrlNormalizationError, | ||
| 48 | ); | ||
| 49 | expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow( | ||
| 50 | PreviewUrlNormalizationError, | ||
| 51 | ); | ||
| 52 | // A known scheme with a numeric payload is not a host and port. | ||
| 53 | for (const input of ["ftp:21", "tel:5551234"]) { | ||
| 54 | expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError); | ||
| 55 | } | ||
| 56 | expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 10 | describe("newPreviewTabId", () => { | 60 | describe("newPreviewTabId", () => { |
| 11 | it("returns a unique tab id every call", () => { | 61 | it("returns a unique tab id every call", () => { |
| 12 | const a = newPreviewTabId(); | 62 | const a = newPreviewTabId(); |
packages/shared/src/preview.ts+68| ... | @@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined { | ... | @@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined { |
| 57 | return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); | 57 | return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | const SEARCH_URL = "https://duckduckgo.com/?q="; | ||
| 61 | /** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ | ||
| 62 | const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; | ||
| 63 | const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/; | ||
| 64 | /** | ||
| 65 | * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234` | ||
| 66 | * is rejected rather than read as host and port. | ||
| 67 | */ | ||
| 68 | const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([ | ||
| 69 | "about:", | ||
| 70 | "blob:", | ||
| 71 | "chrome:", | ||
| 72 | "data:", | ||
| 73 | "file:", | ||
| 74 | "ftp:", | ||
| 75 | "javascript:", | ||
| 76 | "mailto:", | ||
| 77 | "sms:", | ||
| 78 | "ssh:", | ||
| 79 | "tel:", | ||
| 80 | "view-source:", | ||
| 81 | "ws:", | ||
| 82 | "wss:", | ||
| 83 | ]); | ||
| 84 | |||
| 85 | /** | ||
| 86 | * Turns what a user typed in an address bar into the URL to open: an address | ||
| 87 | * as `normalizePreviewUrl` reads it, or else a web search for the text. Text | ||
| 88 | * is an address when it has a scheme, or no spaces and a host that has a dot, | ||
| 89 | * a port, or is localhost or an IP. Throws only for empty input or an | ||
| 90 | * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`). | ||
| 91 | */ | ||
| 92 | export function resolveAddressBarInput(rawInput: string): string { | ||
| 93 | const trimmed = rawInput.trim(); | ||
| 94 | if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0) | ||
| 95 | return normalizePreviewUrl(trimmed); | ||
| 96 | if (!/\s/.test(trimmed)) { | ||
| 97 | // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or | ||
| 98 | // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read | ||
| 99 | // `mailto:alice@example.com` as credentials on a bare host, so reject here. | ||
| 100 | const protocol = previewUrlProtocol(trimmed); | ||
| 101 | if ( | ||
| 102 | protocol !== undefined && | ||
| 103 | (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed)) | ||
| 104 | ) { | ||
| 105 | if (protocol !== "http:" && protocol !== "https:") { | ||
| 106 | throw new PreviewUrlNormalizationError({ | ||
| 107 | inputLength: rawInput.length, | ||
| 108 | reason: "unsupported-protocol", | ||
| 109 | protocol, | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | return normalizePreviewUrl(trimmed); | ||
| 113 | } | ||
| 114 | const authority = trimmed.split(/[/?#]/, 1)[0] ?? ""; | ||
| 115 | const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, ""); | ||
| 116 | const hasPort = host.length < authority.replace(/^[^@]*@/, "").length; | ||
| 117 | if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) { | ||
| 118 | try { | ||
| 119 | return normalizePreviewUrl(trimmed); | ||
| 120 | } catch { | ||
| 121 | // Not a usable address after all; search for it. | ||
| 122 | } | ||
| 123 | } | ||
| 124 | } | ||
| 125 | return `${SEARCH_URL}${encodeURIComponent(trimmed)}`; | ||
| 126 | } | ||
| 127 | |||
| 60 | /** | 128 | /** |
| 61 | * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. | 129 | * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. |
| 62 | * | 130 | * |