From 611afa8b63bdf2be09407e8c78cb045fbc25a033 Mon Sep 17 00:00:00 2001 From: Ivan Kosilov Date: Thu, 29 Oct 2026 02:36:52 +0500 Subject: [PATCH] fix(preview): server recordings capture the tab instead of re-encoding the JPEG screencast Server-browser recordings of tabs in a persistent profile now record the tab Chrome composites, through a small recorder extension's tabCapture, into the existing encoder page. The JPEG screencast stays for incognito and isolated tabs, where extensions do not run, and for desktop tabs. Tab capture needs full Chrome, so T3 now downloads Chrome for Testing's `chrome` instead of chrome-headless-shell (about 200 MB instead of 120 MB), unpacks the macOS bundle's links, sets up hosts for its four extra libraries, and renames the AppArmor profile so setup installs one for the new path. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/browser.ts | 5 +- .../server/src/preview/PreviewBrowser.test.ts | 58 ++++++++- apps/server/src/preview/PreviewBrowser.ts | 91 ++++++++----- .../src/preview/PreviewBrowserHost.test.ts | 4 +- apps/server/src/preview/PreviewBrowserHost.ts | 10 +- apps/server/src/preview/ServerBrowser.test.ts | 123 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 89 +++++++++++-- .../src/preview/ServerBrowserContexts.test.ts | 46 ++++++- .../src/preview/ServerBrowserContexts.ts | 78 ++++++++++- apps/server/src/preview/ServerBrowserPage.ts | 109 +++++++++++----- docs/user/html-renders.md | 2 +- docs/user/remote-access.md | 2 +- 12 files changed, 518 insertions(+), 99 deletions(-) diff --git a/apps/server/src/cli/browser.ts b/apps/server/src/cli/browser.ts index 34a2efc55d5ca..b20fe7ac0ab5a 100644 --- a/apps/server/src/cli/browser.ts +++ b/apps/server/src/cli/browser.ts @@ -9,6 +9,7 @@ import { Command } from "effect/cli"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { baseDirFlag } from "./config.ts"; @@ -67,12 +68,12 @@ const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) { const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = path.join(baseDir, "tools", "chrome-headless-shell"); + const root = path.join(baseDir, "tools", "chrome-for-testing"); for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) { for (const version of yield* fs .readDirectory(path.join(root, platform)) .pipe(Effect.orElseSucceed(() => []))) { - const executable = path.join(root, platform, version, "chrome-headless-shell"); + const executable = path.join(root, platform, version, PreviewBrowser.executableName("linux")); if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) { return Option.some(executable); } diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 0cb1665d4f3d4..bc0e4700b9957 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -18,9 +18,8 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; // NTFS cannot enforce POSIX modes; choose the host platform. const hostPlatform: NodeJS.Platform = HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; -const executableName = - hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; -const ROOT = "chrome-headless-shell-fixture/"; +const executableName = PreviewBrowser.executableName(hostPlatform); +const ROOT = "chrome-fixture/"; const makeZip = ( entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>, @@ -95,7 +94,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( : { version: "1.2.3", platform: "fixture", - url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", + url: "https://storage.googleapis.com/chrome-fixture.zip", bytes: archive.byteLength, sha256, }, @@ -116,14 +115,16 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( ), ), ); - const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture"); + const installRoot = path.join(baseDir, "tools", "chrome-for-testing", "fixture"); return { browser, fs, path, baseDir, installRoot, requests }; }); it.layer(NodeServices.layer)("PreviewBrowser", (it) => { it.effect("installs a verified download with its file modes and then reuses it", () => Effect.gen(function* () { - const { browser, fs, path, installRoot, requests } = yield* makeHarness(); + const { browser, fs, path, baseDir, installRoot, requests } = yield* makeHarness(); + const headlessShell = path.join(baseDir, "tools", "chrome-headless-shell"); + yield* fs.makeDirectory(path.join(headlessShell, "linux64", "156"), { recursive: true }); yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true }); const active = path.join(installRoot, ".install-other"); const abandoned = path.join(installRoot, ".install-abandoned"); @@ -152,6 +153,49 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { expect(yield* browser.executable).toBe(executable); expect(yield* browser.installed).toEqual(Option.some(executable)); expect(requests).toHaveLength(1); + expect(yield* fs.exists(headlessShell)).toBe(false); + }), + ); + + // macOS framework bundles link inside themselves; links are written as POSIX links. + it.effect.skipIf(hostPlatform === "win32")( + "installs links that stay inside the browser's directory", + () => + Effect.gen(function* () { + const { browser, fs, path, installRoot } = yield* makeHarness({ + archive: makeZip([ + { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 }, + { name: `${ROOT}Versions/A/library`, data: "library", mode: 0o100644 }, + { name: `${ROOT}Versions/Current`, data: "A", mode: 0o120755 }, + { name: `${ROOT}library`, data: "Versions/Current/library", mode: 0o120755 }, + ]), + }); + + yield* browser.executable; + + const version = path.join(installRoot, "1.2.3"); + expect(yield* fs.readLink(path.join(version, "library"))).toBe("Versions/Current/library"); + expect(yield* fs.readFileString(path.join(version, "library"))).toBe("library"); + }), + ); + + it.effect.each([ + { name: "a parent directory", link: "../../escape" }, + { name: "an absolute path", link: "/etc" }, + { name: "nothing", link: "" }, + ])("rejects an archive with a link to $name", (testCase) => + Effect.gen(function* () { + const { browser, fs, installRoot } = yield* makeHarness({ + archive: makeZip([ + { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 }, + { name: `${ROOT}link`, data: testCase.link, mode: 0o120755 }, + ]), + }); + + const error = yield* browser.executable.pipe(Effect.flip); + + expect(error._tag).toBe("PreviewBrowserInstallError"); + expect(yield* fs.readDirectory(installRoot)).toEqual([]); }), ); @@ -193,7 +237,7 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { expect(error._tag).toBe("PreviewBrowserInstallError"); expect(yield* fs.readDirectory(installRoot)).toEqual([]); - expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe( + expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-for-testing", "escape"))).toBe( false, ); }), diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index d13b495ef5c20..ee10fd0341d64 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -21,38 +21,46 @@ import * as NodeCrypto from "node:crypto"; import * as ServerConfig from "../config.ts"; import { openZipArchive } from "../zipArchive.ts"; -// Update the version, sizes and hashes from each chrome-headless-shell zip listed at +// Update from each `chrome` zip (full Chrome: only it can capture a tab for recordings) listed at // https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json -// Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust -// compromised eMudhra intermediates (crrev.com/c/8627687). +// Never pin below 156.0.8078.70: Chrome for Testing has no CRLSet and older SPKI blocklists +// trust compromised eMudhra intermediates (crrev.com/c/8627687). const VERSION = "156.0.8078.70"; const ARCHIVES = { linux64: { - bytes: 126_646_931, - sha256: "a313da592261ff1e342c1e507d315f25b1809006c0508f77ce86151d464d4ccb", + bytes: 201_035_256, + sha256: "baac330433bb105012b7429ad77d4ba3d4ec3768810008ce9257d5d930c1b0cd", }, "linux-arm64": { - bytes: 126_749_876, - sha256: "dca11213d2e0b1db52141edd9f92ec423fd52ca25715846790d84fa1d5d16e8a", + bytes: 200_845_435, + sha256: "46dce94a0790d177927044e83ee7e0f571bc22e90e6fafe21fce5c1e248bc865", }, "mac-arm64": { - bytes: 103_901_293, - sha256: "1f848385611f2e58122e68969489712661880fc9a92c9dd20428099f9998ba7b", + bytes: 194_910_526, + sha256: "1070e2acc6fec1b80aac9215c9b83a381334a8c556df941e5146efb816121adb", }, "mac-x64": { - bytes: 109_387_968, - sha256: "ab4cc2949f88439e2241b2712c49268f24982f2d25af87960466cc22bdc2e6e1", + bytes: 205_543_880, + sha256: "3218f6e802588632f2f8055b90035b306cad48874daf6067330e0f012afdb7ab", }, win32: { - bytes: 119_029_359, - sha256: "37dc9a03f37fd79637fec9f26042bf32f04cfe5b83749d980eb1e7d1a429d47c", + bytes: 188_579_460, + sha256: "853955e981b07b8c44dfc77e658f6bfc806868a74176a44900aecda65ea4c26e", }, win64: { - bytes: 126_214_071, - sha256: "41c6aa97046d05d015b817a95f7b1512c3a1a41b18364f8da46478ae520400b1", + bytes: 209_092_934, + sha256: "8ab65752ca6106bb8a5bf3afaa710ab4f62a57c27ed39db4977422c929fce0b3", }, } as const; +/** The browser's executable inside the archive's top directory. */ +export const executableName = (platform: NodeJS.Platform) => + platform === "win32" + ? "chrome.exe" + : platform === "darwin" + ? "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" + : "chrome"; + const chromePlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture) => { switch (platform) { case "linux": @@ -85,7 +93,7 @@ const previewBrowserRelease = ( : { version: VERSION, platform: chrome, - url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-headless-shell-${chrome}.zip`, + url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-${chrome}.zip`, ...ARCHIVES[chrome], }; }; @@ -119,7 +127,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError { - const executable = path.join(installRoot, release.version, executableName); - return fs.stat(executable).pipe( + const installed = path.join(installRoot, release.version, binary); + return fs.stat(installed).pipe( Effect.map((info) => info.type === "File" && (platform === "win32" || (info.mode & 0o111) !== 0) - ? Option.some(executable) + ? Option.some(installed) : Option.none(), ), Effect.orElseSucceed(() => Option.none()), @@ -251,7 +258,7 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( const unsafe = new PreviewBrowserInstallError({ detail: "The archive contains an unexpected or unsafe entry.", }); - const root = `chrome-headless-shell-${release.platform}/`; + const root = `chrome-${release.platform}/`; yield* fs.makeDirectory(destination); let foundExecutable = false; for (;;) { @@ -260,6 +267,7 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( const unixMode = entry.externalFileAttributes >>> 16; const unixType = unixMode & 0o170000; const isDirectory = entry.fileName.endsWith("/"); + const isSymlink = unixType === 0o120000; const relative = entry.fileName.startsWith(root) ? entry.fileName.slice(root.length) : null; const target = relative === null ? null : path.resolve(destination, relative); if ( @@ -267,7 +275,7 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( target === null || (relative !== "" && !target.startsWith(`${destination}${path.sep}`)) || relative.split("/").includes("..") || - (unixType !== 0 && unixType !== (isDirectory ? 0o040000 : 0o100000)) || + (unixType !== 0 && !isSymlink && unixType !== (isDirectory ? 0o040000 : 0o100000)) || (entry.generalPurposeBitFlag & 1) !== 0 || ![0, 8].includes(entry.compressionMethod) ) { @@ -278,6 +286,21 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( continue; } yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + if (isSymlink) { + // macOS framework bundles link within themselves (Versions/Current); nothing may point out. + const linked = yield* Effect.gen(function* () { + const readable = yield* archive.streamEntry(entry); + return yield* EffectNodeStream.fromReadable({ + evaluate: () => readable, + onError: wrapFailure("Could not unpack the browser."), + }).pipe(Stream.decodeText(), Stream.mkString); + }).pipe(Effect.scoped); + if (linked === "" || linked.startsWith("/") || linked.split("/").includes("..")) { + return yield* unsafe; + } + yield* fs.symlink(linked, target); + continue; + } yield* Effect.gen(function* () { const readable = yield* archive.streamEntry(entry); yield* EffectNodeStream.fromReadable({ @@ -289,11 +312,11 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( if (platform !== "win32" && (unixMode & 0o777) !== 0) { yield* fs.chmod(target, unixMode & 0o777); } - if (relative === executableName) foundExecutable = true; + if (relative === binary) foundExecutable = true; } if (!foundExecutable) { return yield* new PreviewBrowserInstallError({ - detail: "The archive does not contain chrome-headless-shell.", + detail: "The archive does not contain Chrome.", }); } }, @@ -345,7 +368,15 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( ), { discard: true }, ); - return path.join(installRoot, release.version, executableName); + // Earlier versions installed chrome-headless-shell, which this browser replaces. + yield* fs + .remove(path.join(toolsDir, "chrome-headless-shell"), { recursive: true, force: true }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not remove the old headless shell.", { cause }), + ), + ); + return path.join(installRoot, release.version, binary); }, Effect.mapError(wrapFailure("Could not save the browser in T3's home directory.")), ); diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 2a90d000b262b..9144161b7eb13 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -19,7 +19,7 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) // Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted. const LOADER_ERROR = - "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; + "chrome: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { const commands: Array = []; @@ -60,7 +60,7 @@ const diagnose = (input: { version: input.version ?? LOADER_ERROR, }); return PreviewBrowserHost.diagnoseLaunchFailure({ - executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", + executable: "/home/me/.t3/tools/chrome-for-testing/linux64/154/chrome", output: input.output, setupCommand: "sudo t3 browser setup", }).pipe( diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 896f51b308e9b..1a570399f3a96 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -14,23 +14,27 @@ export const sandboxDisabled = (env: Readonly /** Set on Ubuntu 23.10+: unprivileged user namespaces need an AppArmor profile. */ const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"; -export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell"; +// Named after the browser it allows: hosts set up for the former headless shell need a new profile. +export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-for-testing"; /** Match Ubuntu's Chrome profile; unconfined adds nothing beyond userns. */ export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox. abi , include -profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) { +profile t3-chrome-for-testing /**/tools/chrome-for-testing/*/*/chrome flags=(unconfined) { userns, - include if exists + include if exists } `; /** Ubuntu 24.04 and Debian 13 use some t64 library names; choose names available through apt. */ export const DEBIAN_PACKAGES: ReadonlyArray> = [ ["libnss3"], + ["libcairo2"], + ["libcups2t64", "libcups2"], + ["libpango-1.0-0"], ["libglib2.0-0t64", "libglib2.0-0"], ["libatk1.0-0t64", "libatk1.0-0"], ["libatk-bridge2.0-0t64", "libatk-bridge2.0-0"], diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 99df18c10cf5a..f3adbe1b9a588 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -54,6 +54,12 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ async scratchPage() { return makeEncoderPage() as unknown as Page; } + async recorderPage() { + if (!tabCapture) return null; + const page = makeEncoderPage(tabCapture.globals()); + tabCapture.pages.push(page); + return page as unknown as Page; + } async connectDesktopPage(endpoint: string) { const context = makeContext(); desktopConnections.push({ endpoint, context }); @@ -98,6 +104,8 @@ function makeSession(frame?: ReturnType) { } // Snapshots shrink their screenshot in the page; the header stands in for the result. if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } }; + if (method === "Target.getTargetInfo") return { targetInfo: { targetId: "tab-target" } }; + if (method === "Browser.getWindowForTarget") return { windowId: 1 }; if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { @@ -217,7 +225,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { } // Runs the real encoder script against fake canvas and MediaRecorder globals. -function makeEncoderPage() { +function makeEncoderPage(globals: Record = {}) { const canvas = { width: 0, height: 0, @@ -245,6 +253,7 @@ function makeEncoderPage() { atob, Blob, performance, + ...globals, }; sandbox.window = sandbox; const context = NodeVM.createContext(sandbox); @@ -258,7 +267,48 @@ function makeEncoderPage() { }; } +// The recorder extension's APIs, capturing one 2560x1600 frame of the tab the CDP target names. +const makeTabCapture = (tabId: number | undefined) => { + const capture = { + pages: [] as Array>, + constraints: [] as Array, + track: { stop: vi.fn() }, + globals: () => ({ + chrome: { + debugger: { getTargets: async () => [{ id: "tab-target", tabId }] }, + tabCapture: { getMediaStreamId: async () => `stream-for-${tabId}` }, + }, + navigator: { + mediaDevices: { + getUserMedia: async (constraints: unknown) => { + capture.constraints.push(constraints); + return { getVideoTracks: () => [capture.track] }; + }, + }, + }, + MediaStreamTrackProcessor: class { + readonly readable = { + getReader: () => { + let read = 0; + return { + read: () => + read++ === 0 + ? Promise.resolve({ + done: false, + value: { displayWidth: 2560, displayHeight: 1600, close() {} }, + }) + : new Promise(() => {}), + }; + }, + }; + }, + }), + }; + return capture; +}; + const contexts: ReturnType[] = []; +let tabCapture: ReturnType | null = null; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; @@ -370,6 +420,7 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ beforeEach(() => { contexts.length = 0; + tabCapture = null; encoderStreams.length = 0; contextRequests.length = 0; contextGate = null; @@ -572,6 +623,76 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); +it.live( + "records a tab the browser can capture from its composited frames, not the screencast", + () => + Effect.scoped( + Effect.gen(function* () { + tabCapture = makeTabCapture(7); + const { browser, broker, tabId } = yield* ready; + yield* browser.reportProfiles({ + profiles: [], + defaultProfileId: "default", + recordingFrameRate: 60, + }); + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + expect(encoderStreams).toEqual([{ frameRate: 60, bitsPerSecond: 2560 * 1600 * 60 * 0.05 }]); + expect(tabCapture.constraints).toEqual([ + { + audio: false, + video: { + mandatory: { + chromeMediaSource: "tab", + chromeMediaSourceId: "stream-for-7", + maxFrameRate: 60, + minWidth: 3, + minHeight: 2, + maxWidth: 7680, + maxHeight: 7680, + }, + }, + }, + ]); + const sends = contexts + .flatMap((context) => context.sessions) + .flatMap((session) => session.send.mock.calls); + expect(sends.some(([method]) => method === "Page.startScreencast")).toBe(false); + expect(sends).toContainEqual([ + "Browser.setContentsSize", + expect.objectContaining({ width: 1280, height: 800 }), + ]); + // A resize during the recording moves the window and the cursor scale with it. + const operator = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* operator.input({ type: "takeControl" }); + yield* operator.input({ type: "resize", width: 800, height: 800 }); + const followed = contexts + .flatMap((context) => context.sessions) + .flatMap((session) => session.send.mock.calls); + expect(followed).toContainEqual([ + "Browser.setContentsSize", + expect.objectContaining({ width: 800, height: 800 }), + ]); + expect(tabCapture.pages[0]?.evaluate).toHaveBeenLastCalledWith(expect.any(Function), 800); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("closes the recorder page when it cannot capture the tab", () => + Effect.scoped( + Effect.gen(function* () { + tabCapture = makeTabCapture(undefined); + const { broker, tabId } = yield* ready; + const failed = yield* broker + .invoke({ scope, tabId, operation: "recordingStart", input: {} }) + .pipe(Effect.flip); + expect(failed).toMatchObject({ _tag: "PreviewAutomationExecutionError" }); + expect(JSON.stringify(failed)).toContain("The recorder cannot see this tab."); + expect(tabCapture.pages[0]?.close).toHaveBeenCalled(); + expect(encoderStreams).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 78097b761cb74..22dd7ae8bcbd6 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -36,6 +36,7 @@ import { type PreviewAutomationWaitForInput, PreviewClearProfileError, type PreviewEvent, + PREVIEW_VIEWPORT_MAX_DIMENSION, type PreviewNavStatus, type PreviewSessionSnapshot, type PreviewViewportSetting, @@ -262,6 +263,8 @@ interface EncoderWindow { __t3Recorder: { cursor(x: number, y: number, click: boolean): void; frame(data: string, width: number): Promise; + capture(targetId: string, cssWidth: number, maxSide: number): Promise; + viewport(cssWidth: number): void; stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; chunk(index: number): Promise; }; @@ -269,9 +272,12 @@ interface EncoderWindow { interface Recording { readonly encoder: Page; - readonly session: CDPSession; + /** Null when the browser captures the tab itself; desktop and off-the-record tabs use the screencast. */ + readonly screencast: { + readonly session: CDPSession; + readonly framesInFlight: Set>; + } | null; readonly startedAt: string; - readonly framesInFlight: Set>; } interface ServerTab { @@ -472,6 +478,7 @@ const make = Effect.gen(function* () { const contexts = new ServerBrowserContexts({ profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"), + recorderDir: NodePath.join(config.stateDir, "server-browser", "recorder"), executable: () => runPromise(previewBrowser.executable), // Playwright's launch error drops Chrome's own output; its sandbox note survives. diagnose: (executable, cause) => @@ -637,6 +644,7 @@ const make = Effect.gen(function* () { await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); broadcastViewport(tab); + await followViewport(tab); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -1467,9 +1475,66 @@ const make = Effect.gen(function* () { return run; }; + // Tab capture records the window, which scales a viewport emulated at another size into it. + const fitWindow = async (tab: ServerTab) => { + const viewport = tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT; + const { windowId } = await tab.cdp.send("Browser.getWindowForTarget"); + await tab.cdp.send("Browser.setContentsSize", { windowId, ...viewport }); + return viewport; + }; + + /** Keeps a tab capture's window and cursor scale on the tab's current viewport. */ + const followViewport = async (tab: ServerTab) => { + const recording = tab.recording; + if (!recording || recording.screencast) return; + try { + const { width } = await fitWindow(tab); + await recording.encoder.evaluate( + (cssWidth) => (globalThis as unknown as EncoderWindow).__t3Recorder.viewport(cssWidth), + width, + ); + } catch (cause) { + if (tab.recording === recording && !recording.encoder.isClosed()) throw cause; + } + }; + + const captureTab = async (tab: ServerTab, encoder: Page): Promise => { + try { + await encoder.evaluate(ServerBrowserPage.recordingEncoderScript(recordingFrameRate)); + const viewport = await fitWindow(tab); + const { targetInfo } = await tab.cdp.send("Target.getTargetInfo"); + await encoder.evaluate( + ([targetId, cssWidth, maxSide]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.capture( + targetId, + cssWidth, + maxSide, + ), + [ + targetInfo.targetId, + viewport.width, + PREVIEW_VIEWPORT_MAX_DIMENSION * RENDER_SCALE, + ] as const, + ); + if (tab.closing) throw new Error("The tab closed while the recording started."); + return { encoder, screencast: null, startedAt: new Date().toISOString() }; + } catch (cause) { + await encoder.close().catch(constVoid); + throw cause; + } + }; + const startRecording = (tab: ServerTab): Promise => { const started = withCaptureLock(tab, async () => { if (tab.recording) return tab.recording; + const recorder = tab.desktop ? null : await contexts.recorderPage(tab.page.context()); + if (recorder) { + const recording = await captureTab(tab, recorder); + tab.recording = recording; + // A viewer may have resized the tab while the capture started. + await followViewport(tab); + return recording; + } const encoder = await contexts.scratchPage(); let session: CDPSession | null = null; try { @@ -1511,9 +1576,8 @@ const make = Effect.gen(function* () { if (tab.closing) throw new Error("The tab closed while the recording started."); const recording: Recording = { encoder, - session: opened, + screencast: { session: opened, framesInFlight }, startedAt: new Date().toISOString(), - framesInFlight, }; tab.recording = recording; return recording; @@ -1532,11 +1596,11 @@ const make = Effect.gen(function* () { const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; - const recording = tab.recording; + const screencast = tab.recording?.screencast; try { await Promise.all([ ...[...tab.viewers].map((viewer) => viewer.pause()), - recording?.session.send("Page.stopScreencast").catch(constVoid), + screencast?.session.send("Page.stopScreencast").catch(constVoid), ]); return await capture(); } finally { @@ -1544,8 +1608,8 @@ const make = Effect.gen(function* () { await Promise.all([ ...[...tab.viewers].map((viewer) => viewer.resume()), - recording && tab.recording === recording - ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid) + screencast && tab.recording?.screencast === screencast + ? screencast.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid) : undefined, ]); } @@ -1563,9 +1627,11 @@ const make = Effect.gen(function* () { let mimeType: string | null; const chunks: Array = []; try { - await recording.session.send("Page.stopScreencast").catch(constVoid); - await Promise.all(recording.framesInFlight); - await recording.session.detach().catch(constVoid); + if (recording.screencast) { + await recording.screencast.session.send("Page.stopScreencast").catch(constVoid); + await Promise.all(recording.screencast.framesInFlight); + await recording.screencast.session.detach().catch(constVoid); + } const stopped = await recording.encoder.evaluate(() => (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), ); @@ -2200,6 +2266,7 @@ const make = Effect.gen(function* () { if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); broadcastViewport(tab); + await followViewport(tab); return; } case "viewport": { diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 7a3babd0dcc0a..224605c5f3735 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -11,10 +11,15 @@ const launches = vi.hoisted(() => ({ persistent: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), })); vi.spyOn(chromium, "launch").mockImplementation(launches.launch); vi.spyOn(chromium, "launchPersistentContext").mockImplementation(launches.persistent); -vi.mock("node:fs/promises", () => ({ mkdir: launches.mkdir, rm: launches.rm })); +vi.mock("node:fs/promises", () => ({ + mkdir: launches.mkdir, + rm: launches.rm, + writeFile: launches.writeFile, +})); const makeContext = () => { const events = new NodeEvents.EventEmitter(); @@ -51,6 +56,7 @@ const makeBrowser = () => { const options = () => ({ profilesDir: "/test/profiles", + recorderDir: "/test/recorder", executable: vi.fn(async () => "/test/chromium"), env: {}, }); @@ -60,6 +66,7 @@ beforeEach(() => { launches.persistent.mockReset(); launches.mkdir.mockClear(); launches.rm.mockClear(); + launches.writeFile.mockClear(); }); describe("ServerBrowserContexts", () => { @@ -107,6 +114,43 @@ describe("ServerBrowserContexts", () => { expect(human.close).toHaveBeenCalledTimes(1); }); + it("loads the recorder extension only into persistent profiles, which can record their tabs", async () => { + const human = makeContext(); + const recorder = { goto: vi.fn(async () => null), close: vi.fn(async () => {}) }; + const withPages = Object.assign(human, { newPage: vi.fn(async () => recorder) }); + const browser = makeBrowser(); + launches.persistent.mockResolvedValue(withPages as unknown as BrowserContext); + launches.launch.mockResolvedValue(browser as unknown as Browser); + const pool = new ServerBrowserContexts(options()); + const persistent = await pool.contextFor("default"); + const agent = await pool.contextFor("default", "agent"); + const extensionArgs = [ + "--disable-extensions-except=/test/recorder", + "--load-extension=/test/recorder", + expect.stringMatching(/^--allowlisted-extension-id=[a-p]{32}$/), + ]; + expect(launches.persistent.mock.calls[0]?.[1]).toMatchObject({ + args: expect.arrayContaining(extensionArgs), + ignoreDefaultArgs: expect.arrayContaining(["--disable-extensions"]), + }); + expect(launches.launch.mock.calls[0]?.[0]?.args).not.toContain( + "--load-extension=/test/recorder", + ); + expect(launches.writeFile).toHaveBeenCalledWith( + "/test/recorder/manifest.json", + expect.stringContaining('"tabCapture"'), + ); + expect(await pool.recorderPage(agent)).toBeNull(); + expect(await pool.recorderPage(persistent)).toBe(recorder); + expect(recorder.goto).toHaveBeenCalledWith( + expect.stringMatching(/^chrome-extension:\/\/[a-p]{32}\//), + ); + recorder.goto.mockRejectedValueOnce(new Error("extension missing")); + await expect(pool.recorderPage(persistent)).rejects.toThrow("extension missing"); + expect(recorder.close).toHaveBeenCalledTimes(1); + await pool.close(); + }); + it("clears a profile by closing its open context before deleting its storage", async () => { const human = makeContext(); const browser = makeBrowser(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 0e0f71d4e53ca..8fed0e96b0172 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -1,11 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off - Owns Playwright resources outside the Effect runtime. import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; +import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import type { Browser, BrowserContext, CDPSession } from "playwright-core"; +import type { Browser, BrowserContext, CDPSession, Page } from "playwright-core"; import { sandboxDisabled } from "./PreviewBrowserHost.ts"; @@ -16,12 +17,36 @@ const loadPlaywright = () => interface Options { readonly profilesDir: string; + /** Where the recorder extension is written for profiles to load. */ + readonly recorderDir: string; readonly executable: () => Promise; readonly diagnose?: (executable: string, cause: unknown) => Promise; readonly env?: NodeJS.ProcessEnv; readonly onContextClose?: (context: BrowserContext) => void; } +// The fixed key gives the unpacked extension a fixed ID, which --allowlisted-extension-id needs for tabCapture. +const RECORDER_KEY = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3RehY8yJ5xemVONdHDb++CtAr/Wg+0v/4aVjMACNjAUeDPUHj++V2cmzZ594xmmlL8O9eb9/FwH9t/3da7tlnAT7lVqI/fC0gLvbhaBu4YvYAAc+clRIEMSTDfxYfQzNXyizRMavf1gV7AjIfHJdsjnxRkHoPTHG8zjncWqByl+4A97maierxc5Y9iFfy3RkPXjBQUbec80BmD1V9MRKf4Zk4ZKbHFPptj59wP1eoOmFOdkiv0AUtM5+uTfvAFG+5Yyq4R2P242gOWcoIVz1z1u/ByuTvBPLbsKCPjsXSg/T+fC6NthTunSSCu3pDuIlCrkRIjaIfNE0DWVNywRSKQIDAQAB"; +const RECORDER_ID = [ + ...NodeCrypto.createHash("sha256").update(Buffer.from(RECORDER_KEY, "base64")).digest("hex"), +] + .slice(0, 32) + .map((digit) => String.fromCharCode(97 + Number.parseInt(digit, 16))) + .join(""); +const RECORDER_FILES = { + "manifest.json": JSON.stringify({ + manifest_version: 3, + name: "T3 Code recorder", + version: "1", + key: RECORDER_KEY, + // debugger only for getTargets, which maps a CDP target to its tab. + permissions: ["tabCapture", "debugger"], + }), + "recorder.html": "T3 Code recorder", +}; +const RECORDER_PAGE_URL = `chrome-extension://${RECORDER_ID}/recorder.html`; + const UA_PLATFORMS: Partial> = { darwin: "macOS", linux: "Linux", @@ -73,12 +98,30 @@ export class ServerBrowserContexts { private readonly clearing = new Map>(); private browser: Promise | undefined; private closing: Promise | undefined; + private recorder: Promise | undefined; + // Extensions do not run in off-the-record contexts, so only persistent profiles can record their tabs. + private readonly recordable = new WeakSet(); constructor(options: Options) { this.options = options; } - private async launchOptions() { + private recorderExtension() { + this.recorder ??= (async () => { + const directory = this.options.recorderDir; + await NodeFSP.mkdir(directory, { recursive: true }); + for (const [name, contents] of Object.entries(RECORDER_FILES)) { + await NodeFSP.writeFile(NodePath.join(directory, name), contents); + } + return directory; + })(); + void this.recorder.catch(() => { + this.recorder = undefined; + }); + return this.recorder; + } + + private async launchOptions(recorder?: string) { const executablePath = await this.options.executable(); const env = this.options.env ?? process.env; return { @@ -88,8 +131,18 @@ export class ServerBrowserContexts { "--disable-gpu", "--force-device-scale-factor=2", "--disable-blink-features=AutomationControlled", + ...(recorder === undefined + ? [] + : [ + `--disable-extensions-except=${recorder}`, + `--load-extension=${recorder}`, + `--allowlisted-extension-id=${RECORDER_ID}`, + ]), + ], + ignoreDefaultArgs: [ + "--enable-automation", + ...(recorder === undefined ? [] : ["--disable-extensions"]), ], - ignoreDefaultArgs: ["--enable-automation"], headless: true, // Only an explicit operator opt-out disables sandboxing. Launch errors never do. chromiumSandbox: !sandboxDisabled(env), @@ -156,12 +209,27 @@ export class ServerBrowserContexts { return browser.newContext(contextOptions); } const directory = this.profileDirectory(profileId); - const options = await this.launchOptions(); + const options = await this.launchOptions(await this.recorderExtension()); const { chromium } = loadPlaywright(); await NodeFSP.mkdir(directory, { recursive: true }); - return this.launch(options, () => + const context = await this.launch(options, () => chromium.launchPersistentContext(directory, { ...options, ...contextOptions }), ); + this.recordable.add(context); + return context; + } + + /** A page of the recorder extension beside the context's tabs, or null where tab capture is unavailable. */ + async recorderPage(context: BrowserContext): Promise { + if (!this.recordable.has(context)) return null; + const page = await context.newPage(); + try { + await page.goto(RECORDER_PAGE_URL); + return page; + } catch (cause) { + await page.close().catch(constVoid); + throw cause; + } } private profileDirectory(profileId: string) { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..418bd41932025 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -908,6 +908,8 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => let sizeBytes = 0; let tooLarge = false; let frame = null; + let capture = null; + let captureCssWidth = 1; let cursor = null; let ring = null; const AGENT_CURSOR_PATH = new Path2D("M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"); @@ -926,7 +928,7 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => context.fillStyle = "#000"; context.fillRect(0, 0, canvas.width, canvas.height); } - context.drawImage(frame, offsetX, offsetY, width, height); + context.drawImage(frame.image, offsetX, offsetY, width, height); scale = (width / frame.width) * frame.cssScale; if (ring) { const age = (performance.now() - ring.at) / 400; @@ -957,48 +959,84 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => context.restore(); } }; + // Draws an image (an ImageBitmap or a VideoFrame) and the cursor; the first one starts the recorder. + const show = async (image, width, height, cssWidth) => { + if (!recorder) { + canvas.width = width; + canvas.height = height; + const mimeType = ["video/mp4;codecs=avc1.640033", "video/webm;codecs=vp9", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type)); + const bitsPerSecond = Math.min(50e6, Math.max(2.5e6, width * height * ${frameRate} * 0.05)); + recorder = new MediaRecorder(canvas.captureStream(${frameRate}), { mimeType, videoBitsPerSecond: bitsPerSecond }); + started = new Promise((resolve, reject) => { + recorder.onstart = resolve; + recorder.onerror = (event) => reject(event.error); + }); + stopped = new Promise((resolve) => { recorder.onstop = resolve; }); + recorder.ondataavailable = (event) => { + if (tooLarge || event.data.size === 0) return; + sizeBytes += event.data.size; + if (sizeBytes > ${PROVIDER_SEND_TURN_MAX_FILE_BYTES}) { + tooLarge = true; + chunks.length = 0; + clearInterval(ringTimer); + frame?.image.close(); + frame = null; + capture?.stop(); + if (recorder.state !== "inactive") recorder.stop(); + for (const track of recorder.stream.getTracks()) track.stop(); + return; + } + chunks.push(event.data); + }; + recorder.start(1000); + } + // cssScale: frame px per page CSS px, before fitting into the canvas. + frame?.image.close(); + frame = { image, width, height, cssScale: width / cssWidth }; + paint(); + await started; + }; window.__t3Recorder = { async frame(base64, cssWidth) { if (tooLarge) return false; const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); if (tooLarge) { bitmap.close(); return false; } - if (!recorder) { - canvas.width = bitmap.width; - canvas.height = bitmap.height; - const mimeType = ["video/mp4;codecs=avc1.640033", "video/webm;codecs=vp9", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type)); - const bitsPerSecond = Math.min(50e6, Math.max(2.5e6, bitmap.width * bitmap.height * ${frameRate} * 0.05)); - recorder = new MediaRecorder(canvas.captureStream(${frameRate}), { mimeType, videoBitsPerSecond: bitsPerSecond }); - started = new Promise((resolve, reject) => { - recorder.onstart = resolve; - recorder.onerror = (event) => reject(event.error); - }); - stopped = new Promise((resolve) => { recorder.onstop = resolve; }); - recorder.ondataavailable = (event) => { - if (tooLarge || event.data.size === 0) return; - sizeBytes += event.data.size; - if (sizeBytes > ${PROVIDER_SEND_TURN_MAX_FILE_BYTES}) { - tooLarge = true; - chunks.length = 0; - clearInterval(ringTimer); - frame?.close(); - frame = null; - if (recorder.state !== "inactive") recorder.stop(); - for (const track of recorder.stream.getTracks()) track.stop(); - return; - } - chunks.push(event.data); - }; - recorder.start(1000); - } - // Frame px per page CSS px, before fitting into the canvas. - bitmap.cssScale = bitmap.width / cssWidth; - frame?.close(); - frame = bitmap; - paint(); - await started; + await show(bitmap, bitmap.width, bitmap.height, cssWidth); return true; }, + // In the recorder extension's page: records the tab the browser renders, frame by frame as composited. + async capture(targetId, cssWidth, maxSide) { + const target = (await chrome.debugger.getTargets()).find((candidate) => candidate.id === targetId); + if (target?.tabId === undefined) throw new Error("The recorder cannot see this tab."); + const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: target.tabId }); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + // Tab capture keeps one frame size and letterboxes into it, unless the bounds exceed 1 px + // and differ in aspect ratio (Chromium's SelectResolutionPolicyFromCandidates): then frames + // follow the window, as screencast frames follow the viewport. + video: { mandatory: { + chromeMediaSource: "tab", chromeMediaSourceId: streamId, maxFrameRate: ${frameRate}, + minWidth: 3, minHeight: 2, maxWidth: maxSide, maxHeight: maxSide, + } }, + }); + capture = stream.getVideoTracks()[0]; + captureCssWidth = cssWidth; + const reader = new MediaStreamTrackProcessor({ track: capture }).readable.getReader(); + const next = async () => { + const { value, done } = await reader.read(); + if (done) return false; + if (tooLarge) { value.close(); return false; } + await show(value, value.displayWidth, value.displayHeight, captureCssWidth); + return true; + }; + // The first frame starts the recorder, so a stop right after this returns has a frame to encode. + if (!(await next())) throw new Error("The tab capture ended before its first frame."); + void (async () => { while (await next()); })(); + }, + viewport(cssWidth) { + captureCssWidth = cssWidth; + }, cursor(x, y, click) { if (tooLarge) return; cursor = { x, y }; @@ -1013,6 +1051,7 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => paint(); }, async stop() { + capture?.stop(); if (!recorder) return { mimeType: null, count: 0, bytes: 0 }; if (recorder.state !== "inactive") { // Consume the cached frame, then wait for the final paint to reach the diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index ac1ca0ef048bc..37f5dca1c261d 100644 --- a/docs/user/html-renders.md +++ b/docs/user/html-renders.md @@ -6,7 +6,7 @@ Pages use your current theme, including custom themes, and follow light and dark Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages. -Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. +Before publishing, agents check their work with screenshots from a headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 200 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. ## MCP apps diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index eca8907e87e14..867b93ffd3289 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -220,7 +220,7 @@ directly. Every other device, and the desktop app for other environments, streams them from the host. Agents keep using them while no device is connected, and `localhost` addresses reach servers on the host. -The first tab downloads a headless Chrome, about 120 MB, into the T3 home. It +The first tab downloads a headless Chrome, about 200 MB, into the T3 home. It is the same browser [HTML renders](html-renders.md) use, so a host downloads it only once. Some Linux hosts need [setup](#browser-host-setup) before it can start.