From 8262db7d19bbef425649ad0760126739f0a1f88d Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:16:45 +0000 Subject: [PATCH 01/68] feat(preview): run the browser on the environment server Standalone servers (`t3 serve`, `npx t3`) now host preview tabs in headless Chromium, so phones, web clients, and agents can use the browser while no desktop app is connected. The desktop app's own environment keeps Electron tabs. - server: ServerBrowser owns tabs, serves the agent preview tools as the preferred automation host, and streams JPEG screencasts over /api/preview-stream with paced acks, motion-mode downscaling, and settle stills. Recording encodes in-browser to mp4. A pinned Chromium and font pack install on first use. - web: server tabs render through ServerBrowserSurface with touch input and a server-probed soft keyboard, float over chat, and pop into system picture-in-picture. - mobile: a browser screen, floating player, and iOS picture-in-picture built on the same stream. - fix: the details card no longer loops with a tall floating preview. --- apps/mobile/app.config.ts | 3 + apps/mobile/metro.config.js | 54 +- .../mobile/scripts/generate-device-stream.mts | 30 +- apps/mobile/src/Stack.tsx | 11 + .../browser/BrowserPreviewRouteScreen.tsx | 311 +++++ .../features/browser/PreviewStreamWebView.tsx | 265 ++++ .../features/browser/ThreadBrowserFloat.tsx | 138 ++ .../browser/browser-preview-button.tsx | 37 + .../src/features/browser/browserTabs.ts | 23 + .../browser/preview-stream-document.ts | 76 + .../browser/preview-stream.browser.ts | 523 +++++++ .../features/threads/ThreadDetailScreen.tsx | 36 + .../threads/floating-working-control.tsx | 36 +- apps/mobile/src/state/preview.ts | 187 +++ .../src/types/mobile-preview-stream.d.ts | 4 + apps/server/package.json | 1 + apps/server/src/auth/http.ts | 29 + apps/server/src/device/DeviceHubProxy.ts | 46 +- .../src/environment/ServerEnvironment.ts | 2 + .../server/src/mcp/PreviewAutomationBroker.ts | 18 +- apps/server/src/preview/Manager.ts | 11 + apps/server/src/preview/ServerBrowser.ts | 1224 +++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 422 ++++++ .../server/src/preview/ServerBrowserStream.ts | 131 ++ .../src/preview/ServerBrowserToolchain.ts | 600 ++++++++ .../src/preview/serverBrowserEnabled.ts | 13 + apps/server/src/server.ts | 10 + apps/web/src/browser/ElectronBrowserHost.tsx | 25 +- apps/web/src/browser/ServerBrowserSurface.tsx | 576 ++++++++ apps/web/src/browser/openFileInPreview.ts | 32 +- .../web/src/browser/serverPictureInPicture.ts | 191 +++ apps/web/src/components/ChatView.tsx | 55 +- .../src/components/preview/PreviewPanel.tsx | 4 +- .../src/components/preview/PreviewView.tsx | 101 +- .../preview/ThreadPreviewMiniPlayer.tsx | 197 ++- .../preview/addBrowserSurface.test.ts | 3 + .../src/components/preview/localServerTabs.ts | 37 + .../components/preview/openDiscoveredPort.ts | 6 +- .../preview/openPreviewSession.test.ts | 4 + .../components/preview/openPreviewSession.ts | 28 +- apps/web/src/routes/_chat.tsx | 7 +- apps/web/src/state/entities.ts | 17 + apps/web/src/state/previewStream.ts | 56 + docs/user/remote-access.md | 17 + packages/client-runtime/package.json | 4 + .../src/preview/serverBrowserStream.ts | 281 ++++ packages/contracts/src/environment.ts | 4 + packages/contracts/src/preview.ts | 12 + pnpm-lock.yaml | 3 + 49 files changed, 5704 insertions(+), 197 deletions(-) create mode 100644 apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx create mode 100644 apps/mobile/src/features/browser/PreviewStreamWebView.tsx create mode 100644 apps/mobile/src/features/browser/ThreadBrowserFloat.tsx create mode 100644 apps/mobile/src/features/browser/browser-preview-button.tsx create mode 100644 apps/mobile/src/features/browser/browserTabs.ts create mode 100644 apps/mobile/src/features/browser/preview-stream-document.ts create mode 100644 apps/mobile/src/features/browser/preview-stream.browser.ts create mode 100644 apps/mobile/src/state/preview.ts create mode 100644 apps/mobile/src/types/mobile-preview-stream.d.ts create mode 100644 apps/server/src/preview/ServerBrowser.ts create mode 100644 apps/server/src/preview/ServerBrowserPage.ts create mode 100644 apps/server/src/preview/ServerBrowserStream.ts create mode 100644 apps/server/src/preview/ServerBrowserToolchain.ts create mode 100644 apps/server/src/preview/serverBrowserEnabled.ts create mode 100644 apps/web/src/browser/ServerBrowserSurface.tsx create mode 100644 apps/web/src/browser/serverPictureInPicture.ts create mode 100644 apps/web/src/components/preview/localServerTabs.ts create mode 100644 apps/web/src/state/previewStream.ts create mode 100644 packages/client-runtime/src/preview/serverBrowserStream.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4eb77db7b651..f61798d53d63 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -270,6 +270,9 @@ const config: ExpoConfig = { NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", + // "Audio, AirPlay, and Picture in Picture": the browser screen's system + // picture in picture needs it to start and to stay up outside the app. + UIBackgroundModes: ["audio"], ITSAppUsesNonExemptEncryption: false, // The App Store screenshot harness rotates the iPad interface from // inside the app (CI denies osascript the Accessibility access that diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 9025dc4f6b28..5a9068675280 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -18,6 +18,7 @@ const licenseGeneratorSource = path.join( const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream"); +const generatedPreviewStreamRoot = path.join(__dirname, ".generated", "preview-stream"); const resolveShikiDependencyRoot = (packageName) => { const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); let currentDir = path.dirname(entryPath); @@ -48,6 +49,7 @@ config.resolver = { ...config.resolver?.extraNodeModules, "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, "@t3tools/mobile-device-stream": generatedDeviceStreamRoot, + "@t3tools/mobile-preview-stream": generatedPreviewStreamRoot, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), @@ -95,35 +97,57 @@ async function generateMobileThirdPartyLicenses() { ]); } -async function prepareDeviceStream() { - const { generateDeviceStreamScript } = await import( +async function prepareStreamScripts() { + const { generateDeviceStreamScript, generatePreviewStreamScript } = await import( pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href ); - await generateDeviceStreamScript(); + const generateAll = () => + Promise.all([generateDeviceStreamScript(), generatePreviewStreamScript()]); + await generateAll(); if (process.env.NODE_ENV !== "production") { let rebuild = Promise.resolve(); - for (const [directory, files] of [ - [path.join(__dirname, "src/features/devices"), ["device-stream.browser.ts"]], + for (const [directory, files, generate] of [ + [ + path.join(__dirname, "src/features/devices"), + ["device-stream.browser.ts"], + generateDeviceStreamScript, + ], + [ + path.join(__dirname, "src/features/browser"), + ["preview-stream.browser.ts"], + generatePreviewStreamScript, + ], + // The preview transport also imports `hubAccess.ts`. [ path.join(workspaceRoot, "packages/client-runtime/src/device"), ["stream.ts", "hubAccess.ts"], + generateAll, + ], + [ + path.join(workspaceRoot, "packages/client-runtime/src/preview"), + ["serverBrowserStream.ts"], + generatePreviewStreamScript, ], ]) { - // The generated module participates in Metro's normal Fast Refresh. + // The generated modules participate in Metro's normal Fast Refresh. fs.watch(directory, { persistent: false }, (_event, filename) => { if (filename && !files.includes(String(filename))) return; - rebuild = rebuild.then(generateDeviceStreamScript).catch((error) => { - console.error("Could not rebuild the device stream:", error); - }); + rebuild = rebuild + .then(generate) + .then(() => undefined) + .catch((error) => { + console.error("Could not rebuild a WebView stream script:", error); + }); }); } } } -module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() => - withUniwindConfig(config, { - cssEntryFile: "./global.css", - extraThemes, - polyfills: { rem: 14 }, - }), +module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareStreamScripts()]).then( + () => + withUniwindConfig(config, { + cssEntryFile: "./global.css", + extraThemes, + polyfills: { rem: 14 }, + }), ); diff --git a/apps/mobile/scripts/generate-device-stream.mts b/apps/mobile/scripts/generate-device-stream.mts index 249bbab6ca58..eede4dbf9bd6 100644 --- a/apps/mobile/scripts/generate-device-stream.mts +++ b/apps/mobile/scripts/generate-device-stream.mts @@ -5,8 +5,8 @@ import { build } from "vite-plus"; const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); -/** Metro embeds the shared browser transport as a small script in the native WebView. */ -export async function generateDeviceStreamScript() { +/** Metro embeds each shared browser transport as a small script in a native WebView. */ +async function generateWebViewScript(entry: string, name: string, output: string) { const result = await build({ configFile: false, logLevel: "silent", @@ -15,8 +15,8 @@ export async function generateDeviceStreamScript() { target: "es2022", minify: true, lib: { - entry: NodePath.join(mobileRoot, "src/features/devices/device-stream.browser.ts"), - name: "T3DeviceStream", + entry: NodePath.join(mobileRoot, entry), + name, formats: ["iife"], }, }, @@ -25,15 +25,29 @@ export async function generateDeviceStreamScript() { const chunk = bundles .flatMap((bundle) => ("output" in bundle ? bundle.output : [])) .find((output) => output.type === "chunk"); - if (!chunk) throw new Error("Device stream build did not emit a script."); - const root = NodePath.join(mobileRoot, ".generated/device-stream"); + if (!chunk) throw new Error(`${name} build did not emit a script.`); + const root = NodePath.join(mobileRoot, output); await NodeFSP.mkdir(root, { recursive: true }); - for (const [name, contents] of [ + for (const [file, contents] of [ ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`], ["package.json", '{"main":"index.js"}\n'], ] as const) { - const destination = NodePath.join(root, name); + const destination = NodePath.join(root, file); const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null); if (previous !== contents) await NodeFSP.writeFile(destination, contents); } } + +export const generateDeviceStreamScript = () => + generateWebViewScript( + "src/features/devices/device-stream.browser.ts", + "T3DeviceStream", + ".generated/device-stream", + ); + +export const generatePreviewStreamScript = () => + generateWebViewScript( + "src/features/browser/preview-stream.browser.ts", + "T3PreviewStream", + ".generated/preview-stream", + ); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 452d1a4dbed5..8dd0bea2f550 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; +import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; @@ -548,6 +549,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "ThreadQueue", "ThreadReviewComment", "ThreadDevicePreview", + "ThreadBrowserPreview", "ThreadSettingsSheet", ]); @@ -705,6 +707,15 @@ const RootStackConfig = createNativeStackNavigator({ navigationBarHidden: true, }, }), + ThreadBrowserPreview: createNativeStackScreen({ + screen: BrowserPreviewRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/browser`, + options: { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + }, + }), ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx new file mode 100644 index 000000000000..27fffbafc67e --- /dev/null +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -0,0 +1,311 @@ +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + AppState, + Platform, + Pressable, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { + refreshPreviewStreamAccess, + usePreviewStreamAccess, + useThreadServerBrowserTabs, +} from "../../state/preview"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; +import { + PreviewStreamWebView, + type PreviewPictureInPictureState, + type PreviewStreamRef, +} from "./PreviewStreamWebView"; + +const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>(); + +const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false }; + +type BrowserPreviewRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly tabId?: string; +}>; + +/** The nested native stack supplies the navigation bar inside the modal. */ +export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) { + const navigation = useNavigation(); + const onClose = useCallback(() => navigation.goBack(), [navigation]); + return ( + + + + {() => ( + + )} + + + + ); +} + +function BrowserPreviewScreen({ + environmentId, + threadId, + initialTabId, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly initialTabId: string | null; + readonly onClose: () => void; +}) { + const insets = useSafeAreaInsets(); + const { themeVariables } = useAppearancePreferences(); + const focused = useIsFocused(); + const [foreground, setForeground] = useState(AppState.currentState !== "background"); + const [selectedTabId, setSelectedTabId] = useState(initialTabId); + const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE); + const streamRef = useRef(null); + const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true }); + const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs); + const tabId = tab?.tabId ?? null; + // Pin the fallback so another tab's activity does not switch the view. + if (tabId !== null && tabId !== selectedTabId) setSelectedTabId(tabId); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setForeground(state !== "background"), + ); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (focused && loaded && tabs.length === 0) onClose(); + }, [focused, loaded, tabs.length, onClose]); + const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => { + setPictureInPicture({ supported: state.supported, active: state.active }); + if (detail) Alert.alert("Picture in picture is unavailable", detail); + }, []); + const selectTab = (next: string) => { + setPictureInPicture(NO_PICTURE_IN_PICTURE); + setSelectedTabId(next); + }; + // System picture in picture keeps showing this stream after the app leaves the foreground. + const live = (focused && foreground) || pictureInPicture.active; + + return ( + + streamRef.current?.togglePictureInPicture(), + }, + ] + : [] + } + menus={ + tabs.length > 1 + ? [ + { + title: "Tabs", + icon: "square.on.square", + items: tabs.map((entry) => ({ + id: entry.tabId, + title: browserTabTitle(entry), + subtitle: browserTabUrl(entry), + selected: entry.tabId === tabId, + onPress: () => selectTab(entry.tabId), + })), + }, + ] + : [] + } + /> + {Platform.OS === "ios" ? ( + + + + ) : null} + {tab ? ( + <> + streamRef.current?.command(input)} + /> + {live ? ( + + ) : ( + + )} + > + ) : ( + + {focused && foreground ? ( + + ) : null} + + )} + + ); +} + +function BrowserAddressBar({ + tab, + onCommand, +}: { + readonly tab: PreviewSessionSnapshot; + readonly onCommand: PreviewStreamRef["command"]; +}) { + const url = browserTabUrl(tab); + // Null while not editing, so agent navigation keeps the field current. + const [draft, setDraft] = useState(null); + const submit = () => { + const value = draft ?? url; + setDraft(null); + try { + onCommand({ type: "navigate", url: normalizePreviewUrl(value) }); + } catch { + Alert.alert("Could not open this address", "Enter an http or https URL."); + } + }; + return ( + + onCommand({ type: "history", delta: -1 })} + /> + onCommand({ type: "history", delta: 1 })} + /> + setDraft(null)} + onSubmitEditing={submit} + placeholder="Enter a URL" + placeholderTextColorClassName="accent-placeholder" + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="go" + selectTextOnFocus + className="h-10 min-w-0 flex-1 rounded-full border border-input-border bg-input px-4 font-sans text-sm text-foreground" + /> + onCommand({ type: "reload" })} + /> + + ); +} + +function OpenBrowserPreview({ + environmentId, + threadId, + tabId, + streamRef, + onPictureInPicture, +}: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly tabId: string; + readonly streamRef: RefObject; + readonly onPictureInPicture: (state: PreviewPictureInPictureState, detail?: string) => void; +}) { + const { themeVariables } = useAppearancePreferences(); + const { access, error, refresh } = usePreviewStreamAccess(environmentId); + const onUnauthorized = useCallback( + () => refreshPreviewStreamAccess(environmentId), + [environmentId], + ); + return access ? ( + + ) : ( + + {error ? ( + <> + + {error} + + + Retry + + > + ) : ( + <> + + Connecting to browser... + > + )} + + ); +} diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx new file mode 100644 index 000000000000..772305a9e1d1 --- /dev/null +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -0,0 +1,265 @@ +import previewStreamScript from "@t3tools/mobile-preview-stream"; +import type { PreviewStreamInput } from "@t3tools/client-runtime/preview/server-browser-stream"; +import { + useEffect, + useEffectEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; +import { WebView } from "react-native-webview"; + +import { AppText } from "../../components/AppText"; + +import { + previewStreamDocument, + previewStreamMessage, + type PreviewStreamConfiguration, +} from "./preview-stream-document"; + +export interface PreviewStreamRef { + /** `navigate`, `history`, and `reload` wait for the socket if it is not open yet. */ + readonly command: (input: PreviewStreamInput) => void; + readonly togglePictureInPicture: () => void; +} + +export interface PreviewPictureInPictureState { + readonly supported: boolean; + readonly active: boolean; +} + +type NativeStreamBridge = { + readonly ref?: Ref; + /** Refresh stream access; new access remounts the document with a fresh ticket. */ + readonly onUnauthorized: () => void; + /** The tab was closed on the server. */ + readonly onGone?: () => void; + readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void; + readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; + /** The floating player shows a spinner without text or a reconnect button. */ + readonly compact?: boolean; +}; + +const UNAUTHORIZED_RETRY_MAX_MS = 30_000; + +/** A server-hosted preview tab streamed into a WebView that runs the shared transport. */ +export function PreviewStreamWebView({ + ref, + ...props +}: PreviewStreamConfiguration & NativeStreamBridge) { + const [attempt, setAttempt] = useState(0); + const processRetried = useRef(false); + const unauthorized = useRef(0); + const refreshTimer = useRef | null>(null); + useEffect( + () => () => { + if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); + }, + [], + ); + const configuration = JSON.stringify({ + access: props.access, + threadId: props.threadId, + tabId: props.tabId, + interactive: props.interactive, + background: props.background, + } satisfies PreviewStreamConfiguration); + return ( + { + // The client has stopped. Restart it, and back off when fresh tickets keep + // getting refused, e.g. a session without operate scope. + const retry = unauthorized.current++; + if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); + refreshTimer.current = setTimeout( + () => { + refreshTimer.current = null; + setAttempt((current) => current + 1); + props.onUnauthorized(); + }, + retry === 0 ? 0 : Math.min(1_000 * 2 ** retry, UNAUTHORIZED_RETRY_MAX_MS), + ); + }} + onRetry={() => { + processRetried.current = false; + setAttempt((current) => current + 1); + props.onUnauthorized(); + }} + onStreaming={() => { + processRetried.current = false; + unauthorized.current = 0; + }} + onRecoverProcess={() => { + if (processRetried.current) return false; + processRetried.current = true; + setAttempt((current) => current + 1); + return true; + }} + /> + ); +} + +function PreviewStreamDocumentView({ + ref, + configuration, + background, + compact, + onUnauthorized, + onGone, + onViewport, + onPictureInPicture, + onRetry, + onStreaming, + onRecoverProcess, +}: Omit & { + readonly configuration: string; + readonly background: string; + readonly compact: boolean; + readonly onRetry: () => void; + readonly onStreaming: () => void; + readonly onRecoverProcess: () => boolean; +}) { + const webView = useRef>(null); + const active = useRef(true); + const failed = useRef(false); + const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting"); + const [error, setError] = useState(null); + const [gone, setGone] = useState(false); + const [started, setStarted] = useState(false); + const fail = (message: string) => { + if (!active.current || failed.current) return; + failed.current = true; + webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + setError(message); + setStatus("error"); + }; + // The shared transport owns reconnects once the document acknowledges startup. + const bootstrapTimedOut = useEffectEvent(() => + fail("Browser viewer could not start. Reconnect to try again."), + ); + useEffect(() => { + if (started) return; + const timer = setTimeout(bootstrapTimedOut, 15_000); + return () => clearTimeout(timer); + }, [started]); + const source = useMemo( + () => ({ + html: previewStreamDocument(configuration, previewStreamScript), + baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///", + }), + [configuration], + ); + useImperativeHandle(ref, () => ({ + command: (input) => + webView.current?.injectJavaScript( + `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`, + ), + togglePictureInPicture: () => + webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"), + })); + useLayoutEffect(() => { + active.current = true; + const view = webView.current; + return () => { + active.current = false; + view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + }; + }, []); + const processTerminated = () => { + if (!active.current || failed.current) return; + if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again."); + }; + return ( + + + ref={webView} + source={source} + originWhitelist={["*"]} + scrollEnabled={false} + bounces={false} + mixedContentMode="always" + allowUniversalAccessFromFileURLs + contentInsetAdjustmentBehavior="never" + setSupportMultipleWindows={false} + // Picture in picture plays the canvas as an inline muted video, started from native chrome. + allowsInlineMediaPlayback + allowsPictureInPictureMediaPlayback + mediaPlaybackRequiresUserAction={false} + style={{ flex: 1, backgroundColor: background }} + onError={() => fail("Browser viewer could not load. Reconnect to try again.")} + onHttpError={() => fail("Browser viewer could not load. Reconnect to try again.")} + onContentProcessDidTerminate={processTerminated} + onRenderProcessGone={processTerminated} + onShouldStartLoadWithRequest={(request) => + request.url === "about:blank" || request.url === source.baseUrl + } + onMessage={(event) => { + if (!active.current || failed.current) return; + const message = previewStreamMessage(event.nativeEvent.data); + if (message === null) return; + switch (message.type) { + case "unauthorized": + onUnauthorized(); + return; + case "gone": + setGone(true); + fail("This tab was closed."); + onGone?.(); + return; + case "viewport": + onViewport?.(message); + return; + case "pictureInPicture": + onPictureInPicture?.(message, message.detail); + return; + case "status": + setStarted(true); + if (message.status === "error") { + fail(message.detail ?? "Browser stream failed."); + return; + } + setStatus(message.status); + if (message.status === "streaming") onStreaming(); + } + }} + /> + {status !== "streaming" ? ( + + {status === "connecting" ? : null} + {compact ? null : ( + + {status === "error" ? error : "Connecting to browser..."} + + )} + {status === "error" && !gone && !compact ? ( + + Reconnect + + ) : null} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx new file mode 100644 index 000000000000..68624c2c7dc7 --- /dev/null +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -0,0 +1,138 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, View } from "react-native"; +import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; + +import { SymbolView } from "../../components/AppSymbol"; +import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "../../state/preview"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { browserTabTitle } from "./browserTabs"; +import { PreviewStreamWebView } from "./PreviewStreamWebView"; + +const PLAYER_LONG_SIDE = 184; +const PLAYER_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); +const PLAYER_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); + +/** + * Floats a server browser tab over chat when the agent opens one, like web's + * "Float preview over chat". View-only: a tap opens the browser screen. + */ +export function ThreadBrowserFloat(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly tabs: ReadonlyArray; + readonly loaded: boolean; + readonly top: number; + readonly onOpen: (tabId: string) => void; +}) { + const [tabId, setTabId] = useState(null); + const known = useRef | null>(null); + useEffect(() => { + if (!props.loaded) return; + const ids = props.tabs.map((tab) => tab.tabId); + const previous = known.current; + known.current = new Set(ids); + // The first list is a baseline, so reopening a thread does not resurface old tabs. + if (previous === null) return; + const opened = ids.findLast((id) => !previous.has(id)); + if (opened !== undefined) setTabId(opened); + }, [props.loaded, props.tabs]); + const focused = useIsFocused(); + const [foreground, setForeground] = useState(AppState.currentState !== "background"); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setForeground(state !== "background"), + ); + return () => subscription.remove(); + }, []); + const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null; + const onOpen = props.onOpen; + const open = useCallback(() => { + if (tabId === null) return; + setTabId(null); + onOpen(tabId); + }, [onOpen, tabId]); + const close = useCallback(() => setTabId(null), []); + if (!tab || !focused) return null; + return ( + + ); +} + +function FloatingBrowserPlayer(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly tab: PreviewSessionSnapshot; + readonly live: boolean; + readonly top: number; + readonly onOpen: () => void; + readonly onClose: () => void; +}) { + const { environmentId } = props; + const { themeVariables } = useAppearancePreferences(); + const { access } = usePreviewStreamAccess(environmentId); + const onUnauthorized = useCallback( + () => refreshPreviewStreamAccess(environmentId), + [environmentId], + ); + const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null); + // The page keeps its own size; the player scales it into a box of the same shape. + const aspect = Math.min(Math.max(viewport ? viewport.width / viewport.height : 16 / 10, 0.5), 2); + const width = aspect >= 1 ? PLAYER_LONG_SIDE : Math.round(PLAYER_LONG_SIDE * aspect); + const height = Math.round(width / aspect); + const background = themeVariables["--color-sheet-solid"]; + return ( + + + + {access && props.live ? ( + + ) : ( + + + + )} + + + + + + + ); +} diff --git a/apps/mobile/src/features/browser/browser-preview-button.tsx b/apps/mobile/src/features/browser/browser-preview-button.tsx new file mode 100644 index 000000000000..78bd6d8eebed --- /dev/null +++ b/apps/mobile/src/features/browser/browser-preview-button.tsx @@ -0,0 +1,37 @@ +import { Pressable, View } from "react-native"; + +import { AppText } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; + +/** Opens the thread's server-hosted browser tabs. Sits beside the device preview button. */ +export function BrowserPreviewButton(props: { + readonly count: number; + readonly onPress: () => void; + readonly compact?: boolean; +}) { + const compact = props.compact ?? true; + return ( + + + {!compact ? ( + + {props.count === 1 ? "One tab open" : `${props.count} tabs open`} + + ) : props.count > 1 ? ( + + {props.count} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/browser/browserTabs.ts b/apps/mobile/src/features/browser/browserTabs.ts new file mode 100644 index 000000000000..509ddad5dcf7 --- /dev/null +++ b/apps/mobile/src/features/browser/browserTabs.ts @@ -0,0 +1,23 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; + +/** The tab the agent touched last, the default when nothing is selected. */ +export function latestBrowserTab(tabs: ReadonlyArray) { + return tabs.reduce( + (latest, tab) => (latest === null || tab.updatedAt > latest.updatedAt ? tab : latest), + null, + ); +} + +export function browserTabUrl(tab: PreviewSessionSnapshot) { + return tab.navStatus._tag === "Idle" ? "" : tab.navStatus.url; +} + +export function browserTabTitle(tab: PreviewSessionSnapshot) { + if (tab.navStatus._tag === "Idle") return "New tab"; + if (tab.navStatus.title.trim()) return tab.navStatus.title; + try { + return new URL(tab.navStatus.url).host || tab.navStatus.url; + } catch { + return tab.navStatus.url; + } +} diff --git a/apps/mobile/src/features/browser/preview-stream-document.ts b/apps/mobile/src/features/browser/preview-stream-document.ts new file mode 100644 index 000000000000..bd60486751ba --- /dev/null +++ b/apps/mobile/src/features/browser/preview-stream-document.ts @@ -0,0 +1,76 @@ +import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; + +export interface PreviewStreamConfiguration { + readonly access: DeviceHubAccess; + readonly threadId: string; + readonly tabId: string; + /** Taps, scrolls, keys, and `resize` to the view size. The floating player only watches. */ + readonly interactive: boolean; + readonly background: string; +} + +/** Messages the WebView document posts to the native view. */ +export type PreviewStreamMessage = + | { + readonly type: "status"; + readonly status: "connecting" | "streaming" | "error"; + readonly detail?: string; + } + | { readonly type: "unauthorized" } + | { readonly type: "gone" } + | { readonly type: "viewport"; readonly width: number; readonly height: number } + | { + readonly type: "pictureInPicture"; + readonly supported: boolean; + readonly active: boolean; + readonly detail?: string; + }; + +export function previewStreamDocument(configuration: string, script: string) { + // Tickets and URLs are data, including any HTML delimiter characters. + const safeConfiguration = configuration.replace(/`; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export function previewStreamMessage(data: string): PreviewStreamMessage | null { + let message: unknown; + try { + message = JSON.parse(data); + } catch { + // Ignore messages that are not part of the stream bridge. + return null; + } + if (!isRecord(message)) return null; + const detail = typeof message.detail === "string" ? message.detail : undefined; + switch (message.type) { + case "unauthorized": + case "gone": + return { type: message.type }; + case "status": + return message.status === "connecting" || + message.status === "streaming" || + message.status === "error" + ? { type: "status", status: message.status, ...(detail ? { detail } : {}) } + : null; + case "viewport": + return typeof message.width === "number" && typeof message.height === "number" + ? { type: "viewport", width: message.width, height: message.height } + : null; + case "pictureInPicture": + return typeof message.supported === "boolean" && typeof message.active === "boolean" + ? { + type: "pictureInPicture", + supported: message.supported, + active: message.active, + ...(detail ? { detail } : {}), + } + : null; + default: + return null; + } +} diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts new file mode 100644 index 000000000000..728d6d31e47e --- /dev/null +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -0,0 +1,523 @@ +import { + createPreviewFramePainter, + createPreviewStreamClient, + previewStreamModifiers, + type PreviewStreamClient, + type PreviewStreamInput, + type PreviewStreamViewport, +} from "@t3tools/client-runtime/preview/server-browser-stream"; + +import type { PreviewStreamConfiguration, PreviewStreamMessage } from "./preview-stream-document"; + +declare global { + interface Window { + ReactNativeWebView: { postMessage: (message: string) => void }; + } +} + +/** WebKit's presentation API, the only picture in picture entry on older iOS. */ +interface PresentationVideo extends HTMLVideoElement { + webkitSupportsPresentationMode?: (mode: string) => boolean; + webkitSetPresentationMode?: (mode: "inline" | "picture-in-picture") => void; + webkitPresentationMode?: string; +} + +type WheelInput = Extract; + +const RESIZE_DEBOUNCE_MS = 150; +const TAP_SLOP_PX = 8; +const MULTI_CLICK_MS = 500; +const MULTI_CLICK_SLOP_PX = 4; +const WHEEL_LINE_PX = 16; +// JPEG frames past 2x cost bandwidth without a visible gain on a phone. +const MAX_PIXEL_RATIO = 2; + +interface Viewer { + readonly stop: () => void; + readonly command: (input: PreviewStreamInput) => void; + readonly togglePictureInPicture: () => Promise; +} + +let activeViewer: Viewer | null = null; + +export function stop() { + activeViewer?.stop(); + activeViewer = null; +} + +/** Navigation and history from the native chrome. Waits for the socket if it is not open yet. */ +export function command(input: PreviewStreamInput) { + activeViewer?.command(input); +} + +export function pictureInPicture() { + void activeViewer?.togglePictureInPicture(); +} + +/** + * Bundled into a native WebView without React or Expo's web runtime. Draws a + * server tab's JPEG frames into a letterboxed canvas. Interactive viewers turn + * taps into clicks, drags into wheel scrolls, and soft keyboard input into key + * and text messages, and resize fill-mode tabs to the view. + */ +export function start(configuration: PreviewStreamConfiguration) { + stop(); + const post = (message: PreviewStreamMessage) => { + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string. + window.ReactNativeWebView.postMessage(JSON.stringify(message)); + }; + const { interactive } = configuration; + Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" }); + Object.assign(document.body.style, { + margin: "0", + height: "100%", + overflow: "hidden", + background: configuration.background, + }); + const container = document.createElement("div"); + Object.assign(container.style, { + position: "fixed", + left: "0", + top: "0", + width: "100%", + height: "100%", + overflow: "hidden", + }); + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "img"); + canvas.setAttribute("aria-label", "Browser page"); + Object.assign(canvas.style, { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + objectFit: "contain", + touchAction: "none", + userSelect: "none", + webkitUserSelect: "none", + webkitTouchCallout: "none", + }); + container.append(canvas); + // Focus target for page keyboard input, visually hidden like `sr-only`. Pinned + // top-left so focusing it never scrolls; 16px keeps iOS from zooming on focus. + const input = document.createElement("textarea"); + input.setAttribute("aria-label", "Browser page input"); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.setAttribute("autocorrect", "off"); + Object.assign(input.style, { + position: "fixed", + left: "0", + top: "0", + width: "1px", + height: "1px", + padding: "0", + margin: "-1px", + border: "0", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + fontSize: "16px", + }); + document.body.replaceChildren(container, ...(interactive ? [input] : [])); + + let stopped = false; + let streaming = false; + let viewport: PreviewStreamViewport | null = null; + let size: { width: number; height: number } | null = null; + // Frame cap in device px, fixed per socket. It only grows, so only outgrowing it reconnects. + let cap: { width: number; height: number } | null = null; + let client: PreviewStreamClient | null = null; + let pendingCommand: PreviewStreamInput | null = null; + let resizeTimer: ReturnType | null = null; + let wheelFrame: number | null = null; + let pendingWheel: WheelInput | null = null; + // Whether the page point under a touch takes text; null until the server answers. + let probe: { x: number; y: number; editable: boolean | null; tapped: boolean } | null = null; + let touch: { + pointerId: number; + startX: number; + startY: number; + lastX: number; + lastY: number; + panning: boolean; + } | null = null; + let lastTap: { time: number; x: number; y: number; count: number } | null = null; + + const reportStatus = (status: "connecting" | "streaming") => { + streaming = status === "streaming"; + post({ type: "status", status }); + }; + const painter = createPreviewFramePainter(canvas, () => { + if (!streaming && !stopped) reportStatus("streaming"); + }); + const send = (message: PreviewStreamInput) => client?.send(message) ?? false; + + const connect = () => { + client?.stop(); + if (!cap || stopped) return; + const next = createPreviewStreamClient( + { + access: configuration.access, + threadId: configuration.threadId, + tabId: configuration.tabId, + maxWidth: cap.width, + maxHeight: cap.height, + }, + { + onFrame: (jpeg) => painter.paint(jpeg), + onViewport: (page) => { + if (viewport?.width === page.width && viewport.height === page.height) return; + viewport = page; + post({ type: "viewport", width: page.width, height: page.height }); + }, + onProbe: (result) => { + const current = probe; + if (!current || current.x !== result.x || current.y !== result.y) return; + current.editable = result.editable; + if (!current.tapped) return; + // Late answer: Android still raises the keyboard; iOS waits for the next tap. + probe = null; + if (result.editable) input.focus({ preventScroll: true }); + else input.blur(); + }, + onConnectedChange: (connected) => { + if (!connected) { + if (streaming) reportStatus("connecting"); + return; + } + if (interactive && size) next.send({ type: "resize", ...size }); + const queued = pendingCommand; + pendingCommand = null; + if (queued) next.send(queued); + }, + onUnauthorized: () => post({ type: "unauthorized" }), + onGone: () => post({ type: "gone" }), + }, + ); + client = next; + }; + + const measure = () => { + resizeTimer = null; + const rect = container.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + const next = { width: Math.round(rect.width), height: Math.round(rect.height) }; + if (size?.width === next.width && size.height === next.height) return; + size = next; + if (interactive) send({ type: "resize", ...next }); + const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); + // The floating player scales a page of any shape into its box, so its cap is square. + const side = Math.max(next.width, next.height); + const width = Math.round((interactive ? next.width : side) * ratio); + const height = Math.round((interactive ? next.height : side) * ratio); + if (cap && cap.width >= width && cap.height >= height) return; + cap = { width: Math.max(width, cap?.width ?? 0), height: Math.max(height, cap?.height ?? 0) }; + connect(); + }; + const observer = new ResizeObserver(() => { + if (resizeTimer !== null) clearTimeout(resizeTimer); + // The first size connects right away; later ones settle before resizing the page. + resizeTimer = setTimeout(measure, size === null ? 0 : RESIZE_DEBOUNCE_MS); + }); + observer.observe(container); + + // iOS keeps the layout viewport under the soft keyboard; follow the visible area + // so the page resizes above it. + const visualViewport = window.visualViewport; + const followVisualViewport = () => { + if (!visualViewport) return; + container.style.top = `${visualViewport.offsetTop}px`; + container.style.height = `${visualViewport.height}px`; + }; + if (interactive) { + visualViewport?.addEventListener("resize", followVisualViewport); + visualViewport?.addEventListener("scroll", followVisualViewport); + } + + const pagePoint = (clientX: number, clientY: number, clamp: boolean) => { + if (!viewport || canvas.width === 0 || canvas.height === 0) return null; + const rect = canvas.getBoundingClientRect(); + // `object-fit: contain` letterboxes the frame inside the canvas box. + const fit = Math.min(rect.width / canvas.width, rect.height / canvas.height); + const width = canvas.width * fit; + const height = canvas.height * fit; + if (!(width > 0 && height > 0)) return null; + const scale = viewport.width / width; + const x = (clientX - rect.left - (rect.width - width) / 2) * scale; + const y = (clientY - rect.top - (rect.height - height) / 2) * (viewport.height / height); + const inside = x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height; + if (!inside && !clamp) return null; + return { + x: Math.min(Math.max(x, 0), viewport.width), + y: Math.min(Math.max(y, 0), viewport.height), + scale, + }; + }; + + // Wheel deltas coalesce to one message per animation frame. + const flushWheel = () => { + if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); + wheelFrame = null; + const wheel = pendingWheel; + pendingWheel = null; + if (wheel) send(wheel); + }; + const queueWheel = (point: { x: number; y: number }, deltaX: number, deltaY: number) => { + pendingWheel = { + type: "wheel", + x: point.x, + y: point.y, + deltaX: (pendingWheel?.deltaX ?? 0) + deltaX, + deltaY: (pendingWheel?.deltaY ?? 0) + deltaY, + modifiers: 0, + }; + wheelFrame ??= requestAnimationFrame(flushWheel); + }; + + const onPointerDown = (event: PointerEvent) => { + if (!event.isPrimary) return; + event.preventDefault(); + touch = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + panning: false, + }; + const point = pagePoint(event.clientX, event.clientY, false); + probe = point ? { x: point.x, y: point.y, editable: null, tapped: false } : null; + if (point) send({ type: "probe", x: point.x, y: point.y }); + }; + const onPointerMove = (event: PointerEvent) => { + if (!touch || touch.pointerId !== event.pointerId) return; + if ( + !touch.panning && + Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) < TAP_SLOP_PX + ) { + return; + } + touch.panning = true; + const point = pagePoint(event.clientX, event.clientY, true); + // Dragging the page up scrolls it down, following the finger. + if (point) { + queueWheel( + point, + (touch.lastX - event.clientX) * point.scale, + (touch.lastY - event.clientY) * point.scale, + ); + } + touch.lastX = event.clientX; + touch.lastY = event.clientY; + }; + const onPointerUp = (event: PointerEvent) => { + const ended = touch; + if (!ended || ended.pointerId !== event.pointerId) return; + touch = null; + const answered = probe; + if (ended.panning) { + probe = null; + return; + } + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + // Focusing inside the tap's user activation is what lets iOS raise the keyboard. + if (answered?.editable === true) input.focus({ preventScroll: true }); + else if (answered?.editable === false) input.blur(); + if (answered?.editable === null) answered.tapped = true; + else probe = null; + flushWheel(); + const last = lastTap; + const clickCount = + last && + event.timeStamp - last.time < MULTI_CLICK_MS && + Math.hypot(event.clientX - last.x, event.clientY - last.y) < MULTI_CLICK_SLOP_PX + ? last.count + 1 + : 1; + lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count: clickCount }; + const at = { x: point.x, y: point.y, modifiers: 0 }; + send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 }); + send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount }); + send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); + }; + const onPointerCancel = (event: PointerEvent) => { + if (touch?.pointerId === event.pointerId) touch = null; + }; + // Trackpads and mice on tablets scroll with wheel events. + const onWheel = (event: WheelEvent) => { + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + event.preventDefault(); + const unit = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? WHEEL_LINE_PX + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? (viewport?.height ?? 0) + : 1; + queueWheel(point, event.deltaX * unit, event.deltaY * unit); + }; + // Keeps focus in the page input and stops native selection and callouts. + const preventDefault = (event: Event) => event.preventDefault(); + + const onKey = (action: "down" | "up", event: KeyboardEvent) => { + // IME and soft keyboards deliver text through composition and input events. + if ( + event.isComposing || + event.keyCode === 229 || + event.key === "Process" || + event.key === "Unidentified" + ) { + return; + } + const shortcut = event.ctrlKey || event.metaKey; + // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. + const text = shortcut + ? undefined + : [...event.key].length === 1 + ? event.key + : event.key === "Enter" + ? "\r" + : undefined; + send({ + type: "key", + action, + key: event.key, + code: event.code, + keyCode: event.keyCode, + ...(action === "down" && text !== undefined ? { text } : {}), + modifiers: previewStreamModifiers(event), + }); + if (!shortcut) event.preventDefault(); + }; + const onKeyDown = (event: KeyboardEvent) => onKey("down", event); + const onKeyUp = (event: KeyboardEvent) => onKey("up", event); + const onInput = (event: Event) => { + if (event instanceof InputEvent && event.isComposing) return; + if (input.value) send({ type: "text", text: input.value }); + input.value = ""; + }; + const onCompositionEnd = (event: CompositionEvent) => { + if (event.data) send({ type: "text", text: event.data }); + input.value = ""; + }; + + if (interactive) { + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerCancel); + canvas.addEventListener("wheel", onWheel, { passive: false }); + canvas.addEventListener("mousedown", preventDefault); + canvas.addEventListener("contextmenu", preventDefault); + input.addEventListener("keydown", onKeyDown); + input.addEventListener("keyup", onKeyUp); + input.addEventListener("input", onInput); + input.addEventListener("compositionend", onCompositionEnd); + } + + // Picture in picture plays the canvas as a muted video. It is created on first + // use so a viewer that never pops out pays nothing for it. + const supportsPresentationMode = (element: PresentationVideo) => + element.webkitSupportsPresentationMode?.("picture-in-picture") === true; + const pictureInPictureSupported = + interactive && + typeof canvas.captureStream === "function" && + (document.pictureInPictureEnabled === true || + supportsPresentationMode(document.createElement("video"))); + let video: PresentationVideo | null = null; + const pictureInPictureActive = () => + video !== null && + (document.pictureInPictureElement === video || + video.webkitPresentationMode === "picture-in-picture"); + const reportPictureInPicture = (detail?: string) => + post({ + type: "pictureInPicture", + supported: pictureInPictureSupported, + active: pictureInPictureActive(), + ...(detail ? { detail } : {}), + }); + const ensureVideo = () => { + if (video) return video; + const element: PresentationVideo = document.createElement("video"); + element.muted = true; + element.playsInline = true; + element.autoplay = true; + element.setAttribute("playsinline", ""); + // Under the canvas at full size: WebKit pauses muted video it considers off screen. + Object.assign(element.style, { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + objectFit: "contain", + pointerEvents: "none", + }); + element.srcObject = canvas.captureStream(); + // A static page sends no new frames; repaint once so the stream has one. + if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); + for (const name of [ + "enterpictureinpicture", + "leavepictureinpicture", + "webkitpresentationmodechanged", + ]) { + element.addEventListener(name, () => { + // The hidden inline copy stops decoding once the window closes. + if (!pictureInPictureActive()) element.pause(); + reportPictureInPicture(); + }); + } + container.prepend(element); + video = element; + return element; + }; + const togglePictureInPicture = async () => { + if (!pictureInPictureSupported) return; + try { + if (pictureInPictureActive()) { + if (document.pictureInPictureElement) await document.exitPictureInPicture(); + else video?.webkitSetPresentationMode?.("inline"); + return; + } + const element = ensureVideo(); + await element.play(); + if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); + else element.webkitSetPresentationMode?.("picture-in-picture"); + } catch (error) { + reportPictureInPicture( + error instanceof Error ? error.message : "Picture in picture is unavailable.", + ); + } + }; + + const viewer: Viewer = { + stop: () => { + stopped = true; + observer.disconnect(); + visualViewport?.removeEventListener("resize", followVisualViewport); + visualViewport?.removeEventListener("scroll", followVisualViewport); + if (resizeTimer !== null) clearTimeout(resizeTimer); + if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); + painter.stop(); + client?.stop(); + client = null; + if (video) { + for (const track of video.srcObject instanceof MediaStream + ? video.srcObject.getTracks() + : []) { + track.stop(); + } + video.srcObject = null; + } + }, + command: (message) => { + if (!send(message)) pendingCommand = message; + }, + togglePictureInPicture, + }; + activeViewer = viewer; + window.addEventListener("pagehide", stop, { once: true }); + reportStatus("connecting"); + if (interactive) reportPictureInPicture(); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 129c24150b75..1d3beebe9247 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -95,6 +95,8 @@ import { editPendingThreadMessage } from "../../state/edit-pending-thread-messag import { deviceEnvironment } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; +import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat"; +import { useThreadServerBrowserTabs } from "../../state/preview"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { @@ -333,6 +335,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId: props.selectedThread.id, }); }, [navigation, props.environmentId, props.selectedThread.id]); + const browserTabs = useThreadServerBrowserTabs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + enabled: props.serverConfig?.environment.capabilities.serverBrowser === true, + }); + const openBrowserPreview = useCallback( + (tabId?: string) => { + Keyboard.dismiss(); + navigation.navigate("ThreadBrowserPreview", { + environmentId: props.environmentId, + threadId: props.selectedThread.id, + ...(tabId === undefined ? {} : { tabId }), + }); + }, + [navigation, props.environmentId, props.selectedThread.id], + ); const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); @@ -512,6 +530,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread queuedCount > 0 || agentsSegment !== null || devicePreviews.length > 0 || + browserTabs.tabs.length > 0 || props.connectionStateLabel !== "connected" || props.queuedMessages.length > 0 || props.selectedThreadFeed.some( @@ -1129,6 +1148,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread )} + {showContent ? ( + + ) : null} + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( 0 + ? { count: browserTabs.tabs.length, onPress: () => openBrowserPreview() } + : null + } showScrollToEnd={showScrollToEndButton} onScrollToEnd={handleScrollToEnd} agents={agentsSegment} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 27d6757f3158..2e032eb8ba5e 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -26,6 +26,7 @@ import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; @@ -67,6 +68,7 @@ export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly status: FloatingWorkingStatus | null; readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null; + readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; readonly agents: SubagentPillSegment | null; @@ -81,8 +83,8 @@ export function FloatingWorkingControl(props: { const [queueWidth, setQueueWidth] = useState(0); const [agentsWidth, setAgentsWidth] = useState(0); const hasQueue = props.queuedCount > 0; - const hasDevicePreview = props.devicePreview !== null; - const [deviceWidth, setDeviceWidth] = useState(0); + const hasPreview = props.devicePreview !== null || props.browserPreview !== null; + const [previewWidth, setPreviewWidth] = useState(0); const agents = props.agents; const hasAgents = agents !== null; // Segments keep their measured width; only the status label absorbs the @@ -94,7 +96,7 @@ export function FloatingWorkingControl(props: { 32 - (hasQueue ? queueWidth : 0) - (hasAgents ? agentsWidth : 0) - - (hasDevicePreview ? deviceWidth : 0), + (hasPreview ? previewWidth : 0), ); const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); @@ -131,7 +133,7 @@ export function FloatingWorkingControl(props: { // Forget the width while no label is shown so the next one appears at its // own size instead of animating from the previous label's. const hasStatus = props.status !== null; - const hasCapsule = hasStatus || hasQueue || hasAgents || hasDevicePreview; + const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview; useEffect(() => { if (!hasStatus) { measuredWidthRef.current = null; @@ -151,9 +153,15 @@ export function FloatingWorkingControl(props: { return null; } + // Both preview buttons fit beside each other only as icons. + const previewCompact = + hasStatus || + hasAgents || + hasQueue || + (props.devicePreview !== null && props.browserPreview !== null); // The queue, agents, and reconnect labels have separate tap targets. const statusInteractive = props.status?.kind === "connection"; - const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview; + const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = @@ -184,16 +192,18 @@ export function FloatingWorkingControl(props: { const capsuleContent = ( {statusContent} - {props.devicePreview !== null ? ( + {hasPreview ? ( setDeviceWidth(event.nativeEvent.layout.width)} + onLayout={(event) => setPreviewWidth(event.nativeEvent.layout.width)} > {hasStatus ? : null} - + {props.devicePreview !== null ? ( + + ) : null} + {props.browserPreview !== null ? ( + + ) : null} ) : null} {agents !== null ? ( @@ -205,7 +215,7 @@ export function FloatingWorkingControl(props: { onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)} className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70" > - {hasStatus || hasDevicePreview ? : null} + {hasStatus || hasPreview ? : null} {agents.label} @@ -222,7 +232,7 @@ export function FloatingWorkingControl(props: { style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }} className="h-11 flex-row items-center gap-2 px-3 active:opacity-70" > - {hasStatus || hasDevicePreview || hasAgents ? ( + {hasStatus || hasPreview || hasAgents ? ( ) : null} diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts new file mode 100644 index 000000000000..9640a224e43e --- /dev/null +++ b/apps/mobile/src/state/preview.ts @@ -0,0 +1,187 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream"; +import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; +import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { + EnvironmentId, + PreviewEvent, + PreviewListResult, + PreviewSessionSnapshot, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; +import { useEnvironmentQuery } from "./query"; +import { environmentSession, usePreparedConnection } from "./session"; + +export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); + +interface ThreadPreviewTabs { + /** Null until the first list result or event arrives. */ + readonly serverEpoch: string | null; + readonly revision: number; + readonly sessions: ReadonlyArray; + /** A list result has arrived, so `sessions` covers tabs opened before this view. */ + readonly listed: boolean; +} + +const EMPTY_TABS: ThreadPreviewTabs = { + serverEpoch: null, + revision: 0, + sessions: [], + listed: false, +}; +const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty")); + +function reconcileList(current: ThreadPreviewTabs, result: PreviewListResult): ThreadPreviewTabs { + if ( + current.listed && + current.serverEpoch === result.serverEpoch && + result.revision <= current.revision + ) { + return current; + } + return { + serverEpoch: result.serverEpoch, + revision: result.revision, + sessions: result.sessions, + listed: true, + }; +} + +function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs { + if (event.revision <= current.revision) return current; + const others = current.sessions.filter((session) => session.tabId !== event.tabId); + const existing = current.sessions.find((session) => session.tabId === event.tabId); + const sessions = (() => { + switch (event.type) { + case "opened": + case "navigated": + case "resized": + // Keep a tab's place so the picker order is stable. + return existing + ? current.sessions.map((session) => + session.tabId === event.tabId ? event.snapshot : session, + ) + : [...others, event.snapshot]; + case "failed": + return existing + ? current.sessions.map((session) => + session.tabId === event.tabId + ? { + ...session, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + } + : session, + ) + : current.sessions; + case "closed": + return others; + } + })(); + return { ...current, serverEpoch: event.serverEpoch, revision: event.revision, sessions }; +} + +/** + * One thread's preview tabs: the `preview.list` result kept current by + * environment preview events, the same pairing web's preview session sync uses. + */ +const threadPreviewTabsAtom = Atom.family((threadKey: string) => { + const ref = parseScopedThreadKey(threadKey); + if (ref === null) return emptyTabsAtom; + const listAtom = previewEnvironment.list({ + environmentId: ref.environmentId, + input: { threadId: ref.threadId }, + }); + const eventsAtom = previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); + return Atom.make((get) => { + let disposed = false; + let state = EMPTY_TABS; + const publish = (next: ThreadPreviewTabs) => { + if (next === state) return; + state = next; + get.setSelf(next); + }; + get.addFinalizer(() => { + disposed = true; + }); + get.subscribe(listAtom, (result) => { + if (AsyncResult.isSuccess(result)) publish(reconcileList(state, result.value)); + }); + get.subscribe(eventsAtom, (result) => { + if (!AsyncResult.isSuccess(result) || result.value.threadId !== ref.threadId) return; + // A restarted server resets revisions; only a fresh list is authoritative. + if (state.serverEpoch !== null && result.value.serverEpoch !== state.serverEpoch) { + get.refresh(listAtom); + return; + } + publish(applyEvent(state, result.value)); + }); + const cached = get.once(listAtom); + if (AsyncResult.isSuccess(cached)) state = reconcileList(state, cached.value); + // The cached list can predate an agent-opened tab. + queueMicrotask(() => { + if (!disposed) get.refresh(listAtom); + }); + return state; + }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`mobile-preview-tabs:${threadKey}`)); +}); + +/** The thread's server-hosted browser tabs. `loaded` turns true once the tab list has arrived. */ +export function useThreadServerBrowserTabs(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly enabled: boolean; +}) { + const tabs = useAtomValue( + input.enabled + ? threadPreviewTabsAtom( + scopedThreadKey({ environmentId: input.environmentId, threadId: input.threadId }), + ) + : emptyTabsAtom, + ); + const sessions = useMemo( + () => tabs.sessions.filter((session) => session.runtime === "server"), + [tabs.sessions], + ); + return { tabs: sessions, loaded: tabs.listed }; +} + +const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + return prepared === null + ? Effect.never + : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + }) + .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), +); + +/** Fetches a fresh stream ticket; a refused socket calls this before reconnecting. */ +export function refreshPreviewStreamAccess(environmentId: EnvironmentId) { + appAtomRegistry.refresh(previewStreamAccessAtom(environmentId)); +} + +/** Stream credentials for server tabs, with the same ticket flow as the device hub. */ +export function usePreviewStreamAccess(environmentId: EnvironmentId) { + const prepared = usePreparedConnection(environmentId); + const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); + const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null; + return { access, error: query.error, refresh: query.refresh }; +} diff --git a/apps/mobile/src/types/mobile-preview-stream.d.ts b/apps/mobile/src/types/mobile-preview-stream.d.ts new file mode 100644 index 000000000000..34a76a995512 --- /dev/null +++ b/apps/mobile/src/types/mobile-preview-stream.d.ts @@ -0,0 +1,4 @@ +declare module "@t3tools/mobile-preview-stream" { + const script: string; + export default script; +} diff --git a/apps/server/package.json b/apps/server/package.json index f62e0d487315..1c86a4b402da 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", + "playwright-core": "1.60.0", "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", "stream-json": "3.6.0", diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 29febe036ae6..b684a2109054 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -157,6 +157,35 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso ); } +/** + * `` and WebSocket cannot set headers, so media routes (device hub, + * preview stream) authenticate the way the `/ws` upgrade does: a cookie for browser + * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for + * bearer and DPoP clients. The upgrade authenticator already implements that + * fallback order, so it is used for plain requests as well. + */ +export const authenticateMediaRequest = (requiredScope: AuthEnvironmentScope) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (EnvironmentAuth.isServerAuthCredentialError(error)) { + return yield* failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ); + } + return yield* failEnvironmentInternal("internal_error", error); + }), + ), + ); + if (!session.scopes.includes(requiredScope)) { + return yield* failEnvironmentScopeRequired(requiredScope); + } + }); + export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { return Effect.gen(function* () { const traceId = yield* currentEnvironmentTraceId; diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 5975864d925e..b7821e063ddd 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -11,11 +11,7 @@ * Only the routes the Device panel needs are forwarded. Anything under the * hub's dashboard, exec, or WebRTC surface is rejected here. */ -import { - AuthOrchestrationReadScope, - AuthOrchestrationOperateScope, - type AuthEnvironmentScope, -} from "@t3tools/contracts"; +import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { @@ -28,12 +24,7 @@ import { import * as Socket from "effect/socket/Socket"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import { - failEnvironmentAuthInvalid, - failEnvironmentInternal, - failEnvironmentScopeRequired, -} from "../auth/http.ts"; +import { authenticateMediaRequest } from "../auth/http.ts"; import * as DeviceService from "./DeviceService.ts"; const ALLOWED_PATHS: ReadonlyArray = [ @@ -80,35 +71,6 @@ const DROPPED_REQUEST_HEADERS = new Set([ const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => request.headers.upgrade?.toLowerCase() === "websocket"; -/** - * `` and WebSocket cannot set headers, so every proxied request - * authenticates the way the `/ws` upgrade does: a cookie for browser - * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for - * bearer and DPoP clients. The upgrade authenticator already implements that - * fallback order, so it is used for plain requests as well. - */ -const authenticate = (requiredScope: AuthEnvironmentScope) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catch((error) => - Effect.gen(function* () { - if (EnvironmentAuth.isServerAuthCredentialError(error)) { - return yield* failEnvironmentAuthInvalid( - EnvironmentAuth.serverAuthCredentialReason(error), - EnvironmentAuth.serverAuthDpopFailureReason(error), - ); - } - return yield* failEnvironmentInternal("internal_error", error); - }), - ), - ); - if (!session.scopes.includes(requiredScope)) { - return yield* failEnvironmentScopeRequired(requiredScope); - } - }); - const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => { const headers: Record = {}; for (const [name, value] of Object.entries(request.headers)) { @@ -206,7 +168,9 @@ const handler = Effect.gen(function* () { const controlsDevice = (upgrade && hubPath !== "/api/devices/ws") || (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath)); - yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); + yield* authenticateMediaRequest( + controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope, + ); const devices = yield* DeviceService.DeviceService; const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); if (!ready) { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2ccc07b87b79..85d7c09c202d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -20,6 +20,7 @@ import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; +import { isServerBrowserEnabled } from "../preview/serverBrowserEnabled.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; @@ -258,6 +259,7 @@ export const make = Effect.gen(function* () { ? { serverSelfUpdateProgress: true } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(isServerBrowserEnabled(serverConfig.mode) ? { serverBrowser: true } : {}), }, }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 2e0534fec25a..bc1ee0722ebc 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -52,11 +52,21 @@ export interface PreviewAutomationInvokeInput { readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; } +export interface PreviewAutomationConnectOptions { + /** + * New agent work goes to a preferred host before any desktop. The server's + * own headless browser registers this way so a standalone environment keeps + * browsing when every desktop disconnects. + */ + readonly preferred?: boolean; +} + export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, { readonly connect: ( host: PreviewAutomationHost, + options?: PreviewAutomationConnectOptions, ) => Effect.Effect>; readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect; readonly respond: ( @@ -74,6 +84,7 @@ interface ClientConnection { readonly environmentId: PreviewAutomationHost["environmentId"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; + readonly preferred: boolean; readonly liveTabs: NonNullable; readonly focusOrder: number; readonly queue: Queue.Queue; @@ -367,6 +378,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( host: PreviewAutomationHost, + options: PreviewAutomationConnectOptions | undefined, ) { const clientId = host.clientId; const queue = yield* Queue.unbounded(); @@ -378,6 +390,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { environmentId: host.environmentId, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, + preferred: options?.preferred ?? false, liveTabs: [], focusOrder: 0, queue, @@ -408,10 +421,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( "PreviewAutomationBroker.connect", - )((host) => + )((host, options) => Effect.succeed( Stream.unwrap( - Effect.acquireRelease(acquireConnection(host), (connection) => + Effect.acquireRelease(acquireConnection(host, options), (connection) => disconnect(connection.clientId, connection.queue), ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), ), @@ -518,6 +531,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { (left, right) => Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || + Number(right.preferred) - Number(left.preferred) || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, )[0]; diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 3a1f870e3904..e0a6aae47a10 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -21,6 +21,7 @@ import { type PreviewRefreshInput, type PreviewReportStatusInput, type PreviewResizeInput, + type PreviewRuntime, FILL_PREVIEW_VIEWPORT, PreviewSessionLookupError, type PreviewSessionSnapshot, @@ -124,6 +125,7 @@ const buildLoadingSnapshot = (input: { readonly title: string; readonly viewport: PreviewViewportSetting; readonly profileId?: string | undefined; + readonly runtime?: PreviewRuntime | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -133,6 +135,7 @@ const buildLoadingSnapshot = (input: { canGoForward: false, viewport: input.viewport, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(input.runtime === undefined ? {} : { runtime: input.runtime }), updatedAt: input.updatedAt, }); @@ -141,6 +144,7 @@ const buildIdleSnapshot = (input: { readonly tabId: string; readonly viewport: PreviewViewportSetting; readonly profileId?: string | undefined; + readonly runtime?: PreviewRuntime | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -150,6 +154,7 @@ const buildIdleSnapshot = (input: { canGoForward: false, viewport: input.viewport, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(input.runtime === undefined ? {} : { runtime: input.runtime }), updatedAt: input.updatedAt, }); @@ -236,6 +241,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { title: "", viewport, profileId: input.profileId, + runtime: input.runtime, updatedAt, }) : buildIdleSnapshot({ @@ -243,6 +249,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId, viewport, profileId: input.profileId, + runtime: input.runtime, updatedAt, }); yield* SynchronizedRef.modifyEffect(stateRef, (state) => @@ -291,6 +298,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { ...(session.snapshot.profileId === undefined ? {} : { profileId: session.snapshot.profileId }), + ...(session.snapshot.runtime === undefined + ? {} + : { runtime: session.snapshot.runtime }), updatedAt, }; return { @@ -327,6 +337,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ...(session.snapshot.profileId === undefined ? {} : { profileId: session.snapshot.profileId }), + ...(session.snapshot.runtime === undefined ? {} : { runtime: session.snapshot.runtime }), updatedAt, }; const emit: PreviewEventDraft = diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts new file mode 100644 index 000000000000..fa1a25a35582 --- /dev/null +++ b/apps/server/src/preview/ServerBrowser.ts @@ -0,0 +1,1224 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. +/** + * Headless Chromium preview tabs owned by the environment server. + * + * Tabs opened with `runtime: "server"` live here instead of in a desktop + * ``, so agents keep browsing with no client attached and any client + * (web, phone, a desktop on another machine) watches through + * `/api/preview-stream`. PreviewManager stays the tab list: this service + * mirrors it (`opened` creates a page, `resized` applies the viewport, `closed` + * drops it) and reports page state back through `reportStatus`, the same path + * desktops use. Agents reach these tabs through PreviewAutomationBroker, where + * this service registers as a preferred in-process automation host. + * + * Rendering is tuned for servers without a GPU. `--disable-gpu` selects + * software compositing, which measured about six times cheaper than + * SwiftShader GL compositing for a busy page; WebGL still works through + * SwiftShader. Screencast frames ignore emulated device scale, so the process + * renders at a real 2x for crisp screenshots, recordings, and retina viewers; + * small viewers get downscaled frames. + */ +import { + FILL_PREVIEW_VIEWPORT, + PREVIEW_AUTOMATION_OPERATIONS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type PreviewAutomationActionEvent, + type PreviewAutomationClickInput, + type PreviewAutomationConsoleEntry, + type PreviewAutomationEvaluateInput, + type PreviewAutomationNavigateInput, + type PreviewAutomationNetworkEntry, + type PreviewAutomationOpenInput, + type PreviewAutomationPressInput, + type PreviewAutomationRequest, + type PreviewAutomationResizeInput, + type PreviewAutomationScrollInput, + type PreviewAutomationSetColorSchemeInput, + type PreviewAutomationStatus, + type PreviewAutomationTypeInput, + type PreviewAutomationWaitForInput, + type PreviewEvent, + type PreviewNavStatus, + type PreviewSessionSnapshot, + type PreviewViewportSetting, + ThreadId, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type { BrowserContext, CDPSession, Page } from "playwright-core"; + +import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; +import * as PreviewManager from "./Manager.ts"; +import * as ServerBrowserPage from "./ServerBrowserPage.ts"; +import * as ServerBrowserToolchain from "./ServerBrowserToolchain.ts"; +import { isServerBrowserEnabled } from "./serverBrowserEnabled.ts"; + +const SERVER_HOST_CLIENT_ID = "server-browser"; +const RENDER_SCALE = 2; +/** + * Chromium keeps three screencast frames in flight and drops frames past + * that, so holding each ack this long caps a viewer near 30 fps. + */ +const SCREENCAST_ACK_PACE_MS = 100; +/** Quiet time after which a viewer that may have missed frames gets a still. */ +const SCREENCAST_SETTLE_MS = 200; +/** + * This many frames inside the window, while the viewer is scrolling, is + * motion. Motion streams at half size and lower quality, and the sharp frame + * follows once the page settles. Clicks and the page's own animations stay + * sharp. + */ +const SCREENCAST_MOTION_FRAMES = 4; +const SCREENCAST_MOTION_WINDOW_MS = 300; +const SCREENCAST_MOTION_QUALITY = 50; +const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; + +/** Resolves at `deadline`, in epoch ms. */ +const sleepUntil = (deadline: number) => + new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); + +/** + * Whether a tap at a page point lands on something that takes text, so a touch + * viewer raises its soft keyboard only then. Frames count as editable because + * their contents are out of reach. + */ +const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { + let element = document.elementFromPoint(x, y); + while (element && element.shadowRoot) { + const inner = element.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === element) break; + element = inner; + } + if (element && element.tagName === "LABEL" && element.control) element = element.control; + if (!element) return false; + if (element.tagName === "IFRAME" || element.tagName === "FRAME") return true; + if (element.isContentEditable) return true; + if (element.tagName === "TEXTAREA") return !element.disabled && !element.readOnly; + if (element.tagName !== "INPUT") return false; + const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; + return !nonText.includes(element.type) && !element.disabled && !element.readOnly; +}`; +/** Fill-mode size before any viewer reports its panel. */ +const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; +const NAVIGATION_TIMEOUT_MS = 15_000; +const ACTION_TIMELINE_LIMIT = 50; + +export class ServerBrowserTabNotFoundError extends Schema.TaggedError()( + "ServerBrowserTabNotFoundError", + { threadId: Schema.String, tabId: Schema.String }, +) { + override get message(): string { + return "The server preview tab does not exist."; + } +} + +export class ServerBrowserLaunchError extends Schema.TaggedError()( + "ServerBrowserLaunchError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "The server preview browser could not start."; + } +} + +export type ServerBrowserViewerOutput = + | { + readonly _tag: "frame"; + readonly data: Uint8Array; + /** Releases the next frame; call after the bytes reach the socket. */ + readonly ack: Effect.Effect; + } + | { readonly _tag: "viewport"; readonly width: number; readonly height: number } + | { + readonly _tag: "probe"; + readonly x: number; + readonly y: number; + readonly editable: boolean; + } + /** The tab closed; the socket ends. */ + | { readonly _tag: "gone" }; + +export interface ServerBrowserViewer { + readonly output: Queue.Dequeue; + readonly input: (message: unknown) => Effect.Effect; +} + +export class ServerBrowser extends Context.Service< + ServerBrowser, + { + readonly enabled: boolean; + /** Streams one tab to a viewer until the scope closes. */ + readonly attachViewer: (input: { + readonly threadId: string; + readonly tabId: string; + readonly maxWidth: number; + readonly maxHeight: number; + readonly quality: number; + }) => Effect.Effect< + ServerBrowserViewer, + ServerBrowserTabNotFoundError | ServerBrowserLaunchError, + Scope.Scope + >; + } +>()("t3/preview/ServerBrowser") {} + +interface ViewerState { + readonly push: (output: ServerBrowserViewerOutput) => void; + /** Stops and restarts this viewer's screencast around a scaled capture. */ + readonly pause: () => Promise; + readonly resume: () => Promise; + /** Last wheel input from this viewer, for motion mode. */ + scrolledAt: number; +} + +interface Recording { + readonly encoder: Page; + readonly session: CDPSession; + readonly startedAt: string; +} + +interface ServerTab { + readonly threadId: ThreadId; + readonly tabId: string; + readonly page: Page; + readonly cdp: CDPSession; + readonly createdAt: number; + readonly viewers: Set; + readonly consoleEntries: Array; + readonly networkEntries: Array; + readonly actionTimeline: Array; + setting: PreviewViewportSetting; + loading: boolean; + closing: boolean; + recording: Recording | null; +} + +const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; + +const pushBounded = ( + buffer: Array, + entry: A, + limit = ServerBrowserPage.DIAGNOSTIC_BUFFER_LIMIT, +) => { + buffer.push(entry); + if (buffer.length > limit) buffer.splice(0, buffer.length - limit); +}; + +const fixedViewportSize = (setting: PreviewViewportSetting | undefined) => + setting === undefined || setting._tag === "fill" + ? null + : { width: setting.width, height: setting.height }; + +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null ? (value as Record) : null; + +const num = (value: unknown, fallback = 0) => + typeof value === "number" && Number.isFinite(value) ? value : fallback; + +const modifiersOf = (message: Record) => { + const value = num(message.modifiers); + return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0; +}; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const enabled = isServerBrowserEnabled(config.mode); + const manager = yield* PreviewManager.PreviewManager; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const environment = yield* ServerEnvironment.ServerEnvironment; + const toolchain = yield* ServerBrowserToolchain.ServerBrowserToolchain; + const runFork = Effect.runForkWith(yield* Effect.context()); + + const tabs = new Map(); + const pendingTabs = new Map>(); + const contexts = new Map>(); + let hostConnectionId: string | null = null; + + const profilesDir = NodePath.join(config.stateDir, "server-browser", "profiles"); + + const launchContext = async (profileId: string): Promise => { + const resolved = await Effect.runPromise(toolchain.resolve); + const userDataDir = NodePath.join(profilesDir, encodeURIComponent(profileId)); + await NodeFSP.mkdir(userDataDir, { recursive: true }); + // Loaded on first use so servers that never open a server tab skip it. + const { chromium } = await import("playwright-core"); + const launch = (chromiumSandbox: boolean) => + chromium.launchPersistentContext(userDataDir, { + executablePath: resolved.executablePath, + env: { ...process.env, ...resolved.env }, + args: [...resolved.args, "--disable-gpu", `--force-device-scale-factor=${RENDER_SCALE}`], + headless: true, + chromiumSandbox, + viewport: UNATTACHED_FILL_VIEWPORT, + deviceScaleFactor: RENDER_SCALE, + }); + // The namespace sandbox needs unprivileged user namespaces, which some + // containers disable. Running unsandboxed there matches what the agent + // already has: a shell as the same user. + const context = await launch(true).catch(() => launch(false)); + for (const page of context.pages()) await page.close().catch(() => undefined); + context.on("close", () => { + contexts.delete(profileId); + for (const tab of tabs.values()) { + if (tab.page.context() === context) dropTab(tab, true); + } + }); + return context; + }; + + const contextFor = (profileId: string) => { + let context = contexts.get(profileId); + if (!context) { + context = launchContext(profileId); + contexts.set(profileId, context); + context.catch(() => contexts.delete(profileId)); + } + return context; + }; + + const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { + void tab.cdp + .send("Page.getNavigationHistory") + .catch(() => null) + .then((history) => { + const index = history?.currentIndex ?? 0; + const count = history?.entries.length ?? 0; + runFork( + manager + .reportStatus({ + threadId: tab.threadId, + tabId: tab.tabId, + navStatus, + canGoBack: index > 0, + canGoForward: index < count - 1, + }) + .pipe(Effect.ignore), + ); + }); + }; + + const reportLoaded = async (tab: ServerTab) => { + const url = tab.page.url(); + if (url === "about:blank") return; + const title = (await tab.page.title().catch(() => "")).slice(0, 512); + report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); + }; + + const reportLiveTabs = () => { + const connectionId = hostConnectionId; + if (connectionId === null) return; + runFork( + environment.getEnvironmentId.pipe( + Effect.flatMap((environmentId) => + broker.focusHost({ + clientId: SERVER_HOST_CLIENT_ID, + environmentId, + connectionId, + focused: true, + liveTabs: [...tabs.values()].map((tab) => ({ + threadId: tab.threadId, + tabId: tab.tabId, + visible: tab.viewers.size > 0, + })), + }), + ), + ), + ); + }; + + const broadcastViewport = (tab: ServerTab) => { + const size = tab.page.viewportSize(); + if (!size) return; + for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); + }; + + const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => { + tab.setting = setting; + const size = fixedViewportSize(setting); + if (size) await tab.page.setViewportSize(size); + broadcastViewport(tab); + }; + + const dropTab = (tab: ServerTab, closeSession: boolean) => { + const key = tabKey(tab.threadId, tab.tabId); + if (tabs.get(key) !== tab) return; + tabs.delete(key); + tab.closing = true; + for (const viewer of tab.viewers) viewer.push({ _tag: "gone" }); + void tab.page.close().catch(() => undefined); + void tab.recording?.encoder.close().catch(() => undefined); + reportLiveTabs(); + if (closeSession) { + runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); + } + }; + + const createTab = async (snapshot: PreviewSessionSnapshot): Promise => { + const context = await contextFor(snapshot.profileId ?? "default"); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + const tab: ServerTab = { + threadId: ThreadId.make(snapshot.threadId), + tabId: snapshot.tabId, + page, + cdp, + createdAt: Date.now(), + viewers: new Set(), + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + loading: false, + closing: false, + recording: null, + }; + await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); + const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => + request.isNavigationRequest() && request.frame() === page.mainFrame(); + page.on("request", (request) => { + if (!isMainNavigation(request)) return; + tab.loading = true; + report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); + }); + page.on("load", () => { + tab.loading = false; + void reportLoaded(tab); + }); + page.on("framenavigated", (frame) => { + // Same-document navigations (SPA routes) fire no load event. + if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); + }); + page.on("requestfailed", (request) => { + const errorText = request.failure()?.errorText ?? ""; + pushBounded(tab.networkEntries, { + url: request.url(), + method: request.method(), + status: null, + failed: true, + errorText, + timestamp: new Date().toISOString(), + }); + if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return; + tab.loading = false; + const { code, description } = ServerBrowserPage.parseNetError(errorText); + report(tab, { + _tag: "LoadFailed", + url: request.url().slice(0, 2048), + title: "", + code, + description, + }); + }); + page.on("response", (response) => { + pushBounded(tab.networkEntries, { + url: response.url(), + method: response.request().method(), + status: response.status(), + failed: false, + timestamp: new Date().toISOString(), + }); + }); + page.on("console", (message) => { + pushBounded(tab.consoleEntries, { + level: message.type(), + text: message.text().slice(0, 2_000), + timestamp: new Date().toISOString(), + }); + }); + // A blocking dialog would freeze the page for every viewer and agent. + page.on("dialog", (dialog) => void dialog.dismiss().catch(() => undefined)); + // One page per tab: a popup becomes a navigation of its opener. + page.on("popup", (popup) => { + void popup + .waitForURL((url) => url.href !== "about:blank", { + timeout: NAVIGATION_TIMEOUT_MS, + waitUntil: "commit", + }) + .catch(() => undefined) + .then(async () => { + const url = popup.url(); + await popup.close().catch(() => undefined); + if (url !== "about:blank") await page.goto(url).catch(() => undefined); + }); + }); + page.on("close", () => { + if (!tab.closing) dropTab(tab, true); + }); + tabs.set(tabKey(tab.threadId, tab.tabId), tab); + reportLiveTabs(); + if (snapshot.navStatus._tag === "Loading") { + void page.goto(snapshot.navStatus.url).catch(() => undefined); + } + return tab; + }; + + const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { + const key = tabKey(snapshot.threadId, snapshot.tabId); + const existing = tabs.get(key); + if (existing) return Promise.resolve(existing); + let pending = pendingTabs.get(key); + if (!pending) { + pending = createTab(snapshot).finally(() => pendingTabs.delete(key)); + pendingTabs.set(key, pending); + } + return pending; + }; + + /** Server tabs that exist in PreviewManager but not yet here (e.g. after a crash). */ + const findTab = (threadId: string, tabId: string) => + Effect.gen(function* () { + const existing = tabs.get(tabKey(threadId, tabId)); + if (existing) return existing; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === tabId && session.runtime === "server", + ); + if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId }); + return yield* Effect.tryPromise({ + try: () => ensureTab(snapshot), + catch: (cause) => new ServerBrowserLaunchError({ cause }), + }); + }); + + const latestThreadTab = (threadId: string) => + [...tabs.values()] + .filter((tab) => tab.threadId === threadId) + .sort((left, right) => right.createdAt - left.createdAt)[0]; + + const statusOf = (tab: ServerTab | undefined): PreviewAutomationStatus => { + if (!tab) { + return { + available: false, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + }; + } + const url = tab.page.url(); + const viewport = tab.page.viewportSize(); + return { + available: true, + visible: tab.viewers.size > 0, + tabId: tab.tabId, + url: url === "about:blank" ? null : url, + title: null, + loading: tab.loading, + viewportSetting: tab.setting, + ...(viewport ? { viewport } : {}), + }; + }; + + const statusWithTitle = async (tab: ServerTab | undefined) => { + const status = statusOf(tab); + if (!tab || status.url === null) return status; + return { ...status, title: (await tab.page.title().catch(() => "")) || null }; + }; + + const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { + if (input.url !== undefined) return normalizePreviewUrl(input.url); + const target = input.target!; + if (target.kind === "url") return normalizePreviewUrl(target.url); + // The browser runs inside the environment, so its ports are loopback. + const path = target.path ?? ""; + return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`; + }; + + const navigate = async ( + tab: ServerTab, + url: string, + readiness: "load" | "domContentLoaded" | "none", + timeout: number, + ) => { + const navigation = tab.page.goto(url, { + timeout, + waitUntil: + readiness === "domContentLoaded" + ? "domcontentloaded" + : readiness === "none" + ? "commit" + : "load", + }); + if (readiness === "none") { + void navigation.catch(() => undefined); + return; + } + await navigation.catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + if (/ERR_[A-Z_]+/.test(message)) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`, + ); + } + throw cause; + }); + }; + + const moveAgentCursor = ( + tab: ServerTab, + point: { readonly x: number; readonly y: number }, + click: boolean, + ) => { + void tab.recording?.encoder + .evaluate( + ([x, y, isClick]) => { + const recorder = ( + globalThis as unknown as { + __t3Recorder?: { cursor(x: number, y: number, click: boolean): void }; + } + ).__t3Recorder; + recorder?.cursor(x, y, isClick); + }, + [point.x, point.y, click] as const, + ) + .catch(() => undefined); + }; + + const startRecording = async (tab: ServerTab) => { + if (tab.recording) return tab.recording; + const encoder = await tab.page.context().newPage(); + await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); + const session = await tab.page.context().newCDPSession(tab.page); + session.on("Page.screencastFrame", (frame) => { + const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; + void encoder + .evaluate( + ([data, width]) => { + const recorder = ( + globalThis as unknown as { + __t3Recorder: { frame(data: string, width: number): Promise }; + } + ).__t3Recorder; + return recorder.frame(data, width); + }, + [frame.data, cssWidth] as const, + ) + .catch(() => undefined) + .finally(() => { + void session + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(() => undefined); + }); + }); + await session.send("Page.startScreencast", RECORDING_SCREENCAST); + const recording: Recording = { encoder, session, startedAt: new Date().toISOString() }; + tab.recording = recording; + return recording; + }; + + /** + * Runs a scaled capture with the tab's screencasts stopped, so viewers and + * recordings never receive the frame it renders at the capture scale. + */ + const withScreencastsPaused = async (tab: ServerTab, capture: () => Promise) => { + const viewers = [...tab.viewers]; + const recording = tab.recording; + await Promise.all([ + ...viewers.map((viewer) => viewer.pause()), + recording?.session.send("Page.stopScreencast").catch(() => undefined), + ]); + try { + return await capture(); + } finally { + await Promise.all([ + ...viewers.map((viewer) => viewer.resume()), + recording && tab.recording === recording + ? recording.session + .send("Page.startScreencast", RECORDING_SCREENCAST) + .catch(() => undefined) + : undefined, + ]); + } + }; + + const stopRecording = async (tab: ServerTab) => { + const recording = tab.recording; + if (!recording) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this tab.", + ); + } + tab.recording = null; + await recording.session.send("Page.stopScreencast").catch(() => undefined); + await recording.session.detach().catch(() => undefined); + type EncoderWindow = { + __t3Recorder: { + stop(): Promise<{ mimeType: string | null; count: number }>; + chunk(index: number): Promise; + }; + }; + const { mimeType, count } = await recording.encoder.evaluate(() => + (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), + ); + const chunks: Array = []; + for (let index = 0; index < count; index += 1) { + const chunk = await recording.encoder.evaluate( + (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), + index, + ); + chunks.push(Buffer.from(chunk, "base64")); + } + await recording.encoder.close().catch(() => undefined); + const data = Buffer.concat(chunks); + if (!mimeType || data.byteLength === 0) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "The recording captured no frames.", + ); + } + if (data.byteLength > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingTooLargeError", + "The recording is larger than the attachment limit.", + ); + } + // Written where a desktop upload would land, so the MCP handler claims + // both the same way. + const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; + const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); + await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); + await NodeFSP.writeFile(path, data); + return { + id: pendingId, + tabId: tab.tabId, + path, + mimeType: mimeType.split(";")[0]!, + sizeBytes: data.byteLength, + createdAt: new Date().toISOString(), + uploadedAttachmentId: pendingId, + }; + }; + + const recordAction = (tab: ServerTab, action: string, run: () => Promise): Promise => { + const event: { + -readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K]; + } = { + id: NodeCrypto.randomUUID(), + action, + status: "running", + startedAt: new Date().toISOString(), + }; + pushBounded(tab.actionTimeline, event, ACTION_TIMELINE_LIMIT); + return run().then( + (result) => { + event.status = "succeeded"; + event.completedAt = new Date().toISOString(); + return result; + }, + (cause: unknown) => { + event.status = "failed"; + event.completedAt = new Date().toISOString(); + event.error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause); + throw cause; + }, + ); + }; + + const requireTab = async (request: PreviewAutomationRequest) => { + const tab = + request.tabId === undefined + ? latestThreadTab(request.threadId) + : (tabs.get(tabKey(request.threadId, request.tabId)) ?? + (await Effect.runPromise( + findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(() => undefined)), + ))); + if (!tab) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationTabNotFoundError", + "No server preview tab is open for this thread. Call preview_open first.", + ); + } + return tab; + }; + + const runOperation = async (request: PreviewAutomationRequest): Promise => { + const input = request.input; + switch (request.operation) { + case "status": + return statusWithTitle( + request.tabId === undefined + ? latestThreadTab(request.threadId) + : tabs.get(tabKey(request.threadId, request.tabId)), + ); + case "open": { + const open = input as PreviewAutomationOpenInput; + const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url); + const reuse = open.reuseExistingTab ?? true; + const existing = + reuse && request.tabId !== undefined + ? tabs.get(tabKey(request.threadId, request.tabId)) + : undefined; + if (existing) { + if (url) await navigate(existing, url, "load", request.timeoutMs); + return statusWithTitle(existing); + } + const snapshot = await Effect.runPromise( + manager.open({ + threadId: request.threadId, + ...(url ? { url } : {}), + runtime: "server", + }), + ); + const tab = await ensureTab(snapshot); + if (url) { + await tab.page + .waitForLoadState("load", { timeout: request.timeoutMs }) + .catch(() => undefined); + } + return statusWithTitle(tab); + } + case "navigate": { + const tab = await requireTab(request); + const navigateInput = input as PreviewAutomationNavigateInput; + await recordAction(tab, "navigate", () => + navigate( + tab, + resolveNavigationUrl(navigateInput), + navigateInput.readiness ?? "load", + navigateInput.timeoutMs ?? request.timeoutMs, + ), + ); + return statusWithTitle(tab); + } + case "resize": { + const tab = await requireTab(request); + const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); + await Effect.runPromise( + manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), + ); + await applySetting(tab, setting); + return { + tabId: tab.tabId, + setting, + viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT, + }; + } + case "setColorScheme": { + const tab = await requireTab(request); + const { colorScheme } = input as PreviewAutomationSetColorSchemeInput; + await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme }); + return { tabId: tab.tabId, colorScheme }; + } + case "snapshot": { + const tab = await requireTab(request); + return withScreencastsPaused(tab, () => + ServerBrowserPage.snapshot({ + page: tab.page, + cdp: tab.cdp, + renderScale: RENDER_SCALE, + consoleEntries: tab.consoleEntries, + networkEntries: tab.networkEntries, + actionTimeline: tab.actionTimeline, + }), + ); + } + case "click": { + const tab = await requireTab(request); + const clickInput = input as PreviewAutomationClickInput; + const point = await recordAction(tab, "click", () => + ServerBrowserPage.click(tab.page, clickInput), + ); + moveAgentCursor(tab, point, true); + return undefined; + } + case "type": { + const tab = await requireTab(request); + await recordAction(tab, "type", () => + ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput), + ); + return undefined; + } + case "press": { + const tab = await requireTab(request); + await recordAction(tab, "press", () => + ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput), + ); + return undefined; + } + case "scroll": { + const tab = await requireTab(request); + await recordAction(tab, "scroll", () => + ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), + ); + return undefined; + } + case "evaluate": { + const tab = await requireTab(request); + return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput); + } + case "waitFor": { + const tab = await requireTab(request); + await ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); + return undefined; + } + case "recordingStart": { + const tab = await requireTab(request); + const recording = await startRecording(tab); + return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt }; + } + case "recordingStop": { + const tab = + request.tabId === undefined + ? [...tabs.values()].find( + (candidate) => candidate.threadId === request.threadId && candidate.recording, + ) + : await requireTab(request); + if (!tab) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this thread.", + ); + } + return stopRecording(tab); + } + } + }; + + const handleRequest = (connectionId: string, request: PreviewAutomationRequest) => + Effect.tryPromise({ + try: () => runOperation(request), + catch: ServerBrowserPage.toOperationError, + }).pipe( + Effect.match({ + onSuccess: (result) => ({ ok: true as const, result }), + onFailure: (error) => ({ + ok: false as const, + error: { + _tag: error.tag, + message: error.message, + ...(error.detail === undefined ? {} : { detail: error.detail }), + }, + }), + }), + Effect.flatMap((outcome) => + broker.respond({ + clientId: SERVER_HOST_CLIENT_ID, + connectionId, + requestId: request.requestId, + ...outcome, + }), + ), + Effect.ignore, + ); + + const mirrorManagerEvent = (event: PreviewEvent) => + Effect.promise(async () => { + if (event.type === "opened" && event.snapshot.runtime === "server") { + await ensureTab(event.snapshot).catch(() => undefined); + return; + } + const tab = tabs.get(tabKey(event.threadId, event.tabId)); + if (!tab) return; + if (event.type === "resized" && event.snapshot.viewport) { + await applySetting(tab, event.snapshot.viewport).catch(() => undefined); + } else if (event.type === "closed") { + dropTab(tab, false); + } + }); + + const dispatchViewerInput = async ( + tab: ServerTab, + session: CDPSession, + viewer: ViewerState, + raw: unknown, + ) => { + const message = asRecord(raw); + if (!message) return; + const modifiers = modifiersOf(message); + switch (message.type) { + case "mouse": { + const action = message.action; + const type = + action === "down" ? "mousePressed" : action === "up" ? "mouseReleased" : "mouseMoved"; + const button = ["none", "left", "middle", "right"].includes(String(message.button)) + ? (message.button as "none" | "left" | "middle" | "right") + : "none"; + await session.send("Input.dispatchMouseEvent", { + type, + x: num(message.x), + y: num(message.y), + button, + buttons: num(message.buttons), + clickCount: num(message.clickCount, type === "mouseMoved" ? 0 : 1), + modifiers, + }); + return; + } + case "wheel": + viewer.scrolledAt = Date.now(); + await session.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: num(message.x), + y: num(message.y), + deltaX: num(message.deltaX), + deltaY: num(message.deltaY), + modifiers, + }); + return; + case "key": { + const key = typeof message.key === "string" ? message.key : ""; + const code = typeof message.code === "string" ? message.code : ""; + const text = typeof message.text === "string" ? message.text : undefined; + if (message.action === "up") { + await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, modifiers }); + return; + } + await session.send("Input.dispatchKeyEvent", { + type: text ? "keyDown" : "rawKeyDown", + key, + code, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + windowsVirtualKeyCode: num( + message.keyCode, + key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, + ), + }); + return; + } + case "text": + if (typeof message.text === "string" && message.text.length > 0) { + await session.send("Input.insertText", { text: message.text.slice(0, 10_000) }); + } + return; + case "resize": { + const width = Math.round(num(message.width)); + const height = Math.round(num(message.height)); + if (tab.setting._tag !== "fill" || width < 100 || height < 100) return; + const current = tab.page.viewportSize(); + if (current?.width === width && current.height === height) return; + await tab.page.setViewportSize({ + width: Math.min(width, 3840), + height: Math.min(height, 2160), + }); + broadcastViewport(tab); + return; + } + case "navigate": + if (typeof message.url === "string") { + const url = normalizePreviewUrl(message.url); + void tab.page.goto(url).catch(() => undefined); + } + return; + case "history": + void (num(message.delta) < 0 ? tab.page.goBack() : tab.page.goForward()).catch( + () => undefined, + ); + return; + case "reload": + void tab.page.reload().catch(() => undefined); + return; + case "probe": { + const x = num(message.x); + const y = num(message.y); + const result = await session.send("Runtime.evaluate", { + expression: `(${EDITABLE_AT_POINT_SCRIPT})(${x}, ${y})`, + returnByValue: true, + }); + viewer.push({ _tag: "probe", x, y, editable: result.result.value === true }); + return; + } + } + }; + + const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) => + Effect.gen(function* () { + const tab = yield* findTab(input.threadId, input.tabId); + const output = yield* Queue.unbounded(); + const session = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => tab.page.context().newCDPSession(tab.page), + catch: (cause) => new ServerBrowserLaunchError({ cause }), + }), + (session) => + Effect.promise(() => + session + .send("Page.stopScreencast") + .catch(() => undefined) + .then(() => session.detach()) + .catch(() => undefined), + ), + ); + const viewer: ViewerState = { + push: (next) => void Queue.offerUnsafe(output, next), + pause: () => { + screencastParams = screencastParams.then(() => + session.send("Page.stopScreencast").then( + () => undefined, + () => undefined, + ), + ); + return screencastParams; + }, + resume: () => startScreencast(motion ? 0.5 : 1), + scrolledAt: 0, + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + tab.viewers.add(viewer); + reportLiveTabs(); + }), + () => + Effect.sync(() => { + tab.viewers.delete(viewer); + reportLiveTabs(); + }), + ); + const quality = Math.min(100, Math.max(1, Math.round(input.quality))); + // Full scale: a scaled capture would flash in every other viewer. + const pushStill = async () => { + const data = await ServerBrowserPage.captureViewport(tab.page, session, { + format: "jpeg", + quality, + scale: 1, + }).catch(() => null); + if (data) + viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); + }; + // Frames that arrive while two are in flight may have pushed later ones + // out, and a page that then goes still never repaints them. A still + // after the burst shows the final state. + let framesInFlight = 0; + let mayHaveDropped = false; + let motion = false; + const recentFrames: Array = []; + let screencastParams = Promise.resolve(); + const startScreencast = (scale: number) => { + screencastParams = screencastParams.then(async () => { + await session.send("Page.stopScreencast").catch(() => undefined); + await session + .send("Page.startScreencast", { + format: "jpeg", + quality: scale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, + maxWidth: Math.max(1, Math.round(input.maxWidth * scale)), + maxHeight: Math.max(1, Math.round(input.maxHeight * scale)), + }) + .catch(() => undefined); + }); + return screencastParams; + }; + let settleTimer: ReturnType | null = null; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (settleTimer !== null) clearTimeout(settleTimer); + }), + ); + let screencastStarted = false; + session.on("Page.screencastFrame", (frame) => { + screencastStarted = true; + if (framesInFlight > 0) mayHaveDropped = true; + framesInFlight += 1; + const arrivedAt = Date.now(); + recentFrames.push(arrivedAt); + while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift(); + if ( + !motion && + recentFrames.length >= SCREENCAST_MOTION_FRAMES && + arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS + ) { + motion = true; + void startScreencast(0.5); + } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) { + // The page keeps animating after the scroll; its own frames are sharp again. + motion = false; + recentFrames.length = 0; + void startScreencast(1); + } + if (settleTimer !== null) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = null; + if (!motion && !mayHaveDropped) return; + mayHaveDropped = false; + if (motion) { + motion = false; + recentFrames.length = 0; + void startScreencast(1); + } + void pushStill(); + }, SCREENCAST_SETTLE_MS); + viewer.push({ + _tag: "frame", + data: Buffer.from(frame.data, "base64"), + ack: Effect.promise(() => + sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => { + framesInFlight -= 1; + return session + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(() => undefined); + }), + ), + }); + }); + broadcastViewport(tab); + yield* Effect.promise(() => startScreencast(1)); + // An idle page does not repaint for a new screencast, so the viewer + // starts from a still unless a live frame beat it. + if (!screencastStarted) yield* Effect.promise(pushStill); + return { + output, + input: (message: unknown) => + Effect.promise(() => + dispatchViewerInput(tab, session, viewer, message).catch(() => undefined), + ), + } satisfies ServerBrowserViewer; + }); + + if (enabled) { + yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped); + const environmentId = yield* environment.getEnvironmentId; + yield* broker + .connect( + { + clientId: SERVER_HOST_CLIENT_ID, + environmentId, + supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], + }, + { preferred: true }, + ) + .pipe( + Effect.flatMap((events) => + events.pipe( + Stream.runForEach((event) => { + if (event.type === "connected") { + hostConnectionId = event.connectionId; + return Effect.sync(reportLiveTabs); + } + return handleRequest(event.connectionId, event.request).pipe( + Effect.forkScoped, + Effect.asVoid, + ); + }), + ), + ), + Effect.forkScoped, + ); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + for (const context of contexts.values()) { + await context.then((resolved) => resolved.close()).catch(() => undefined); + } + }), + ); + } + + return ServerBrowser.of({ enabled, attachViewer }); +}); + +export const layer = Layer.effect(ServerBrowser, make); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts new file mode 100644 index 000000000000..90ce25adda26 --- /dev/null +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -0,0 +1,422 @@ +// @effect-diagnostics globalDate:off - Playwright callbacks run outside the Effect runtime. +/** + * Page-level automation for server-hosted preview tabs. + * + * Plain async helpers over one Playwright page, called by `ServerBrowser`. + * Results mirror what desktop hosts return for the same operations, so the + * MCP preview tools cannot tell the two runtimes apart. Failures carry the + * broker's error tags (see `PreviewAutomationBroker.classifyResponseError`). + */ +import type { + PreviewAutomationClickInput, + PreviewAutomationConsoleEntry, + PreviewAutomationEvaluateInput, + PreviewAutomationNetworkEntry, + PreviewAutomationPressInput, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import type { CDPSession, Locator, Page } from "playwright-core"; + +const MAX_EVALUATION_BYTES = 64_000; +const MAX_VISIBLE_TEXT_LENGTH = 20_000; +const MAX_INTERACTIVE_ELEMENTS = 200; +const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; +const MAX_SCREENSHOT_WIDTH = 1280; +export const DIAGNOSTIC_BUFFER_LIMIT = 200; + +export class ServerBrowserOperationError extends Error { + readonly tag: string; + readonly detail: unknown; + + constructor(tag: string, message: string, detail?: unknown) { + super(message); + this.tag = tag; + this.detail = detail; + } +} + +/** Maps a Playwright failure onto the tag the broker classifies. */ +export const toOperationError = (cause: unknown): ServerBrowserOperationError => { + if (cause instanceof ServerBrowserOperationError) return cause; + const message = cause instanceof Error ? cause.message : String(cause); + const firstLine = message.split("\n")[0] ?? message; + if (cause instanceof Error && cause.name === "TimeoutError") { + return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine); + } + if (/while parsing selector|Unknown engine|Unexpected token/i.test(message)) { + return new ServerBrowserOperationError("PreviewAutomationInvalidSelectorError", firstLine); + } + if (/not an |not editable|not an editable/i.test(message)) { + return new ServerBrowserOperationError("PreviewAutomationTargetNotEditableError", firstLine); + } + return new ServerBrowserOperationError("PreviewAutomationExecutionError", firstLine); +}; + +const DEFAULT_TIMEOUT_MS = 15_000; + +const targetLocator = ( + page: Page, + input: { readonly locator?: string | undefined; readonly selector?: string | undefined }, +): Locator | null => { + const selector = input.locator ?? input.selector; + return selector === undefined ? null : page.locator(selector).first(); +}; + +const SNAPSHOT_SCRIPT = `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const parts = []; + for (let current = element; current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8; current = current.parentElement) { + const parent = current.parentElement; + const siblings = parent ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) : []; + const base = current.tagName.toLowerCase(); + parts.unshift(siblings.length > 1 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" : base); + } + return parts.join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll("a[href],button,input,textarea,select,[role],[tabindex]")) + .filter(visible) + .slice(0, ${MAX_INTERACTIVE_ELEMENTS}) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, ${MAX_INTERACTIVE_ELEMENT_NAME_LENGTH}), + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements, + }; +})()`; + +/** + * The visible viewport as an image, `scale` relative to the rendered pixels. + * + * A scaled capture briefly re-renders the page at that scale, and every live + * screencast on the page streams that frame; callers pause them around it. + * At full scale there is no clip and nothing re-renders. The clip is + * document-relative, so it starts at the scroll offset. + */ +export const captureViewport = async ( + page: Page, + cdp: CDPSession, + options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number }, +) => { + const quality = options.quality === undefined ? {} : { quality: options.quality }; + if (options.scale >= 1) { + const { data } = await cdp.send("Page.captureScreenshot", { + format: options.format, + ...quality, + }); + return data; + } + const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; + const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); + const { data } = await cdp.send("Page.captureScreenshot", { + format: options.format, + ...quality, + clip: { + x: cssVisualViewport.pageX, + y: cssVisualViewport.pageY, + width: viewport.width, + height: viewport.height, + scale: options.scale, + }, + }); + return data; +}; + +/** + * Screenshot of the current viewport at device resolution, scaled down to at + * most `MAX_SCREENSHOT_WIDTH` like desktop snapshots. `clip.scale` multiplies + * the render scale, so this needs no image library on the server. + */ + +export const captureViewportPng = async (page: Page, cdp: CDPSession, renderScale: number) => { + const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; + const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); + const data = await captureViewport(page, cdp, { format: "png", scale }); + return { + mimeType: "image/png" as const, + data, + width: Math.round(viewport.width * renderScale * scale), + height: Math.round(viewport.height * renderScale * scale), + }; +}; + +export const snapshot = async (input: { + readonly page: Page; + readonly cdp: CDPSession; + readonly renderScale: number; + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; +}): Promise => { + const [page, screenshot] = await Promise.all([ + input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< + Pick< + PreviewAutomationSnapshot, + "url" | "title" | "loading" | "visibleText" | "interactiveElements" + > + >, + captureViewportPng(input.page, input.cdp, input.renderScale), + ]); + return { + ...page, + // The MCP layer drops the AX tree before it reaches the agent. + accessibilityTree: null, + consoleEntries: [...input.consoleEntries], + networkEntries: [...input.networkEntries], + actionTimeline: [...input.actionTimeline], + screenshot, + }; +}; + +/** Resolves the viewport point an action targets, for the agent cursor. */ +export const click = async ( + page: Page, + input: PreviewAutomationClickInput, +): Promise<{ readonly x: number; readonly y: number }> => { + const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const locator = targetLocator(page, input); + if (locator === null) { + await page.mouse.click(input.x!, input.y!); + return { x: input.x!, y: input.y! }; + } + await locator.scrollIntoViewIfNeeded({ timeout }); + const box = await locator.boundingBox({ timeout }); + await locator.click({ timeout }); + return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : { x: 0, y: 0 }; +}; + +export const type = async (page: Page, input: PreviewAutomationTypeInput) => { + const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const locator = targetLocator(page, input); + if (locator !== null) { + if (input.clear) { + await locator.fill(input.text, { timeout }); + return; + } + await locator.focus({ timeout }); + } else if (input.clear) { + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Delete"); + } + await page.keyboard.insertText(input.text); +}; + +export const press = async (page: Page, input: PreviewAutomationPressInput) => { + await page.keyboard.press([...(input.modifiers ?? []), input.key].join("+")); +}; + +export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => { + const delta = [input.deltaX ?? 0, input.deltaY ?? 0] as const; + const locator = targetLocator(page, input); + if (locator === null) { + // Page-side code is passed as source: the server compiles without DOM types. + await page.evaluate(`scrollBy(${delta[0]}, ${delta[1]})`); + return; + } + await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); +}; + +export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => { + const result = await cdp.send("Runtime.evaluate", { + expression: input.expression, + awaitPromise: input.awaitPromise ?? true, + returnByValue: input.returnByValue ?? true, + }); + if (result.exceptionDetails) { + throw new ServerBrowserOperationError( + "PreviewAutomationExecutionError", + result.exceptionDetails.exception?.description ?? result.exceptionDetails.text, + ); + } + const value = + "value" in result.result ? result.result.value : (result.result.description ?? null); + const actualBytes = Buffer.byteLength(JSON.stringify(value ?? null), "utf8"); + if (actualBytes > MAX_EVALUATION_BYTES) { + throw new ServerBrowserOperationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result is ${actualBytes} bytes; the limit is ${MAX_EVALUATION_BYTES}.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ); + } + return value; +}; + +export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { + const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const remaining = () => Math.max(1, deadline - Date.now()); + const locator = targetLocator(page, input); + if (locator !== null) await locator.waitFor({ state: "visible", timeout: remaining() }); + if (input.text !== undefined) { + await page.waitForFunction( + `(document.body?.innerText ?? "").includes(${JSON.stringify(input.text)})`, + undefined, + { timeout: remaining() }, + ); + } + if (input.urlIncludes !== undefined) { + await page.waitForFunction( + `location.href.includes(${JSON.stringify(input.urlIncludes)})`, + undefined, + { + timeout: remaining(), + }, + ); + } +}; + +/** Chromium net error names reported for failed main-frame loads. */ +const NET_ERROR_CODES: Readonly> = { + ERR_FAILED: -2, + ERR_TIMED_OUT: -7, + ERR_CONNECTION_CLOSED: -100, + ERR_CONNECTION_RESET: -101, + ERR_CONNECTION_REFUSED: -102, + ERR_NAME_NOT_RESOLVED: -105, + ERR_INTERNET_DISCONNECTED: -106, + ERR_ADDRESS_UNREACHABLE: -109, + ERR_CERT_AUTHORITY_INVALID: -202, + ERR_EMPTY_RESPONSE: -324, +}; + +export const parseNetError = (errorText: string) => { + const description = /ERR_[A-Z_]+/.exec(errorText)?.[0] ?? errorText; + return { description, code: NET_ERROR_CODES[description] ?? -2 }; +}; + +/** + * In-page video encoder for recordings. Screencast frames are drawn onto a + * canvas and encoded by the browser's own MediaRecorder, so recording needs + * no ffmpeg on the server and costs about the same CPU as piping to x264. + * The agent cursor is drawn on the canvas, not injected into the page. The + * video keeps its first frame's size; a page resized mid-recording is + * letterboxed into it. + */ +export const RECORDING_ENCODER_SCRIPT = `(() => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + const chunks = []; + let recorder = null; + let frame = null; + let cursor = null; + let ring = null; + let ringTimer = null; + let scale = 1; + let offsetX = 0; + let offsetY = 0; + const paint = () => { + if (!frame) return; + const fit = Math.min(canvas.width / frame.width, canvas.height / frame.height); + const width = frame.width * fit; + const height = frame.height * fit; + offsetX = (canvas.width - width) / 2; + offsetY = (canvas.height - height) / 2; + if (width < canvas.width || height < canvas.height) { + context.fillStyle = "#000"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + context.drawImage(frame, offsetX, offsetY, width, height); + scale = (width / frame.width) * frame.cssScale; + if (ring) { + const age = (performance.now() - ring.at) / 400; + if (age < 1) { + context.beginPath(); + context.arc(offsetX + ring.x * scale, offsetY + ring.y * scale, (8 + 18 * age) * scale, 0, Math.PI * 2); + context.strokeStyle = "rgba(59,130,246," + (0.9 * (1 - age)) + ")"; + context.lineWidth = 3 * scale; + context.stroke(); + } + } + if (cursor) { + const x = offsetX + cursor.x * scale; + const y = offsetY + cursor.y * scale; + const s = scale; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x, y + 18 * s); + context.lineTo(x + 4.5 * s, y + 14 * s); + context.lineTo(x + 8 * s, y + 21 * s); + context.lineTo(x + 11 * s, y + 19.5 * s); + context.lineTo(x + 7.5 * s, y + 12.5 * s); + context.lineTo(x + 13 * s, y + 12.5 * s); + context.closePath(); + context.fillStyle = "#111"; + context.strokeStyle = "#fff"; + context.lineWidth = 1.5 * s; + context.fill(); + context.stroke(); + } + }; + window.__t3Recorder = { + async frame(base64, cssWidth) { + const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + 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 * 30 * 0.05)); + recorder = new MediaRecorder(canvas.captureStream(30), { mimeType, videoBitsPerSecond: bitsPerSecond }); + recorder.ondataavailable = (event) => { if (event.data.size > 0) 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(); + }, + cursor(x, y, click) { + cursor = { x, y }; + if (click) { + ring = { x, y, at: performance.now() }; + clearInterval(ringTimer); + ringTimer = setInterval(() => { + paint(); + if (performance.now() - ring.at > 400) { ring = null; clearInterval(ringTimer); paint(); } + }, 33); + } + paint(); + }, + stop() { + return new Promise((resolve) => { + if (!recorder) return resolve({ mimeType: null, count: 0 }); + recorder.onstop = () => resolve({ mimeType: recorder.mimeType, count: chunks.length }); + recorder.stop(); + }); + }, + async chunk(index) { + const bytes = new Uint8Array(await chunks[index].arrayBuffer()); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); + }, + }; +})()`; diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts new file mode 100644 index 000000000000..a1c45f5da89d --- /dev/null +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -0,0 +1,131 @@ +/** + * `/api/preview-stream/ws`: one server preview tab over a WebSocket. + * + * Frames go out as binary JPEG messages, viewport changes as JSON text, and + * viewer input comes back as JSON text. Each frame is acknowledged to Chromium + * only after the socket write drains, so a slow link (phone over T3 Connect) + * gets fewer frames instead of a growing buffer. Authentication matches the + * device hub proxy; the socket drives the page, so it needs operate scope. + */ +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { authenticateMediaRequest } from "../auth/http.ts"; +import * as ServerBrowser from "./ServerBrowser.ts"; + +export const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; +/** Matches `PREVIEW_STREAM_TAB_GONE_CODE` in the client. */ +const TAB_GONE_CODE = 4404; + +const DEFAULT_QUALITY = 70; +const textDecoder = new TextDecoder(); + +const intParam = (params: URLSearchParams, name: string, fallback: number, max: number) => { + const value = Number(params.get(name)); + return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), max) : fallback; +}; + +const parseMessage = (chunk: Uint8Array | string): unknown => { + try { + return JSON.parse(typeof chunk === "string" ? chunk : textDecoder.decode(chunk)); + } catch { + return null; + } +}; + +const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + if ( + url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` || + request.headers.upgrade?.toLowerCase() !== "websocket" + ) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + yield* authenticateMediaRequest(AuthOrchestrationOperateScope); + const params = url.value.searchParams; + const threadId = params.get("threadId") ?? ""; + const tabId = params.get("tabId") ?? ""; + if (!browser.enabled || threadId.length === 0 || tabId.length === 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return yield* Effect.scoped( + Effect.gen(function* () { + const attached = yield* browser + .attachViewer({ + threadId, + tabId, + maxWidth: intParam(params, "maxWidth", 1280, 7680), + maxHeight: intParam(params, "maxHeight", 800, 4320), + quality: intParam(params, "quality", DEFAULT_QUALITY, 100), + }) + .pipe( + Effect.map(Option.some), + Effect.catchTag("ServerBrowserTabNotFoundError", () => Effect.succeedNone), + ); + const socket = yield* request.upgrade; + const writer = yield* socket.writer; + // A refused upgrade reads as an auth failure to ticket clients, so a + // missing tab is a close code they stop on. + const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); + if (Option.isNone(attached)) { + yield* gone; + return HttpServerResponse.empty(); + } + const viewer = attached.value; + const reader = yield* socket.reader; + const sendOutput = Queue.take(viewer.output).pipe( + Effect.flatMap((output) => { + switch (output._tag) { + case "frame": + return writer.write(output.data).pipe(Effect.ensuring(output.ack)); + case "viewport": + return writer.write( + JSON.stringify({ type: "viewport", width: output.width, height: output.height }), + ); + case "probe": + return writer.write( + JSON.stringify({ + type: "probe", + x: output.x, + y: output.y, + editable: output.editable, + }), + ); + case "gone": + return gone.pipe(Effect.andThen(Effect.interrupt)); + } + }), + ); + const receiveInput = reader.pull.pipe( + Effect.flatMap((chunks) => + Effect.forEach(chunks, (chunk) => viewer.input(parseMessage(chunk)), { discard: true }), + ), + ); + // Whichever side ends first closes the other through scope teardown. + return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); + }), + ).pipe( + Effect.catchTag("ServerBrowserLaunchError", (error) => + Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe( + Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), + ), + ), + // A dropped socket is a normal end of viewing. + Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), + ); + }); + +// Route handlers only see request-scoped services, so the browser is captured +// when the route is registered. +export const routeLayer = HttpRouter.use((router) => + Effect.flatMap(ServerBrowser.ServerBrowser, (browser) => + router.add("GET", `${PREVIEW_STREAM_ROUTE_PREFIX}/*`, makeHandler(browser)), + ), +); diff --git a/apps/server/src/preview/ServerBrowserToolchain.ts b/apps/server/src/preview/ServerBrowserToolchain.ts new file mode 100644 index 000000000000..6dc645660ef7 --- /dev/null +++ b/apps/server/src/preview/ServerBrowserToolchain.ts @@ -0,0 +1,600 @@ +/** + * The Chromium the environment server runs for server-side preview tabs. + * + * Servers are often GPU-less containers without root or Chrome's system + * libraries, so on glibc Linux we install a pinned headless build from the + * `@sparticuz/chromium` GitHub release (its npm package ships x64 only): the + * executable with SwiftShader beside it, and NSS/NSPR/expat in `lib/` for + * `LD_LIBRARY_PATH`. A private fontconfig file layers optional T3-owned emoji + * and CJK fonts over whatever the system has. Everywhere else nothing is + * downloaded: we use an installed Chrome, Chromium, or Edge, or a Playwright + * cache build. + * + * Installs follow the pinned-runtime recipe: stage into a temp sibling, write + * the sentinel only after the tree is complete (and, for the browser, after + * the executable runs), then rename into place. + */ +import * as NodeStream from "@effect/platform-node/NodeStream"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeUtil from "node:util"; +import * as NodeZlib from "node:zlib"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +/** `@sparticuz/chromium` release; ships Chromium 153.0.8010.0. */ +export const SERVER_BROWSER_VERSION = "153.0.0"; +/** Bump when `FONTS` changes. */ +const FONTS_VERSION = "1"; + +interface PinnedAsset { + readonly url: string; + readonly sha256: string; +} + +const RELEASE_URL = `https://github.com/Sparticuz/chromium/releases/download/v${SERVER_BROWSER_VERSION}`; +const BROWSER_PACKS: Partial> = { + x64: { + url: `${RELEASE_URL}/chromium-v${SERVER_BROWSER_VERSION}-pack.x64.tar`, + sha256: "91b9f56d35a2cbb14279a1cbdaf1c86c0faa5fd82315bdd7334ff93bf35f224d", + }, + arm64: { + url: `${RELEASE_URL}/chromium-v${SERVER_BROWSER_VERSION}-pack.arm64.tar`, + sha256: "b6874fc0d7af15e16a15ef1b73177731f698c296c73d26aa4a90840c0777a338", + }, +}; + +// Emoji must be the CBDT build; this Chromium does not render the COLRv1 one. +const FONTS: ReadonlyArray = [ + { + file: "NotoColorEmoji.ttf", + url: "https://raw.githubusercontent.com/googlefonts/noto-emoji/v2.051/fonts/NotoColorEmoji.ttf", + sha256: "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b", + }, + { + file: "NotoSansCJK-Regular.ttc", + url: "https://raw.githubusercontent.com/notofonts/noto-cjk/Sans2.004/Sans/OTC/NotoSansCJK-Regular.ttc", + sha256: "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a", + }, + { + file: "NotoSansCJK-Bold.ttc", + url: "https://raw.githubusercontent.com/notofonts/noto-cjk/Sans2.004/Sans/OTC/NotoSansCJK-Bold.ttc", + sha256: "faa5f3656a78b2e2d450d27fe8382c778bc2b6bb5ea29c986664a6a435056ceb", + }, +]; + +const BROWSER_TOOL = "server-browser"; +const FONTS_TOOL = "server-browser-fonts"; +const SENTINEL = ".install-complete"; + +const xmlText = (value: string) => + value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + +/** + * Our cache dir comes first so fontconfig writes there (a relative cachedir + * resolves against the cwd, so it must be absolute). is scanned + * recursively and dot entries are skipped, so the fonts tool's staging dirs + * stay invisible and whichever fonts version is installed gets picked up. + */ +const fontconfigFile = ( + browserDir: string, + fontsToolDir: string, + path: Path.Path, +) => ` + + + ${xmlText(path.join(browserDir, "fontconfig-cache"))} + /etc/fonts/fonts.conf + ${xmlText(path.join(browserDir, "fonts"))} + ${xmlText(fontsToolDir)} + +`; + +const BROWSER_INSTALL_TIMEOUT = Duration.minutes(10); +const FONTS_INSTALL_TIMEOUT = Duration.minutes(3); +const browserInstallLock = Semaphore.makeUnsafe(1); +const fontsInstallLock = Semaphore.makeUnsafe(1); + +export type ServerBrowserSource = "override" | "bundled" | "system" | "playwright-cache"; + +export interface ServerBrowserLaunch { + readonly executablePath: string; + /** Merge over the server's environment when spawning. */ + readonly env: Readonly>; + /** Flags this build needs beyond the launcher's own. */ + readonly args: ReadonlyArray; + readonly source: ServerBrowserSource; +} + +export class ServerBrowserInstallError extends Schema.TaggedError()( + "ServerBrowserInstallError", + { + tool: Schema.String, + step: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Installing ${this.tool} failed while ${this.step}.`; + } +} + +export class ServerBrowserNotFoundError extends Schema.TaggedError()( + "ServerBrowserNotFoundError", + { + platform: Schema.String, + architecture: Schema.String, + override: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return this.override === undefined + ? `No Chrome, Chromium, or Edge was found on ${this.platform}/${this.architecture}. Install one or set T3CODE_PREVIEW_BROWSER_PATH.` + : `T3CODE_PREVIEW_BROWSER_PATH points at '${this.override}', which is not an executable.`; + } +} + +export type ServerBrowserToolchainError = ServerBrowserInstallError | ServerBrowserNotFoundError; + +export class ServerBrowserToolchain extends Context.Service< + ServerBrowserToolchain, + { + /** Installs the bundled build on first use; later calls only read sentinels. */ + readonly resolve: Effect.Effect; + } +>()("t3/preview/ServerBrowserToolchain") {} + +interface TarEntry { + readonly name: string; + readonly type: string; + readonly mode: number; + readonly data: Uint8Array; +} + +/** Reads the plain GNU/ustar tarballs in the pinned pack: files, directories, long names. */ +const readTar = (archive: Uint8Array): ReadonlyArray => { + const decoder = new TextDecoder(); + const text = (bytes: Uint8Array) => decoder.decode(bytes).replace(/\0[\s\S]*$/, ""); + const entries: Array = []; + let longName: string | undefined; + let offset = 0; + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const field = (start: number, length: number) => text(header.subarray(start, start + length)); + const size = Number.parseInt(field(124, 12).trim() || "0", 8); + const data = archive.subarray(offset + 512, offset + 512 + size); + offset += 512 + Math.ceil(size / 512) * 512; + const type = field(156, 1) || "0"; + if (type === "L") { + longName = text(data); + continue; + } + // Only POSIX ustar has a prefix field; old GNU headers keep other data there. + const prefix = field(257, 6) === "ustar" ? field(345, 155) : ""; + const name = longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)); + longName = undefined; + entries.push({ name, type, mode: Number.parseInt(field(100, 8).trim() || "644", 8), data }); + } + return entries; +}; + +const brotliDecompress = NodeUtil.promisify(NodeZlib.brotliDecompress); + +const hostUsesGlibc = (): boolean => { + try { + const report = process.report?.getReport() as + | { readonly header?: { readonly glibcVersionRuntime?: unknown } } + | undefined; + return typeof report?.header?.glibcVersionRuntime === "string"; + } catch { + return false; + } +}; + +const systemBrowserCandidates = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + path: Path.Path, +): ReadonlyArray => { + if (platform === "darwin") { + const apps = [ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ]; + const roots = ["/Applications", ...(env.HOME ? [path.join(env.HOME, "Applications")] : [])]; + return apps.flatMap((app) => roots.map((root) => path.join(root, app))); + } + if (platform === "win32") { + const apps = [ + "Google\\Chrome\\Application\\chrome.exe", + "Microsoft\\Edge\\Application\\msedge.exe", + "Chromium\\Application\\chrome.exe", + ]; + const roots = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA].filter( + (root): root is string => Boolean(root), + ); + return apps.flatMap((app) => roots.map((root) => path.join(root, app))); + } + return [ + "google-chrome-stable", + "google-chrome", + "chromium", + "chromium-browser", + "microsoft-edge-stable", + "microsoft-edge", + ]; +}; + +/** `chromium_headless_shell-/`, from playwright-core's registry. */ +const PLAYWRIGHT_HEADLESS_SHELL: Partial>> = { + "linux-x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"], + "linux-arm64": ["chrome-linux", "headless_shell"], + "darwin-x64": ["chrome-headless-shell-mac-x64", "chrome-headless-shell"], + "darwin-arm64": ["chrome-headless-shell-mac-arm64", "chrome-headless-shell"], + "win32-x64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"], +}; + +const playwrightCacheDir = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + path: Path.Path, +): string | undefined => { + if (env.PLAYWRIGHT_BROWSERS_PATH && env.PLAYWRIGHT_BROWSERS_PATH !== "0") { + return env.PLAYWRIGHT_BROWSERS_PATH; + } + if (platform === "win32") { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "ms-playwright") : undefined; + } + if (!env.HOME) return undefined; + return platform === "darwin" + ? path.join(env.HOME, "Library", "Caches", "ms-playwright") + : path.join(env.XDG_CACHE_HOME || path.join(env.HOME, ".cache"), "ms-playwright"); +}; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const runner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const hostEnv = yield* HostProcessEnvironment; + + const toolsDir = path.join(config.baseDir, "tools"); + const browserDir = path.join(toolsDir, BROWSER_TOOL, SERVER_BROWSER_VERSION); + const browserPack = + platform === "linux" && hostUsesGlibc() ? BROWSER_PACKS[architecture] : undefined; + + const isInstalled = (installDir: string, version: string) => + fs.readFileString(path.join(installDir, SENTINEL)).pipe( + Effect.map((sentinel) => sentinel.trim() === version), + Effect.orElseSucceed(() => false), + ); + + /** Stages, publishes, and prunes `/tools//` unless it is already complete. */ + const installOnce = Effect.fn("ServerBrowserToolchain.installOnce")(function* ( + tool: string, + version: string, + populate: (stagingDir: string) => Effect.Effect, + ) { + const fail = (step: string) => (cause: unknown) => + new ServerBrowserInstallError({ tool, step, cause }); + const parentDir = path.join(toolsDir, tool); + const installDir = path.join(parentDir, version); + if (yield* isInstalled(installDir, version)) return; + + yield* fs + .remove(installDir, { recursive: true, force: true }) + .pipe(Effect.mapError(fail("removing an incomplete install"))); + yield* fs + .makeDirectory(parentDir, { recursive: true }) + .pipe(Effect.mapError(fail("preparing the install directory"))); + const stagingDir = yield* fs + .makeTempDirectory({ directory: parentDir, prefix: ".staging-" }) + .pipe(Effect.mapError(fail("preparing the install directory"))); + + yield* Effect.gen(function* () { + yield* populate(stagingDir); + yield* fs + .writeFileString(path.join(stagingDir, SENTINEL), `${version}\n`) + .pipe(Effect.mapError(fail("recording the completed install"))); + yield* fs.rename(stagingDir, installDir).pipe( + Effect.catch((cause) => + // A concurrent server may have published the same version first. + isInstalled(installDir, version).pipe( + Effect.flatMap((published) => + published ? Effect.void : Effect.fail(fail("publishing the install")(cause)), + ), + ), + ), + ); + // Only runs right after an upgrade; dot entries are other installs' staging dirs. + const names = yield* fs.readDirectory(parentDir).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + names.filter((name) => !name.startsWith(".") && name !== version), + (name) => fs.remove(path.join(parentDir, name), { recursive: true, force: true }), + ).pipe(Effect.ignore); + }).pipe( + Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + }); + + const download = Effect.fn("ServerBrowserToolchain.download")(function* ( + tool: string, + asset: PinnedAsset, + ) { + const bytes = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError( + (cause) => new ServerBrowserInstallError({ tool, step: `downloading ${asset.url}`, cause }), + ), + ); + if (NodeCrypto.createHash("sha256").update(bytes).digest("hex") !== asset.sha256) { + return yield* new ServerBrowserInstallError({ tool, step: `verifying ${asset.url}` }); + } + return bytes; + }); + + const launchEnv = (installDir: string): Record => ({ + LD_LIBRARY_PATH: [path.join(installDir, "lib"), hostEnv.LD_LIBRARY_PATH] + .filter(Boolean) + .join(":"), + FONTCONFIG_FILE: path.join(installDir, "fonts.conf"), + }); + + const populateBrowser = (pack: PinnedAsset) => + Effect.fn("ServerBrowserToolchain.populateBrowser")(function* (stagingDir: string) { + const fail = (step: string) => (cause: unknown) => + new ServerBrowserInstallError({ tool: BROWSER_TOOL, step, cause }); + const members = new Map( + readTar(yield* download(BROWSER_TOOL, pack)).map((entry) => [entry.name, entry.data]), + ); + const member = (name: string) => { + const data = members.get(name); + return data === undefined + ? Effect.fail( + new ServerBrowserInstallError({ tool: BROWSER_TOOL, step: `finding ${name}` }), + ) + : Effect.succeed(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); + }; + + // Stream the ~200 MB executable to disk instead of inflating it in memory. + const executablePath = path.join(stagingDir, "chromium"); + const chromium = yield* member("chromium.br"); + yield* Stream.succeed(chromium).pipe( + NodeStream.pipeThroughSimple(() => NodeZlib.createBrotliDecompress()), + Stream.run(fs.sink(executablePath, { mode: 0o755 })), + Effect.mapError(fail("unpacking chromium.br")), + ); + + // SwiftShader sits beside the executable, NSS/NSPR/expat land in lib/, + // and fonts.tar adds Open Sans for hosts with no fonts at all. + for (const name of ["swiftshader.tar.br", "al2023.tar.br", "fonts.tar.br"]) { + const compressed = yield* member(name); + const archive = yield* Effect.tryPromise({ + try: () => brotliDecompress(compressed), + catch: fail(`unpacking ${name}`), + }); + for (const entry of readTar(archive)) { + const target = path.resolve(stagingDir, entry.name); + if ( + !target.startsWith(stagingDir + path.sep) || + (entry.type !== "0" && entry.type !== "5") + ) { + return yield* fail(`unpacking ${name}`)( + `Unsupported entry ${entry.type} ${entry.name}`, + ); + } + yield* ( + entry.type === "5" + ? fs.makeDirectory(target, { recursive: true }) + : fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe(Effect.andThen(fs.writeFile(target, entry.data, { mode: entry.mode }))) + ).pipe(Effect.mapError(fail(`unpacking ${name}`))); + } + } + // Replaces the Lambda-specific fonts.conf from fonts.tar. + yield* fs + .writeFileString( + path.join(stagingDir, "fonts.conf"), + fontconfigFile(browserDir, path.join(toolsDir, FONTS_TOOL), path), + ) + .pipe(Effect.mapError(fail("writing fonts.conf"))); + + // Proves the loader finds every library before the install counts. + const result = yield* runner + .run({ + command: executablePath, + args: ["--version"], + env: launchEnv(stagingDir), + timeout: Duration.seconds(30), + }) + .pipe(Effect.mapError(fail("running chromium --version"))); + if (result.code !== 0 || !result.stdout.includes("Chromium")) { + return yield* fail("running chromium --version")(result); + } + }); + + const populateFonts = Effect.fn("ServerBrowserToolchain.populateFonts")(function* ( + stagingDir: string, + ) { + yield* Effect.forEach( + FONTS, + (font) => + download(FONTS_TOOL, font).pipe( + Effect.flatMap((bytes) => + fs.writeFile(path.join(stagingDir, font.file), bytes).pipe( + Effect.mapError( + (cause) => + new ServerBrowserInstallError({ + tool: FONTS_TOOL, + step: `saving ${font.file}`, + cause, + }), + ), + ), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + }); + + // Fonts are optional: one attempt per server process that never fails the + // browser. It runs detached so a cancelled or failed resolve cannot + // interrupt it, which would cache the interruption for every later call. + const fontsFiber = yield* Effect.cached( + Effect.forkDetach( + fontsInstallLock.withPermit(installOnce(FONTS_TOOL, FONTS_VERSION, populateFonts)).pipe( + Effect.timeoutOrElse({ + duration: FONTS_INSTALL_TIMEOUT, + orElse: () => + Effect.fail( + new ServerBrowserInstallError({ tool: FONTS_TOOL, step: "downloading (timed out)" }), + ), + }), + Effect.catch((cause) => + Effect.logWarning("Preview browser fonts unavailable; emoji and CJK may not render", { + cause, + }), + ), + ), + ), + ); + const ensureFonts = Effect.flatMap(fontsFiber, Fiber.join); + + const bundledLaunch: ServerBrowserLaunch = { + executablePath: path.join(browserDir, "chromium"), + env: launchEnv(browserDir), + args: [], + source: "bundled", + }; + const bundled = (pack: PinnedAsset) => + Effect.all( + [ + browserInstallLock + .withPermit(installOnce(BROWSER_TOOL, SERVER_BROWSER_VERSION, populateBrowser(pack))) + .pipe( + Effect.timeoutOrElse({ + duration: BROWSER_INSTALL_TIMEOUT, + orElse: () => + Effect.fail( + new ServerBrowserInstallError({ + tool: BROWSER_TOOL, + step: "installing (timed out)", + }), + ), + }), + ), + ensureFonts, + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.as(bundledLaunch)); + + /** Resolves bare names through PATH and checks absolute paths are executable. */ + const executable = (candidate: string) => + resolveCommandPath(candidate).pipe( + Effect.option, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + const firstExecutable = Effect.fn("ServerBrowserToolchain.firstExecutable")(function* ( + candidates: ReadonlyArray, + ) { + for (const candidate of candidates) { + const resolved = yield* executable(candidate); + if (Option.isSome(resolved)) return resolved.value; + } + return undefined; + }); + + const discover = Effect.gen(function* () { + const system = yield* firstExecutable(systemBrowserCandidates(platform, hostEnv, path)); + if (system !== undefined) { + return { + executablePath: system, + env: {}, + args: [], + source: "system", + } satisfies ServerBrowserLaunch; + } + const cacheDir = playwrightCacheDir(platform, hostEnv, path); + const relative = PLAYWRIGHT_HEADLESS_SHELL[`${platform}-${architecture}`]; + if (cacheDir !== undefined && relative !== undefined) { + const revisions = (yield* fs.readDirectory(cacheDir).pipe(Effect.orElseSucceed(() => []))) + .flatMap((name) => { + const match = /^chromium_headless_shell-(\d+)$/.exec(name); + return match ? [{ name, revision: Number(match[1]) }] : []; + }) + .sort((left, right) => right.revision - left.revision); + const cached = yield* firstExecutable( + revisions.map(({ name }) => path.join(cacheDir, name, ...relative)), + ); + if (cached !== undefined) { + return { + executablePath: cached, + env: {}, + args: [], + source: "playwright-cache", + } satisfies ServerBrowserLaunch; + } + } + return yield* new ServerBrowserNotFoundError({ platform, architecture }); + }); + + const resolve: ServerBrowserToolchain["Service"]["resolve"] = Effect.gen(function* () { + const override = hostEnv.T3CODE_PREVIEW_BROWSER_PATH; + if (override) { + const resolved = yield* executable(override); + if (Option.isNone(resolved)) { + return yield* new ServerBrowserNotFoundError({ platform, architecture, override }); + } + const libraryPath = hostEnv.T3CODE_PREVIEW_BROWSER_LD_LIBRARY_PATH; + return { + executablePath: resolved.value, + env: libraryPath ? { LD_LIBRARY_PATH: libraryPath } : {}, + args: [], + source: "override", + } satisfies ServerBrowserLaunch; + } + if (browserPack === undefined) return yield* discover; + // Offline with a system browser still works; otherwise report the install failure. + return yield* bundled(browserPack).pipe( + Effect.catch((installError) => + Effect.logWarning("Bundled preview browser unavailable; looking for a system browser", { + cause: installError, + }).pipe( + Effect.andThen(discover), + Effect.catchTag("ServerBrowserNotFoundError", () => Effect.fail(installError)), + ), + ), + ); + }); + + return ServerBrowserToolchain.of({ resolve }); +}); + +export const layer = Layer.effect(ServerBrowserToolchain, make); diff --git a/apps/server/src/preview/serverBrowserEnabled.ts b/apps/server/src/preview/serverBrowserEnabled.ts new file mode 100644 index 000000000000..63bca9a8e985 --- /dev/null +++ b/apps/server/src/preview/serverBrowserEnabled.ts @@ -0,0 +1,13 @@ +import type { RuntimeMode } from "../config.ts"; + +/** + * Standalone servers host `runtime: "server"` preview tabs. A desktop-managed + * server leaves the browser to its Electron app. `T3CODE_SERVER_BROWSER=0|1` + * overrides either. + */ +export const isServerBrowserEnabled = (mode: RuntimeMode): boolean => { + const override = process.env.T3CODE_SERVER_BROWSER; + if (override === "0") return false; + if (override === "1") return true; + return mode === "web"; +}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3f528cd85f15..49acd5190de8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -63,6 +63,9 @@ import * as DeviceService from "./device/DeviceService.ts"; import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import * as ServerBrowser from "./preview/ServerBrowser.ts"; +import * as ServerBrowserStream from "./preview/ServerBrowserStream.ts"; +import * as ServerBrowserToolchain from "./preview/ServerBrowserToolchain.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; @@ -661,6 +664,13 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, DeviceHubProxy.layer, + ServerBrowserStream.routeLayer.pipe( + Layer.provide( + ServerBrowser.layer.pipe( + Layer.provide(ServerBrowserToolchain.layer.pipe(Layer.provide(ProcessRunner.layer))), + ), + ), + ), ServerHttp.layerStaticAndDevRoute, Ws.layer, ), diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index de7e23603298..e2325cf5fbd2 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -20,19 +20,22 @@ export function ElectronBrowserHost() { () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); + // Server tabs stream from the environment and never get a local webview. return threadRef - ? Object.values(previewState.sessions).map((snapshot) => ({ - threadRef, - snapshot, - runtimeTabId: previewRuntimeTabId( + ? Object.values(previewState.sessions) + .filter((snapshot) => snapshot.runtime !== "server") + .map((snapshot) => ({ threadRef, - previewState.serverEpoch, - snapshot.tabId, - ), - pictureInPicture: - previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, - zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, - })) + snapshot, + runtimeTabId: previewRuntimeTabId( + threadRef, + previewState.serverEpoch, + snapshot.tabId, + ), + pictureInPicture: + previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, + zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, + })) : []; }), [previewByThreadKey], diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx new file mode 100644 index 000000000000..5d8315a9d701 --- /dev/null +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -0,0 +1,576 @@ +"use client"; + +import { + createPreviewFramePainter, + createPreviewStreamClient, + previewStreamModifiers, + type PreviewStreamClient, + type PreviewStreamInput, + type PreviewStreamMouseButton, + type PreviewStreamViewport, +} from "@t3tools/client-runtime/preview/server-browser-stream"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + type ClipboardEvent, + type CompositionEvent, + type FormEvent, + type KeyboardEvent, + type PointerEvent, + type Ref, + useCallback, + useEffect, + useEffectEvent, + useImperativeHandle, + useRef, + useState, +} from "react"; + +import { cn } from "~/lib/utils"; +import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; + +/** Chrome-row controls for a server tab. A command issued before the socket opens waits for it. */ +export interface ServerBrowserHandle { + readonly navigate: (url: string) => void; + readonly history: (delta: -1 | 1) => void; + readonly reload: () => void; + /** The canvas showing the latest frame. */ + readonly canvas: () => HTMLCanvasElement | null; +} + +const RESIZE_DEBOUNCE_MS = 150; +const ACCESS_RETRY_MS = 10_000; +const UNAUTHORIZED_RETRY_MAX_MS = 30_000; +const TAP_SLOP_PX = 8; +const MULTI_CLICK_MS = 500; +const MULTI_CLICK_SLOP_PX = 4; +const WHEEL_LINE_PX = 16; + +type MouseInput = Extract; +type WheelInput = Extract; + +interface PagePoint { + readonly x: number; + readonly y: number; + /** Page CSS px per client px. */ + readonly scale: number; +} + +interface TouchGesture { + readonly pointerId: number; + readonly startX: number; + readonly startY: number; + lastX: number; + lastY: number; + panning: boolean; +} + +/** Whether the page point under a touch takes text; null until the server answers. */ +interface TouchProbe { + readonly x: number; + readonly y: number; + editable: boolean | null; + /** The tap ended before the answer arrived. */ + tapped: boolean; +} + +const buttonOf = (button: number): PreviewStreamMouseButton => + button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none"; + +const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => + buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; + +/** + * A server-hosted preview tab: JPEG frames from the environment's headless + * Chromium drawn into a canvas, with pointer, wheel, and keyboard input sent + * back in page coordinates. Touch taps click and touch drags scroll; the soft + * keyboard opens only for taps the server reports as landing on a text field. + * `visible=false` closes the stream so a hidden panel decodes nothing. + */ +export function ServerBrowserSurface(props: { + readonly environmentId: EnvironmentId; + readonly threadId: string; + readonly tabId: string; + readonly visible: boolean; + /** Sends the surface size so fill-mode tabs follow it. The floating player scales the page instead. */ + readonly followSize?: boolean; + readonly onFirstFrame?: () => void; + readonly onViewport?: (viewport: PreviewStreamViewport) => void; + readonly className?: string; + readonly ref?: Ref; +}) { + const { + environmentId, + threadId, + tabId, + visible, + followSize = true, + onFirstFrame, + onViewport, + className, + ref, + } = props; + const access = usePreviewStreamAccess(environmentId); + const containerRef = useRef(null); + const canvasRef = useRef(null); + const inputRef = useRef(null); + const clientRef = useRef(null); + const viewportRef = useRef(null); + const sizeRef = useRef<{ width: number; height: number } | null>(null); + const hasFrameRef = useRef(false); + const pendingCommandRef = useRef(null); + const unauthorizedRef = useRef(0); + const pendingMoveRef = useRef(null); + const pendingWheelRef = useRef(null); + const inputFrameRef = useRef(null); + const mousePressedRef = useRef(false); + const lastClickRef = useRef<{ + button: PreviewStreamMouseButton; + time: number; + x: number; + y: number; + count: number; + } | null>(null); + const touchRef = useRef(null); + const probeRef = useRef(null); + const firstFrame = useEffectEvent(() => onFirstFrame?.()); + const viewportChanged = useEffectEvent((viewport: PreviewStreamViewport) => + onViewport?.(viewport), + ); + // Frame cap in device px, fixed per socket. It grows with the surface and + // never shrinks, so only outgrowing it reconnects. + const [cap, setCap] = useState<{ width: number; height: number } | null>(null); + + const send = useCallback((input: PreviewStreamInput) => { + clientRef.current?.send(input); + }, []); + + const flushInput = useCallback(() => { + if (inputFrameRef.current !== null) cancelAnimationFrame(inputFrameRef.current); + inputFrameRef.current = null; + const move = pendingMoveRef.current; + const wheel = pendingWheelRef.current; + pendingMoveRef.current = null; + pendingWheelRef.current = null; + if (move) send(move); + if (wheel) send(wheel); + }, [send]); + + // Moves and wheel deltas coalesce to one message each per animation frame. + // `flushInput` only closes over refs and `send`, so it never changes. + const scheduleFlush = useCallback(() => { + inputFrameRef.current ??= requestAnimationFrame(flushInput); + }, []); + + const queueWheel = useCallback( + (point: PagePoint, deltaX: number, deltaY: number, modifiers: number) => { + const pending = pendingWheelRef.current; + pendingWheelRef.current = { + type: "wheel", + x: point.x, + y: point.y, + deltaX: (pending?.deltaX ?? 0) + deltaX, + deltaY: (pending?.deltaY ?? 0) + deltaY, + modifiers, + }; + scheduleFlush(); + }, + [scheduleFlush], + ); + + const pagePoint = useCallback( + (clientX: number, clientY: number, clamp: boolean): PagePoint | null => { + const canvas = canvasRef.current; + const viewport = viewportRef.current; + if (!canvas || !viewport || !hasFrameRef.current) return null; + const rect = canvas.getBoundingClientRect(); + // `object-contain` letterboxes the frame inside the canvas box. + const fit = Math.min(rect.width / canvas.width, rect.height / canvas.height); + const width = canvas.width * fit; + const height = canvas.height * fit; + if (!(width > 0 && height > 0)) return null; + const scale = viewport.width / width; + const x = (clientX - rect.left - (rect.width - width) / 2) * scale; + const y = (clientY - rect.top - (rect.height - height) / 2) * (viewport.height / height); + const inside = x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height; + if (!inside && !clamp) return null; + return { + x: Math.min(Math.max(x, 0), viewport.width), + y: Math.min(Math.max(y, 0), viewport.height), + scale, + }; + }, + [], + ); + + const countClick = (button: PreviewStreamMouseButton, x: number, y: number, time: number) => { + const last = lastClickRef.current; + const count = + last && + last.button === button && + time - last.time < MULTI_CLICK_MS && + Math.hypot(x - last.x, y - last.y) < MULTI_CLICK_SLOP_PX + ? last.count + 1 + : 1; + lastClickRef.current = { button, time, x, y, count }; + return count; + }; + + const focusInput = () => inputRef.current?.focus({ preventScroll: true }); + + useImperativeHandle( + ref, + () => { + const command = (input: PreviewStreamInput) => { + if (clientRef.current?.send(input)) return; + pendingCommandRef.current = input; + }; + return { + navigate: (url) => command({ type: "navigate", url }), + history: (delta) => command({ type: "history", delta }), + reload: () => command({ type: "reload" }), + canvas: () => (hasFrameRef.current ? canvasRef.current : null), + }; + }, + [], + ); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + let timer: ReturnType | null = null; + const measure = () => { + timer = null; + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + const size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + const previous = sizeRef.current; + if (previous?.width === size.width && previous.height === size.height) return; + sizeRef.current = size; + if (followSize) clientRef.current?.send({ type: "resize", ...size }); + const ratio = window.devicePixelRatio || 1; + const width = Math.round(size.width * ratio); + const height = Math.round(size.height * ratio); + setCap((current) => + current !== null && current.width >= width && current.height >= height + ? current + : { + width: Math.max(width, current?.width ?? 0), + height: Math.max(height, current?.height ?? 0), + }, + ); + }; + const observer = new ResizeObserver(() => { + if (timer !== null) clearTimeout(timer); + // The first size connects right away; later ones settle before resizing the page. + timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS); + }); + observer.observe(element); + return () => { + observer.disconnect(); + if (timer !== null) clearTimeout(timer); + }; + }, [followSize]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!visible || !access || !cap || !canvas) return; + let refreshTimer: ReturnType | null = null; + const painter = createPreviewFramePainter(canvas, () => { + if (hasFrameRef.current) return; + hasFrameRef.current = true; + firstFrame(); + }); + const client = createPreviewStreamClient( + { access, threadId, tabId, maxWidth: cap.width, maxHeight: cap.height }, + { + onFrame: (jpeg) => { + unauthorizedRef.current = 0; + painter.paint(jpeg); + }, + onProbe: (result) => { + const probe = probeRef.current; + if (!probe || probe.x !== result.x || probe.y !== result.y) return; + probe.editable = result.editable; + if (!probe.tapped) return; + // Late answer: Android still raises the keyboard; iOS waits for the next tap. + probeRef.current = null; + if (result.editable) inputRef.current?.focus({ preventScroll: true }); + else inputRef.current?.blur(); + }, + onViewport: (viewport) => { + viewportRef.current = viewport; + viewportChanged(viewport); + }, + onConnectedChange: (connected) => { + if (!connected) return; + const size = sizeRef.current; + if (followSize && size) client.send({ type: "resize", ...size }); + const command = pendingCommandRef.current; + pendingCommandRef.current = null; + if (command) client.send(command); + }, + onUnauthorized: () => { + // A fresh ticket re-runs this effect through `access`. Back off when + // new tickets keep getting refused, e.g. a session without operate scope. + const attempt = unauthorizedRef.current++; + refreshTimer = setTimeout( + () => refreshPreviewStreamAccess(environmentId), + attempt === 0 ? 0 : Math.min(1_000 * 2 ** attempt, UNAUTHORIZED_RETRY_MAX_MS), + ); + }, + }, + ); + clientRef.current = client; + return () => { + painter.stop(); + if (refreshTimer !== null) clearTimeout(refreshTimer); + client.stop(); + if (clientRef.current === client) clientRef.current = null; + }; + }, [access, cap, environmentId, followSize, tabId, threadId, visible]); + + useEffect(() => { + if (!visible || access !== null) return; + // A failed ticket mint, e.g. while the server restarts, never retries on its own. + const timer = setTimeout(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS); + return () => clearTimeout(timer); + }, [access, environmentId, visible]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + // Registered natively: React's wheel listener is passive and cannot stop the panel scrolling. + const onWheel = (event: WheelEvent) => { + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + event.preventDefault(); + const unit = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? WHEEL_LINE_PX + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? (viewportRef.current?.height ?? 0) + : 1; + queueWheel(point, event.deltaX * unit, event.deltaY * unit, previewStreamModifiers(event)); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [pagePoint, queueWheel]); + + useEffect( + () => () => { + if (inputFrameRef.current !== null) cancelAnimationFrame(inputFrameRef.current); + }, + [], + ); + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === "touch") { + if (!event.isPrimary) return; + touchRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + panning: false, + }; + const point = pagePoint(event.clientX, event.clientY, false); + probeRef.current = point ? { x: point.x, y: point.y, editable: null, tapped: false } : null; + if (point) send({ type: "probe", x: point.x, y: point.y }); + return; + } + focusInput(); + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + event.currentTarget.setPointerCapture(event.pointerId); + flushInput(); + mousePressedRef.current = true; + const button = buttonOf(event.button); + send({ + type: "mouse", + action: "down", + x: point.x, + y: point.y, + button, + buttons: event.buttons, + clickCount: countClick(button, event.clientX, event.clientY, event.timeStamp), + modifiers: previewStreamModifiers(event), + }); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") { + const touch = touchRef.current; + if (!touch || touch.pointerId !== event.pointerId) return; + if ( + !touch.panning && + Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) < TAP_SLOP_PX + ) { + return; + } + touch.panning = true; + const point = pagePoint(event.clientX, event.clientY, true); + // Dragging the page up scrolls it down, following the finger. + if (point) { + queueWheel( + point, + (touch.lastX - event.clientX) * point.scale, + (touch.lastY - event.clientY) * point.scale, + 0, + ); + } + touch.lastX = event.clientX; + touch.lastY = event.clientY; + return; + } + const point = pagePoint(event.clientX, event.clientY, event.buttons !== 0); + if (!point) return; + pendingMoveRef.current = { + type: "mouse", + action: "move", + x: point.x, + y: point.y, + button: pressedButtonOf(event.buttons), + buttons: event.buttons, + clickCount: 0, + modifiers: previewStreamModifiers(event), + }; + scheduleFlush(); + }; + + const handlePointerUp = (event: PointerEvent) => { + if (event.pointerType === "touch") { + const touch = touchRef.current; + if (!touch || touch.pointerId !== event.pointerId) return; + touchRef.current = null; + const probe = probeRef.current; + if (touch.panning) { + probeRef.current = null; + return; + } + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + // Focusing inside the tap's user activation is what lets iOS raise the keyboard. + if (probe?.editable === true) focusInput(); + else if (probe?.editable === false) inputRef.current?.blur(); + if (probe?.editable === null) probe.tapped = true; + else probeRef.current = null; + flushInput(); + const clickCount = countClick("left", event.clientX, event.clientY, event.timeStamp); + const at = { x: point.x, y: point.y, modifiers: 0 }; + send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 }); + send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount }); + send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); + return; + } + if (!mousePressedRef.current) return; + mousePressedRef.current = false; + const point = pagePoint(event.clientX, event.clientY, true); + if (!point) return; + flushInput(); + send({ + type: "mouse", + action: "up", + x: point.x, + y: point.y, + button: buttonOf(event.button), + buttons: event.buttons, + clickCount: lastClickRef.current?.count ?? 1, + modifiers: previewStreamModifiers(event), + }); + }; + + const handlePointerCancel = (event: PointerEvent) => { + if (touchRef.current?.pointerId === event.pointerId) touchRef.current = null; + }; + + const handleKey = (action: "down" | "up", event: KeyboardEvent) => { + // IME and soft keyboards deliver text through composition and input events. + if ( + event.nativeEvent.isComposing || + event.keyCode === 229 || + event.key === "Process" || + event.key === "Unidentified" + ) { + return; + } + const shortcut = event.ctrlKey || event.metaKey; + // Paste arrives as a paste event carrying this device's clipboard. + if (shortcut && event.key.toLowerCase() === "v") return; + // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. + const text = shortcut + ? undefined + : [...event.key].length === 1 + ? event.key + : event.key === "Enter" + ? "\r" + : undefined; + send({ + type: "key", + action, + key: event.key, + code: event.code, + keyCode: event.keyCode, + ...(action === "down" && text !== undefined ? { text } : {}), + modifiers: previewStreamModifiers(event), + }); + // Shortcuts stay with the app (copy, paste, keybindings); other keys belong to the page. + if (shortcut) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const handleInput = (event: FormEvent) => { + if (event.nativeEvent instanceof InputEvent && event.nativeEvent.isComposing) return; + const textarea = event.currentTarget; + if (textarea.value) send({ type: "text", text: textarea.value }); + textarea.value = ""; + }; + + const handleCompositionEnd = (event: CompositionEvent) => { + if (event.data) send({ type: "text", text: event.data }); + event.currentTarget.value = ""; + }; + + const handlePaste = (event: ClipboardEvent) => { + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + if (text) send({ type: "text", text }); + }; + + return ( + + event.preventDefault()} + onContextMenu={(event) => event.preventDefault()} + /> + {/* Focus target for page keyboard input. Pinned top-left so focusing it never + scrolls the surface; 16px keeps iOS from zooming the app on focus. */} + handleKey("down", event)} + onKeyUp={(event) => handleKey("up", event)} + onInput={handleInput} + onCompositionEnd={handleCompositionEnd} + onPaste={handlePaste} + /> + + ); +} diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 38ab15eb826b..100fc4e6da41 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -16,6 +16,8 @@ import * as Data from "effect/Data"; import { AsyncResult } from "effect/reactivity"; import { resolveAssetUrl } from "~/assets/assetUrls"; +import { trackLocalServerTabOpen } from "~/components/preview/localServerTabs"; +import { previewBridge } from "~/components/preview/previewBridge"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime, @@ -62,18 +64,24 @@ export async function openUrlInPreview(input: { if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - url: input.url, - // Built here rather than via `openPreviewSession` because this path - // maps the result differently, so the configured defaults have to be - // applied explicitly or file/link opens would ignore them. - viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), - }, - }); + const open = () => + input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Built here rather than via `openPreviewSession` because this path + // maps the result differently, so the configured defaults have to be + // applied explicitly or file/link opens would ignore them. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + // Same runtime rule as `openPreviewSession`. + ...(previewBridge ? {} : { runtime: "server" as const }), + }, + }); + const result = previewBridge + ? await open() + : await trackLocalServerTabOpen(input.threadRef, open); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); diff --git a/apps/web/src/browser/serverPictureInPicture.ts b/apps/web/src/browser/serverPictureInPicture.ts new file mode 100644 index 000000000000..d07ce18bfd2e --- /dev/null +++ b/apps/web/src/browser/serverPictureInPicture.ts @@ -0,0 +1,191 @@ +import { + createPreviewFramePainter, + createPreviewStreamClient, + type PreviewStreamClient, +} from "@t3tools/client-runtime/preview/server-browser-stream"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useSyncExternalStore } from "react"; + +import { readPreviewStreamAccess } from "~/state/previewStream"; + +/** + * System picture in picture for a server tab, so a phone keeps watching the + * agent's browser over other apps. The tab streams into a detached canvas, + * which plays as a muted video the browser floats. It is view-only and owns + * its own socket, so it outlives the panel and floating player that opened it. + * One at a time, like the browser's own picture in picture. + */ + +/** Frames cap for the floating window, in device px. */ +const STREAM_CAP_PX = 1280; +const PLACEHOLDER = { width: 640, height: 400 }; + +interface WebKitVideo { + webkitSupportsPresentationMode?: (mode: string) => boolean; + webkitSetPresentationMode?: (mode: string) => void; + webkitPresentationMode?: string; +} + +interface ActivePictureInPicture { + readonly key: string; + readonly video: HTMLVideoElement; + readonly stop: () => void; +} + +let active: ActivePictureInPicture | null = null; +const listeners = new Set<() => void>(); + +const emit = () => { + for (const listener of listeners) listener(); +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const serverPictureInPictureKey = (threadId: string, tabId: string) => + JSON.stringify([threadId, tabId]); + +/** Chromium and Safari float a canvas-captured video; Firefox has no API for it. */ +export function supportsServerPictureInPicture(): boolean { + if (typeof document === "undefined") return false; + if (typeof HTMLCanvasElement.prototype.captureStream !== "function") return false; + const video = HTMLVideoElement.prototype as HTMLVideoElement & WebKitVideo; + return ( + (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") || + typeof video.webkitSetPresentationMode === "function" + ); +} + +/** The key of the floating server tab, or null. */ +export function useServerPictureInPictureKey(): string | null { + return useSyncExternalStore( + subscribe, + () => active?.key ?? null, + () => null, + ); +} + +export function closeServerPictureInPicture(): void { + const current = active; + active = null; + current?.stop(); + emit(); +} + +/** + * Must run inside the click that asked for it: browsers only float a video + * during user activation. `seed` is the tab's visible canvas, copied so the + * window opens on the current frame instead of a blank one. + */ +export async function openServerPictureInPicture(input: { + readonly environmentId: EnvironmentId; + readonly threadId: string; + readonly tabId: string; + readonly seed: HTMLCanvasElement | null; +}): Promise { + closeServerPictureInPicture(); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + const seed = input.seed && input.seed.width > 0 && input.seed.height > 0 ? input.seed : null; + canvas.width = seed?.width ?? PLACEHOLDER.width; + canvas.height = seed?.height ?? PLACEHOLDER.height; + // The capture emits a frame per draw, so the seed is drawn after it starts. + const stream = canvas.captureStream(); + if (seed) context?.drawImage(seed, 0, 0); + else context?.fillRect(0, 0, canvas.width, canvas.height); + const video = document.createElement("video") as HTMLVideoElement & WebKitVideo; + video.muted = true; + video.playsInline = true; + video.srcObject = stream; + // Safari only floats a video that is in the document. + Object.assign(video.style, { + position: "fixed", + right: "0", + bottom: "0", + width: "1px", + height: "1px", + opacity: "0", + pointerEvents: "none", + }); + document.body.append(video); + + const painter = createPreviewFramePainter(canvas); + let client: PreviewStreamClient | null = null; + let stopped = false; + const connect = async (refresh: boolean) => { + const access = await readPreviewStreamAccess(input.environmentId, refresh); + if (stopped) return; + if (!access) { + closeServerPictureInPicture(); + return; + } + client = createPreviewStreamClient( + { + access, + threadId: input.threadId, + tabId: input.tabId, + maxWidth: STREAM_CAP_PX, + maxHeight: STREAM_CAP_PX, + }, + { + onFrame: painter.paint, + onViewport: () => undefined, + onConnectedChange: () => undefined, + onUnauthorized: () => void connect(true), + onGone: () => { + if (active?.video === video) closeServerPictureInPicture(); + }, + }, + ); + }; + + const onLeave = () => { + if (active?.video === video) closeServerPictureInPicture(); + }; + const onPresentationMode = () => { + if (video.webkitPresentationMode !== "picture-in-picture") onLeave(); + }; + const current: ActivePictureInPicture = { + key: serverPictureInPictureKey(input.threadId, input.tabId), + video, + stop: () => { + stopped = true; + client?.stop(); + painter.stop(); + video.removeEventListener("leavepictureinpicture", onLeave); + video.removeEventListener("webkitpresentationmodechanged", onPresentationMode); + if (document.pictureInPictureElement === video) { + void document.exitPictureInPicture().catch(() => undefined); + } else if (video.webkitPresentationMode === "picture-in-picture") { + video.webkitSetPresentationMode?.("inline"); + } + for (const track of stream.getTracks()) track.stop(); + video.srcObject = null; + video.remove(); + }, + }; + active = current; + emit(); + + try { + await video.play(); + if (video.readyState < HTMLMediaElement.HAVE_METADATA) { + await new Promise((resolve) => + video.addEventListener("loadedmetadata", resolve, { once: true }), + ); + } + if (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") { + video.addEventListener("leavepictureinpicture", onLeave); + await video.requestPictureInPicture(); + } else { + video.addEventListener("webkitpresentationmodechanged", onPresentationMode); + video.webkitSetPresentationMode?.("picture-in-picture"); + } + } catch (error) { + if (active === current) closeServerPictureInPicture(); + throw error; + } + if (active === current) void connect(false); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 656b989e8595..36455bc6ba30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -258,7 +258,9 @@ import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; +import { isLocalServerTab } from "./preview/localServerTabs"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; +import { usePreviewSession } from "./preview/usePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -408,6 +410,7 @@ import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/c import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { resolveThreadDetailRef, + useEnvironmentSupportsServerBrowser, useProject, useProjects, useThreadProjection, @@ -2195,6 +2198,11 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + // Electron hosts its own browser tabs; other clients need the environment to host them. + const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( + activeThreadRef?.environmentId ?? null, + ); + const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { containerWidth: workspaceLayoutWidth ?? undefined, widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, @@ -2283,7 +2291,7 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], [activeKnownTerminalIds, panelTerminalIds], ); - const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); + const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); @@ -5349,6 +5357,35 @@ export default function ChatView(props: ChatViewProps) { deviceState.sessions, deviceState.devices, ]); + // A server tab the agent opens floats over chat, as a desktop automation + // host does for its own tabs. The first snapshot is a baseline so reloads do + // not resurrect tabs; tabs this client opened or already shows stay put. + const previousServerPreviewTabs = useRef(new Map>()); + useEffect(() => { + if (!activeThreadRef || !activeEnvironmentServerBrowser) return; + const threadKey = scopedThreadKey(activeThreadRef); + const serverTabIds = Object.values(activePreviewState.sessions) + .filter((session) => session.runtime === "server") + .map((session) => session.tabId); + const previous = previousServerPreviewTabs.current.get(threadKey); + previousServerPreviewTabs.current.set(threadKey, new Set(serverTabIds)); + if (!previous || !autoShowFloatingPreview) return; + for (const tabId of serverTabIds) { + if (previous.has(tabId)) continue; + const shown = rightPanelState.surfaces.some( + (surface) => surface.kind === "preview" && surface.resourceId === tabId, + ); + if (!shown && !isLocalServerTab(activeThreadRef, tabId)) { + usePreviewMiniPlayerStore.getState().open(activeThreadRef, browserMiniPlayerSource(tabId)); + } + } + }, [ + activeEnvironmentServerBrowser, + activePreviewState.sessions, + activeThreadRef, + autoShowFloatingPreview, + rightPanelState.surfaces, + ]); // A floating device follows its session: once the agent or another client // closes the device there is nothing left to stream. useEffect(() => { @@ -5653,7 +5690,7 @@ export default function ChatView(props: ChatViewProps) { } }, [activeRightPanelSurface, activeThreadRef]); const togglePreviewPanel = useCallback(() => { - if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; + if (!activeThreadRef || !browserAvailable) return; if (previewPanelOpen) { closePreviewPanel(); return; @@ -5667,6 +5704,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activePreviewState.activeTabId, activeThreadRef, + browserAvailable, closePreviewPanel, createBrowserSurface, previewPanelOpen, @@ -11449,6 +11487,9 @@ export default function ChatView(props: ChatViewProps) { + {activeThreadRef && activeEnvironmentServerBrowser ? ( + + ) : null} {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( ); } + +/** Keeps the thread's preview tabs synced while no browser panel is mounted. */ +function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { + usePreviewSession(props.threadRef); + return null; +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index eb3912fb5b67..3ab821839f8b 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -4,6 +4,7 @@ import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contrac import type { ComposerImageAttachment } from "~/composerDraftStore"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useEnvironmentSupportsServerBrowser } from "~/state/entities"; import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; import { PreviewView } from "./PreviewView"; @@ -28,7 +29,8 @@ export function PreviewPanel({ visible, onSendAnnotation, }: Props) { - if (!isPreviewSupportedInRuntime()) { + const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId); + if (!isPreviewSupportedInRuntime() && !serverBrowser) { return ( diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 810e6d502804..b7fd47f6d78a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -59,6 +59,8 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { Badge } from "~/components/ui/badge"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface"; +import { cn } from "~/lib/utils"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; @@ -150,6 +152,10 @@ export function PreviewView({ : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) : null; const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; + // Server tabs run in the environment's own browser and stream to any client. + const isServerTab = snapshot?.runtime === "server"; + const serverSurfaceRef = useRef(null); + const [serverFrameTabId, setServerFrameTabId] = useState(null); const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; const url = navStatus._tag === "Idle" ? "" : navStatus.url; @@ -159,6 +165,8 @@ export function PreviewView({ const refreshDisabled = navStatus._tag === "Idle"; const isUnreachable = navStatus._tag === "LoadFailed"; const showEmptyState = shouldShowPreviewEmptyState(snapshot); + const serverStreamPending = + isServerTab && !showEmptyState && !isUnreachable && serverFrameTabId !== runtimeTabId; const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); @@ -187,6 +195,11 @@ export function PreviewView({ const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { + if (isServerTab && serverSurfaceRef.current) { + serverSurfaceRef.current.navigate(resolvedUrl); + rememberPreviewUrl(threadRef, resolvedUrl); + return true; + } if (runtimeTabId && previewBridge) { // The bridge mirrors the resolved URL back to the server. await previewBridge.navigate(runtimeTabId, resolvedUrl); @@ -206,7 +219,7 @@ export function PreviewView({ } return result._tag === "Success"; }, - [open, runtimeTabId, threadRef], + [isServerTab, open, runtimeTabId, threadRef], ); const handleSubmitUrl = useCallback( @@ -226,7 +239,11 @@ export function PreviewView({ const handleOpenServerUrl = useCallback( async (next: string) => { try { - const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); + // A server tab's browser runs on the environment, where loopback is already right. + const resolved = + isServerTab || !previewBridge + ? normalizePreviewUrl(next) + : resolveDiscoveredServerUrl(threadRef.environmentId, next); if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } @@ -234,24 +251,25 @@ export function PreviewView({ // Server-side `failed` event renders the unreachable view. } }, - [navigateToResolvedUrl, threadRef], + [isServerTab, navigateToResolvedUrl, threadRef], ); const handleRefresh = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId); - }, [runtimeTabId]); + if (isServerTab) serverSurfaceRef.current?.reload(); + else if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleZoomIn = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); - }, [runtimeTabId]); + if (previewBridge && runtimeTabId && !isServerTab) void previewBridge.zoomIn(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleZoomOut = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId); - }, [runtimeTabId]); + if (previewBridge && runtimeTabId && !isServerTab) void previewBridge.zoomOut(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleResetZoom = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId); - }, [runtimeTabId]); + if (previewBridge && runtimeTabId && !isServerTab) void previewBridge.resetZoom(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleViewportChange = useCallback( async (nextViewport: PreviewViewportSetting) => { @@ -301,12 +319,14 @@ export function PreviewView({ }, [handleViewportChange, runtimeTabId]); const handleBack = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId); - }, [runtimeTabId]); + if (isServerTab) serverSurfaceRef.current?.history(-1); + else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleForward = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId); - }, [runtimeTabId]); + if (isServerTab) serverSurfaceRef.current?.history(1); + else if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId); + }, [isServerTab, runtimeTabId]); const handleOpenInBrowser = useCallback(() => { if (!localApi || !url) return; @@ -712,7 +732,7 @@ export function PreviewView({ > void handleSubmitUrl(next)} onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} - onCapture={previewBridge && tabId ? handleCapture : undefined} + // Capture, annotation, and the more menu drive the desktop webview, so + // server tabs leave them out. Floating works for both. + onCapture={previewBridge && tabId && !isServerTab ? handleCapture : undefined} captureDisabled={!desktopOverlay || isUnreachable} recording={recordingRuntimeTabId !== null} - onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} + onPictureInPicture={ + tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined + } pictureInPicture={miniPlayerTabId === tabId} - pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} - onPickElement={previewBridge && tabId ? handlePickElement : undefined} + pictureInPictureDisabled={ + isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents) + } + onPickElement={previewBridge && tabId && !isServerTab ? handlePickElement : undefined} pickActive={pickActive} // Disable when there's no tab (nothing to pick on) OR the page // failed to load (a React overlay covers the webview, so the @@ -758,7 +784,7 @@ export function PreviewView({ ) : null } trailingActions={ - previewBridge ? ( + previewBridge && !isServerTab ? ( - {runtimeTabId && snapshot && !showEmptyState ? ( - setServerFrameTabId(runtimeTabId)} + // Stays connected under the empty state so a URL picked there reaches the page. + className={cn( + "absolute inset-0 h-full w-full", + (showEmptyState || isUnreachable) && "invisible", + )} /> + ) : runtimeTabId && snapshot && !showEmptyState ? ( + previewBridge ? ( + + ) : ( + + + This tab is open in the T3 Code desktop app. + + + ) ) : null} {showEmptyState ? ( state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); - const sourceSize = resolvePreviewMiniPlayerSourceSize( - snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, - fittedSourceContent, - desktopOverlay?.zoomFactor ?? 1, - ); + // A server tab floats as its streamed page, scaled rather than resized. + const serverTab = snapshot?.runtime === "server"; + const [streamViewport, setStreamViewport] = useState(null); + const serverSurfaceRef = useRef(null); + const serverPictureInPicture = + useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); + const sourceSize = + serverTab && streamViewport + ? streamViewport + : resolvePreviewMiniPlayerSourceSize( + snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, + fittedSourceContent, + desktopOverlay?.zoomFactor ?? 1, + ); const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); @@ -124,6 +148,25 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly }; const toggleNativePictureInPicture = () => { + if (serverTab) { + if (serverPictureInPicture) { + closeServerPictureInPicture(); + return; + } + void openServerPictureInPicture({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + seed: serverSurfaceRef.current?.canvas() ?? null, + }).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to pop out preview", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + return; + } if (!previewBridge) return; const operation = desktopOverlay?.pictureInPicture ? previewBridge.pictureInPicture.close @@ -138,6 +181,8 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly }; if (!snapshot) return null; + const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture); + const canPopOut = serverTab ? supportsServerPictureInPicture() : true; return ( - event.stopPropagation()} - onClick={toggleNativePictureInPicture} - /> - } - > - - - - {desktopOverlay?.pictureInPicture - ? "Close separate window" - : "Pop into separate window"} - - + canPopOut ? ( + + event.stopPropagation()} + onClick={toggleNativePictureInPicture} + /> + } + > + + + + {poppedOut ? "Close separate window" : "Pop into separate window"} + + + ) : null } > - {(frame) => ( - <> - - {!desktopOverlay?.hasWebContents ? ( - - Reconnecting preview… - - ) : null} - > - )} + {(frame) => + serverTab ? ( + + + + ) : ( + <> + + {!desktopOverlay?.hasWebContents ? ( + + Reconnecting preview… + + ) : null} + > + ) + } ); } @@ -285,6 +346,21 @@ function MiniPlayerShell({ }) { const canvas = useChatCanvas(); const gestureRef = useRef(null); + // Touch has no hover, so tapping the handle toggles the pill instead. The + // toggle waits for the tap's click, which would otherwise land on the pill. + const [pillOpen, setPillOpen] = useState(false); + const handleTappedRef = useRef(false); + const handleRef = useRef(null); + // The pill covers the handle, so a tap anywhere else dismisses it. + useEffect(() => { + if (!pillOpen) return; + const dismiss = (event: PointerEvent) => { + if (event.target instanceof Node && handleRef.current?.contains(event.target)) return; + setPillOpen(false); + }; + document.addEventListener("pointerdown", dismiss, true); + return () => document.removeEventListener("pointerdown", dismiss, true); + }, [pillOpen]); const container = canvas?.container ?? null; const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); @@ -326,10 +402,12 @@ function MiniPlayerShell({ if (event.button !== 0 || !frame) return; gestureRef.current = { pointerId: event.pointerId, + pointerType: event.pointerType, pointerX: event.clientX, pointerY: event.clientY, frame, direction, + moved: false, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); @@ -340,6 +418,8 @@ function MiniPlayerShell({ const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId || !container) return; const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; + if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; + gesture.moved = true; const store = usePreviewMiniPlayerStore.getState(); if (gesture.direction === null) { store.move( @@ -366,8 +446,14 @@ function MiniPlayerShell({ }; const endGesture = (event: ReactPointerEvent) => { - if (gestureRef.current?.pointerId !== event.pointerId) return; + const gesture = gestureRef.current; + if (gesture?.pointerId !== event.pointerId) return; gestureRef.current = null; + handleTappedRef.current = + event.type === "pointerup" && + gesture.direction === null && + gesture.pointerType === "touch" && + !gesture.moved; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -389,18 +475,25 @@ function MiniPlayerShell({ }} > beginGesture(event, null)} onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onClick={() => { + if (!handleTappedRef.current) return; + handleTappedRef.current = false; + setPillOpen((open) => !open); + }} > - + {recording ? ( diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index 5d2b78ce4cc5..b20a188791f9 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -55,6 +55,8 @@ describe("addBrowserSurface", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: "profile-work", + // No Electron bridge here, so the environment hosts the tab. + runtime: "server", }); }); @@ -71,6 +73,7 @@ describe("addBrowserSurface", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, + runtime: "server", }); expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); expect( diff --git a/apps/web/src/components/preview/localServerTabs.ts b/apps/web/src/components/preview/localServerTabs.ts new file mode 100644 index 000000000000..106b44e04240 --- /dev/null +++ b/apps/web/src/components/preview/localServerTabs.ts @@ -0,0 +1,37 @@ +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +/** + * Server tabs this client opened, so the floating player pops up only for tabs + * an agent opens. A tab can reach the session stream before its open call + * returns, so an open still in flight counts for its whole thread. + */ +const pendingOpens = new Map(); +const openedTabs = new Set(); + +const tabKey = (threadKey: string, tabId: string) => JSON.stringify([threadKey, tabId]); + +export async function trackLocalServerTabOpen( + threadRef: ScopedThreadRef, + open: () => Promise, +): Promise { + const threadKey = scopedThreadKey(threadRef); + pendingOpens.set(threadKey, (pendingOpens.get(threadKey) ?? 0) + 1); + try { + const result = await open(); + if (result._tag === "Success" && "value" in result) { + const snapshot = result.value as PreviewSessionSnapshot; + openedTabs.add(tabKey(threadKey, snapshot.tabId)); + } + return result; + } finally { + const remaining = (pendingOpens.get(threadKey) ?? 1) - 1; + if (remaining > 0) pendingOpens.set(threadKey, remaining); + else pendingOpens.delete(threadKey); + } +} + +export function isLocalServerTab(threadRef: ScopedThreadRef, tabId: string): boolean { + const threadKey = scopedThreadKey(threadRef); + return openedTabs.has(tabKey(threadKey, tabId)) || pendingOpens.has(threadKey); +} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 288db101e7a5..09bf8e6548c1 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -9,13 +9,17 @@ import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/op import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; +import { previewBridge } from "./previewBridge"; export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; }): Promise> { - const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + // Without the desktop bridge the tab runs on the environment, where loopback is already right. + const resolvedUrl = previewBridge + ? resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url) + : input.port.url; const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 70751aef8342..d3b4fcb8e9b0 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -62,6 +62,8 @@ describe("openPreviewSession", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, + // No Electron bridge here, so the environment hosts the tab. + runtime: "server", }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); @@ -81,6 +83,7 @@ describe("openPreviewSession", () => { url: "t3.chat", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, + runtime: "server", }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); @@ -135,6 +138,7 @@ describe("openPreviewSession", () => { url: input.url, viewport, profileId: "work", + runtime: "server", }, }); }, diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 1b2f31a85da8..6a7f4f139b9c 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -17,6 +17,9 @@ import { import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; +import { trackLocalServerTabOpen } from "./localServerTabs"; +import { previewBridge } from "./previewBridge"; + interface OpenPreviewSessionInput { openPreview: (input: { readonly environmentId: EnvironmentId; @@ -41,15 +44,22 @@ export async function openPreviewSession( if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - ...(input.url === undefined ? {} : { url: input.url }), - viewport: input.viewport ?? browserDefaultOpenViewport(defaults), - profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), - }, - }); + const open = () => + input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + ...(input.url === undefined ? {} : { url: input.url }), + viewport: input.viewport ?? browserDefaultOpenViewport(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), + // Only Electron can host a desktop tab; every other client asks the + // environment to run the page in its own browser. + ...(previewBridge ? {} : { runtime: "server" as const }), + }, + }); + const result = previewBridge + ? await open() + : await trackLocalServerTabOpen(input.threadRef, open); if (result._tag === "Failure") { return result; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0a0b1adee06b..d92951b11376 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -7,7 +7,7 @@ import { ThreadRouteView } from "../components/ThreadRouteView"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; -import { useProjects } from "../state/entities"; +import { readEnvironmentSupportsServerBrowser, useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; @@ -141,7 +141,10 @@ function ChatRouteGlobalShortcuts() { event.preventDefault(); event.stopPropagation(); if (!routeThreadRef) return; - if (!isPreviewSupportedInRuntime()) { + if ( + !isPreviewSupportedInRuntime() && + !readEnvironmentSupportsServerBrowser(routeThreadRef.environmentId) + ) { toastManager.add( stackedThreadToast({ type: "info", diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index ad20c3f3dd83..aa9b650fb5b5 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -201,6 +201,23 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } +/** Whether the environment hosts preview tabs in its own browser (`runtime: "server"`), + so clients without Electron can still use the Browser panel. */ +export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.serverBrowser === true + ); +} + +export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .serverBrowser === true + ); +} + export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { return ( appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/apps/web/src/state/previewStream.ts b/apps/web/src/state/previewStream.ts new file mode 100644 index 000000000000..b76da4867d0a --- /dev/null +++ b/apps/web/src/state/previewStream.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream"; +import { + type DeviceHubAccess, + resolveDeviceHubAccess, +} from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSession } from "./session"; + +/** + * Stream access for server-hosted preview tabs on one environment. Same ticket + * flow as the device hub; a socket refused for credentials refreshes this atom + * and reconnects. Keyed on the prepared connection so a re-pair produces new + * credentials. + */ +const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + if (prepared === null) return Effect.never; + return resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); + }) + .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`preview-stream-access:${environmentId}`)), +); + +export function usePreviewStreamAccess(environmentId: EnvironmentId): DeviceHubAccess | null { + const result = useAtomValue(previewStreamAccessAtom(environmentId)); + return AsyncResult.isSuccess(result) ? result.value : null; +} + +export function refreshPreviewStreamAccess(environmentId: EnvironmentId): void { + appAtomRegistry.refresh(previewStreamAccessAtom(environmentId)); +} + +/** Fresh stream access for a long-lived viewer outside React, e.g. system picture in picture. */ +export function readPreviewStreamAccess( + environmentId: EnvironmentId, + refresh: boolean, +): Promise { + const atom = previewStreamAccessAtom(environmentId); + if (refresh) appAtomRegistry.refresh(atom); + return Effect.runPromise( + AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => null), + ), + ); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index a5f8bbae3af2..1d3c2d6327c1 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -172,6 +172,23 @@ running is left alone. For Antigravity's Google callback on a remote host, see [remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device). +## Browser on a remote environment + +On an environment started with `t3 serve` or `npx t3`, browser tabs run on the +host instead of your device. You can watch and use them from a phone or any +other browser. Agents keep using them while no device is connected, and +`localhost` addresses reach servers on the host. The desktop app's own +environment keeps using the desktop browser. + +The first tab downloads a headless Chromium and fonts, about 120 MB, into the +T3 home. To use a browser already installed on the host, set +`T3CODE_PREVIEW_BROWSER_PATH` to its executable. Set `T3CODE_SERVER_BROWSER=0` +to turn host browser tabs off. + +On a phone, tap the floating preview's corner dot to show its controls, then +**Pop into separate window** to keep watching in picture-in-picture over other +apps. + ## Manage or revoke access On the host, **Settings → Connections** lets authorized administrators create diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 13cfabf182b8..5ede41722e60 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,10 @@ "types": "./src/device/hubAccess.ts", "default": "./src/device/hubAccess.ts" }, + "./preview/server-browser-stream": { + "types": "./src/preview/serverBrowserStream.ts", + "default": "./src/preview/serverBrowserStream.ts" + }, "./worktree-setup": { "types": "./src/worktreeSetup.ts", "default": "./src/worktreeSetup.ts" diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts new file mode 100644 index 000000000000..c8e86cbec21a --- /dev/null +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -0,0 +1,281 @@ +// @effect-diagnostics globalTimers:off - This browser and WebView transport runs without an Effect runtime. +/** + * Viewer socket for a server-hosted preview tab (`runtime: "server"`). + * + * The environment runs the page in headless Chromium and streams it over + * `/api/preview-stream/ws`. Binary messages are complete JPEG frames; text + * messages report the page viewport in CSS px. Input goes back as JSON in page + * CSS px. The server keeps one frame in flight per socket, so a slow viewer + * gets fewer frames instead of a growing buffer. + * + * The socket reconnects with backoff while the owner keeps it running. A + * refused upgrade stops it and asks the owner for fresh credentials. + */ +import { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts"; + +export const PREVIEW_STREAM_BASE_PATH = "/api/preview-stream"; + +export interface PreviewStreamViewport { + readonly width: number; + readonly height: number; +} + +export type PreviewStreamMouseButton = "none" | "left" | "middle" | "right"; + +/** Client-to-server messages. Coordinates are page CSS px. */ +export type PreviewStreamInput = + | { + readonly type: "mouse"; + readonly action: "move" | "down" | "up"; + readonly x: number; + readonly y: number; + readonly button: PreviewStreamMouseButton; + /** Pressed buttons: left 1, right 2, middle 4. */ + readonly buttons: number; + readonly clickCount: number; + readonly modifiers: number; + } + | { + readonly type: "wheel"; + readonly x: number; + readonly y: number; + readonly deltaX: number; + readonly deltaY: number; + readonly modifiers: number; + } + | { + readonly type: "key"; + readonly action: "down" | "up"; + readonly key: string; + readonly code: string; + /** Windows virtual key code (DOM `keyCode`); Enter, Backspace, and arrows need it. */ + readonly keyCode?: number; + /** Only for printable keys without Ctrl or Meta. */ + readonly text?: string; + readonly modifiers: number; + } + | { readonly type: "text"; readonly text: string } + | { readonly type: "resize"; readonly width: number; readonly height: number } + | { readonly type: "navigate"; readonly url: string } + | { readonly type: "history"; readonly delta: -1 | 1 } + | { readonly type: "reload" } + /** Asks whether the page point takes text. Touch viewers send it on touch start. */ + | { readonly type: "probe"; readonly x: number; readonly y: number }; + +/** Answer to a `probe`, echoing its point. */ +export interface PreviewStreamProbe { + readonly x: number; + readonly y: number; + readonly editable: boolean; +} + +/** Close code for a tab that no longer exists. The client stops instead of retrying. */ +export const PREVIEW_STREAM_TAB_GONE_CODE = 4404; + +/** CDP modifier bitmask: Alt 1, Ctrl 2, Meta 4, Shift 8. */ +export const previewStreamModifiers = (event: { + readonly altKey: boolean; + readonly ctrlKey: boolean; + readonly metaKey: boolean; + readonly shiftKey: boolean; +}): number => + (event.altKey ? 1 : 0) | + (event.ctrlKey ? 2 : 0) | + (event.metaKey ? 4 : 0) | + (event.shiftKey ? 8 : 0); + +export interface PreviewStreamTarget { + readonly access: DeviceHubAccess; + readonly threadId: string; + readonly tabId: string; + /** Viewer backing-store size in device px. The server never sends larger frames. */ + readonly maxWidth: number; + readonly maxHeight: number; +} + +export interface PreviewStreamEvents { + /** One complete JPEG frame. */ + readonly onFrame: (jpeg: ArrayBuffer) => void; + readonly onViewport: (viewport: PreviewStreamViewport) => void; + readonly onProbe?: (probe: PreviewStreamProbe) => void; + /** Input sent while disconnected is dropped. */ + readonly onConnectedChange: (connected: boolean) => void; + /** The upgrade was refused; refresh access and start a new client. */ + readonly onUnauthorized: () => void; + /** The tab was closed on the server. The client has stopped. */ + readonly onGone?: () => void; +} + +export interface PreviewStreamClient { + /** False when the socket is not open and the message was dropped. */ + readonly send: (input: PreviewStreamInput) => boolean; + readonly stop: () => void; +} + +const RETRY_BASE_MS = 500; +const RETRY_MAX_MS = 10_000; + +const readMessage = (data: string): Record | null => { + try { + const message: unknown = JSON.parse(data); + return typeof message === "object" && message !== null + ? (message as Record) + : null; + } catch { + return null; + } +}; + +export function createPreviewStreamClient( + target: PreviewStreamTarget, + events: PreviewStreamEvents, +): PreviewStreamClient { + const query = new URLSearchParams({ + threadId: target.threadId, + tabId: target.tabId, + maxWidth: String(Math.max(1, Math.round(target.maxWidth))), + maxHeight: String(Math.max(1, Math.round(target.maxHeight))), + }); + const url = withDeviceHubQuery(`${target.access.wsBase}/ws?${query.toString()}`, target.access); + let stopped = false; + let socket: WebSocket | null = null; + let retryTimer: ReturnType | null = null; + let failures = 0; + + const connect = () => { + if (stopped) return; + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + socket = ws; + let opened = false; + ws.addEventListener("open", () => { + if (socket !== ws) return; + opened = true; + events.onConnectedChange(true); + }); + ws.addEventListener("message", (event) => { + if (socket !== ws) return; + if (event.data instanceof ArrayBuffer) { + failures = 0; + events.onFrame(event.data); + return; + } + if (typeof event.data !== "string") return; + const message = readMessage(event.data); + const { type, x, y, width, height, editable } = message ?? {}; + if (type === "viewport" && typeof width === "number" && typeof height === "number") { + failures = 0; + events.onViewport({ width, height }); + } else if ( + type === "probe" && + typeof x === "number" && + typeof y === "number" && + typeof editable === "boolean" + ) { + events.onProbe?.({ x, y, editable }); + } + }); + ws.addEventListener("close", (event) => { + if (socket !== ws) return; + socket = null; + if (opened) events.onConnectedChange(false); + if (stopped) return; + if (event.code === PREVIEW_STREAM_TAB_GONE_CODE) { + stopped = true; + events.onGone?.(); + return; + } + // A rejected upgrade surfaces as 1006 before open, including an expired ticket. + if ( + event.code === 1008 || + event.code === 4401 || + (!opened && event.code === 1006 && target.access.query.wsTicket !== undefined) + ) { + stopped = true; + events.onUnauthorized(); + return; + } + const delay = Math.min(RETRY_BASE_MS * 2 ** failures, RETRY_MAX_MS); + failures += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + connect(); + }, delay); + }); + ws.addEventListener("error", () => ws.close()); + }; + + connect(); + + return { + send: (input) => { + if (socket?.readyState !== WebSocket.OPEN) return false; + socket.send(JSON.stringify(input)); + return true; + }, + stop: () => { + stopped = true; + if (retryTimer !== null) clearTimeout(retryTimer); + retryTimer = null; + const ws = socket; + socket = null; + ws?.close(); + }, + }; +} + +export interface PreviewFramePainter { + readonly paint: (jpeg: ArrayBuffer) => void; + readonly stop: () => void; +} + +/** + * Draws stream frames into a canvas sized to each frame. One decode runs at a + * time; a frame that lands mid-decode replaces the waiting one, so a slow + * device skips frames instead of queueing them. + */ +export function createPreviewFramePainter( + canvas: HTMLCanvasElement, + onPainted?: () => void, +): PreviewFramePainter { + const context = canvas.getContext("2d"); + let stopped = false; + let decoding = false; + let waiting: ArrayBuffer | null = null; + const draw = (jpeg: ArrayBuffer) => { + decoding = true; + void createImageBitmap(new Blob([jpeg], { type: "image/jpeg" })) + .then((bitmap) => { + if (stopped || !context) { + bitmap.close(); + return; + } + if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + } + context.drawImage(bitmap, 0, 0); + bitmap.close(); + onPainted?.(); + }) + // A frame that fails to decode is skipped; the next one repaints. + .catch(() => undefined) + .finally(() => { + decoding = false; + const next = waiting; + waiting = null; + if (next && !stopped) draw(next); + }); + }; + return { + paint: (jpeg) => { + if (stopped) return; + if (decoding) waiting = jpeg; + else draw(jpeg); + }, + stop: () => { + stopped = true; + waiting = null; + }, + }; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7cd4654ba053..c9ac7482d4cc 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -208,6 +208,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ desktop servers whose app predates the remote trigger, where clients must keep telling the user to update the app on that machine. */ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** Server hosts preview tabs in its own headless Chromium (`runtime: + "server"`) and streams them over `/api/preview-stream`. Clients + without a local browser runtime open server tabs here. */ + serverBrowser: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 6e4154301a19..9a8dc8858915 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -162,6 +162,15 @@ export const PreviewNavStatus = Schema.Union([ ]); export type PreviewNavStatus = typeof PreviewNavStatus.Type; +/** + * Where a tab's page runs. `desktop` is an Electron owned by one + * desktop client; `server` is headless Chromium owned by the environment + * server, viewed by any client through `/api/preview-stream` and driven by + * agents with no client attached. Absent means `desktop`. + */ +export const PreviewRuntime = Schema.Literals(["desktop", "server"]); +export type PreviewRuntime = typeof PreviewRuntime.Type; + export const PreviewSessionSnapshot = Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: PreviewTabId, @@ -176,6 +185,7 @@ export const PreviewSessionSnapshot = Schema.Struct({ * switching would require tearing the guest down and losing page state. */ profileId: Schema.optional(BrowserProfileId), + runtime: Schema.optional(PreviewRuntime), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; @@ -193,6 +203,8 @@ export const PreviewOpenInput = Schema.Struct({ viewport: Schema.optional(PreviewViewportSetting), /** Omit to open under the client's configured default profile. */ profileId: Schema.optional(BrowserProfileId), + /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */ + runtime: Schema.optional(PreviewRuntime), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 274414b26bbf..7163ba7b1e79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -564,6 +564,9 @@ importers: node-pty: specifier: ^1.2.0-beta.15 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414) + playwright-core: + specifier: 1.60.0 + version: 1.60.0 proper-lockfile: specifier: 4.1.2 version: 4.1.2 From 5797b04d05ba8e940019645207f0d5983724681a Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:51:14 +0000 Subject: [PATCH 02/68] fix(preview): bound streams and preserve server browser interactions --- .../browser/BrowserPreviewRouteScreen.tsx | 15 +- .../features/browser/PreviewStreamWebView.tsx | 29 +- .../features/browser/ThreadBrowserFloat.tsx | 4 +- .../browser/preview-stream.browser.ts | 220 ++++++++-- apps/mobile/src/state/preview.ts | 51 ++- .../server/src/mcp/PreviewAutomationBroker.ts | 4 +- apps/server/src/preview/Manager.ts | 8 + apps/server/src/preview/ServerBrowser.ts | 406 +++++++++++------- apps/server/src/preview/ServerBrowserPage.ts | 160 +++++-- .../server/src/preview/ServerBrowserStream.ts | 60 ++- .../src/preview/ServerBrowserToolchain.ts | 30 +- apps/web/src/browser/ServerBrowserSurface.tsx | 157 +++++-- apps/web/src/browser/browserLinkTarget.ts | 10 +- apps/web/src/browser/openFileInPreview.ts | 43 +- apps/web/src/browser/previewRuntime.ts | 18 + .../web/src/browser/serverPictureInPicture.ts | 25 +- apps/web/src/browser/useOpenLink.ts | 2 +- apps/web/src/components/ChatMarkdown.tsx | 6 +- apps/web/src/components/ChatView.tsx | 34 +- .../src/components/files/FilePreviewPanel.tsx | 4 +- .../preview/addBrowserSurface.test.ts | 3 - .../src/components/preview/localServerTabs.ts | 37 -- .../components/preview/openDiscoveredPort.ts | 11 +- .../preview/openPreviewSession.test.ts | 4 - .../components/preview/openPreviewSession.ts | 31 +- .../preview/openTerminalLinkInPreview.ts | 7 +- apps/web/src/previewStateStore.ts | 8 +- .../src/preview/serverBrowserStream.ts | 7 +- packages/contracts/src/preview.ts | 4 + 29 files changed, 977 insertions(+), 421 deletions(-) create mode 100644 apps/web/src/browser/previewRuntime.ts delete mode 100644 apps/web/src/components/preview/localServerTabs.ts diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index 27fffbafc67e..68f7c3a9d5f9 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -88,6 +88,8 @@ function BrowserPreviewScreen({ const [foreground, setForeground] = useState(AppState.currentState !== "background"); const [selectedTabId, setSelectedTabId] = useState(initialTabId); const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE); + // Address bar commands only reach a page that is streaming. + const [streaming, setStreaming] = useState(false); const streamRef = useRef(null); const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true }); const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs); @@ -172,6 +174,7 @@ function BrowserPreviewScreen({ streamRef.current?.command(input)} /> {live ? ( @@ -182,6 +185,7 @@ function BrowserPreviewScreen({ tabId={tab.tabId} streamRef={streamRef} onPictureInPicture={onPictureInPicture} + onStreamingChange={setStreaming} /> ) : ( @@ -200,9 +204,11 @@ function BrowserPreviewScreen({ function BrowserAddressBar({ tab, + ready, onCommand, }: { readonly tab: PreviewSessionSnapshot; + readonly ready: boolean; readonly onCommand: PreviewStreamRef["command"]; }) { const url = browserTabUrl(tab); @@ -222,17 +228,18 @@ function BrowserAddressBar({ onCommand({ type: "history", delta: -1 })} /> onCommand({ type: "history", delta: 1 })} /> setDraft(null)} @@ -249,6 +256,7 @@ function BrowserAddressBar({ onCommand({ type: "reload" })} /> @@ -261,12 +269,14 @@ function OpenBrowserPreview({ tabId, streamRef, onPictureInPicture, + onStreamingChange, }: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly tabId: string; readonly streamRef: RefObject; readonly onPictureInPicture: (state: PreviewPictureInPictureState, detail?: string) => void; + readonly onStreamingChange: (streaming: boolean) => void; }) { const { themeVariables } = useAppearancePreferences(); const { access, error, refresh } = usePreviewStreamAccess(environmentId); @@ -284,6 +294,7 @@ function OpenBrowserPreview({ background={themeVariables["--color-sheet-solid"]} onUnauthorized={onUnauthorized} onPictureInPicture={onPictureInPicture} + onStreamingChange={onStreamingChange} /> ) : ( diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 772305a9e1d1..eb7ff27d34c5 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -40,11 +40,15 @@ type NativeStreamBridge = { readonly onGone?: () => void; readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void; readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; + /** True while frames show, so commands reach the page. Pass a stable function. */ + readonly onStreamingChange?: (streaming: boolean) => void; /** The floating player shows a spinner without text or a reconnect button. */ readonly compact?: boolean; }; -const UNAUTHORIZED_RETRY_MAX_MS = 30_000; +// Consecutive refused tickets before the view stops and offers Reconnect, e.g. a +// session without operate scope. +const MAX_REFUSALS = 3; /** A server-hosted preview tab streamed into a WebView that runs the shared transport. */ export function PreviewStreamWebView({ @@ -78,10 +82,11 @@ export function PreviewStreamWebView({ onGone={props.onGone} onViewport={props.onViewport} onPictureInPicture={props.onPictureInPicture} + onStreamingChange={props.onStreamingChange} onUnauthorized={() => { - // The client has stopped. Restart it, and back off when fresh tickets keep - // getting refused, e.g. a session without operate scope. - const retry = unauthorized.current++; + // The client has stopped. Restart it with a fresh ticket, backing off between refusals. + const refusals = ++unauthorized.current; + if (refusals >= MAX_REFUSALS) return false; if (refreshTimer.current !== null) clearTimeout(refreshTimer.current); refreshTimer.current = setTimeout( () => { @@ -89,11 +94,13 @@ export function PreviewStreamWebView({ setAttempt((current) => current + 1); props.onUnauthorized(); }, - retry === 0 ? 0 : Math.min(1_000 * 2 ** retry, UNAUTHORIZED_RETRY_MAX_MS), + refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), ); + return true; }} onRetry={() => { processRetried.current = false; + unauthorized.current = 0; setAttempt((current) => current + 1); props.onUnauthorized(); }} @@ -120,13 +127,16 @@ function PreviewStreamDocumentView({ onGone, onViewport, onPictureInPicture, + onStreamingChange, onRetry, onStreaming, onRecoverProcess, -}: Omit & { +}: Omit & { readonly configuration: string; readonly background: string; readonly compact: boolean; + /** False when the view should stop retrying and fail. */ + readonly onUnauthorized: () => boolean; readonly onRetry: () => void; readonly onStreaming: () => void; readonly onRecoverProcess: () => boolean; @@ -142,6 +152,7 @@ function PreviewStreamDocumentView({ if (!active.current || failed.current) return; failed.current = true; webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); + onStreamingChange?.(false); setError(message); setStatus("error"); }; @@ -177,6 +188,7 @@ function PreviewStreamDocumentView({ view?.injectJavaScript("window.T3PreviewStream?.stop(); true;"); }; }, []); + useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]); const processTerminated = () => { if (!active.current || failed.current) return; if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again."); @@ -211,7 +223,9 @@ function PreviewStreamDocumentView({ if (message === null) return; switch (message.type) { case "unauthorized": - onUnauthorized(); + if (!onUnauthorized()) { + fail("This session can't open the browser stream. Reconnect to try again."); + } return; case "gone": setGone(true); @@ -231,6 +245,7 @@ function PreviewStreamDocumentView({ return; } setStatus(message.status); + onStreamingChange?.(message.status === "streaming"); if (message.status === "streaming") onStreaming(); } }} diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index 68624c2c7dc7..c33127a2226b 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -35,8 +35,8 @@ export function ThreadBrowserFloat(props: { known.current = new Set(ids); // The first list is a baseline, so reopening a thread does not resurface old tabs. if (previous === null) return; - const opened = ids.findLast((id) => !previous.has(id)); - if (opened !== undefined) setTabId(opened); + const opened = props.tabs.findLast((tab) => tab.reveal === true && !previous.has(tab.tabId)); + if (opened !== undefined) setTabId(opened.tabId); }, [props.loaded, props.tabs]); const focused = useIsFocused(); const [foreground, setForeground] = useState(AppState.currentState !== "background"); diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 728d6d31e47e..f88aa0895fd9 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -17,12 +17,14 @@ declare global { /** WebKit's presentation API, the only picture in picture entry on older iOS. */ interface PresentationVideo extends HTMLVideoElement { - webkitSupportsPresentationMode?: (mode: string) => boolean; webkitSetPresentationMode?: (mode: "inline" | "picture-in-picture") => void; webkitPresentationMode?: string; } type WheelInput = Extract; +type MouseInput = Extract; +const mouseButton = (button: number): MouseInput["button"] => + button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none"; const RESIZE_DEBOUNCE_MS = 150; const TAP_SLOP_PX = 8; @@ -31,6 +33,13 @@ const MULTI_CLICK_SLOP_PX = 4; const WHEEL_LINE_PX = 16; // JPEG frames past 2x cost bandwidth without a visible gain on a phone. const MAX_PIXEL_RATIO = 2; +// A tap this close to an answered probe, this soon, reuses its answer. On a slow +// link the answer lands after touch end, too late for iOS to raise the keyboard. +const PROBE_REUSE_PX = 24; +const PROBE_REUSE_MS = 10_000; +// Kept in the hidden textarea so a soft keyboard's backspace has something to +// delete and fires `input`; Gboard's keydown carries keyCode 229 and no key. +const SENTINEL = "\u200b"; interface Viewer { readonly stop: () => void; @@ -133,8 +142,27 @@ export function start(configuration: PreviewStreamConfiguration) { let resizeTimer: ReturnType | null = null; let wheelFrame: number | null = null; let pendingWheel: WheelInput | null = null; - // Whether the page point under a touch takes text; null until the server answers. - let probe: { x: number; y: number; editable: boolean | null; tapped: boolean } | null = null; + let mouseFrame: number | null = null; + let pendingMouse: MouseInput | null = null; + let mousePressed: MouseInput["button"] | null = null; + // The latest probe. `editable` is the answer its tap acts on, null until known; + // `end` records what touch end did, so a late answer can still act or correct it. + let probe: { + readonly x: number; + readonly y: number; + readonly clientX: number; + readonly clientY: number; + editable: boolean | null; + end: "touching" | "acted" | "late" | "panned"; + } | null = null; + let probeCache: { + readonly clientX: number; + readonly clientY: number; + readonly editable: boolean; + readonly time: number; + } | null = null; + // A keydown already sent this key; its `input` must not send it again. + let keySent = false; let touch: { pointerId: number; startX: number; @@ -143,7 +171,13 @@ export function start(configuration: PreviewStreamConfiguration) { lastY: number; panning: boolean; } | null = null; - let lastTap: { time: number; x: number; y: number; count: number } | null = null; + let lastTap: { + time: number; + x: number; + y: number; + count: number; + button: MouseInput["button"]; + } | null = null; const reportStatus = (status: "connecting" | "streaming") => { streaming = status === "streaming"; @@ -175,10 +209,21 @@ export function start(configuration: PreviewStreamConfiguration) { onProbe: (result) => { const current = probe; if (!current || current.x !== result.x || current.y !== result.y) return; - current.editable = result.editable; - if (!current.tapped) return; - // Late answer: Android still raises the keyboard; iOS waits for the next tap. + probeCache = { + clientX: current.clientX, + clientY: current.clientY, + editable: result.editable, + time: performance.now(), + }; + if (current.end === "touching") { + current.editable = result.editable; + return; + } probe = null; + if (current.end === "panned") return; + if (current.end === "acted" && current.editable === result.editable) return; + // Late or corrected answer: Android still raises the keyboard; iOS waits + // for the next tap, which can reuse this answer. if (result.editable) input.focus({ preventScroll: true }); else input.blur(); }, @@ -276,9 +321,47 @@ export function start(configuration: PreviewStreamConfiguration) { wheelFrame ??= requestAnimationFrame(flushWheel); }; + const flushMouse = () => { + if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); + mouseFrame = null; + if (pendingMouse) send(pendingMouse); + pendingMouse = null; + }; + const countClick = (event: PointerEvent, button: MouseInput["button"]) => { + const last = lastTap; + const count = + last && + last.button === button && + event.timeStamp - last.time < MULTI_CLICK_MS && + Math.hypot(event.clientX - last.x, event.clientY - last.y) < MULTI_CLICK_SLOP_PX + ? last.count + 1 + : 1; + lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count, button }; + return count; + }; const onPointerDown = (event: PointerEvent) => { if (!event.isPrimary) return; event.preventDefault(); + if (event.pointerType !== "touch") { + const point = pagePoint(event.clientX, event.clientY, false); + if (!point) return; + canvas.setPointerCapture(event.pointerId); + input.focus({ preventScroll: true }); + flushMouse(); + flushWheel(); + mousePressed = mouseButton(event.button); + send({ + type: "mouse", + action: "down", + x: point.x, + y: point.y, + button: mousePressed, + buttons: event.buttons, + clickCount: countClick(event, mousePressed), + modifiers: previewStreamModifiers(event), + }); + return; + } touch = { pointerId: event.pointerId, startX: event.clientX, @@ -288,10 +371,43 @@ export function start(configuration: PreviewStreamConfiguration) { panning: false, }; const point = pagePoint(event.clientX, event.clientY, false); - probe = point ? { x: point.x, y: point.y, editable: null, tapped: false } : null; - if (point) send({ type: "probe", x: point.x, y: point.y }); + if (!point) { + probe = null; + return; + } + const cached = probeCache; + const reuse = + cached !== null && + performance.now() - cached.time < PROBE_REUSE_MS && + Math.hypot(event.clientX - cached.clientX, event.clientY - cached.clientY) <= PROBE_REUSE_PX; + probe = { + x: point.x, + y: point.y, + clientX: event.clientX, + clientY: event.clientY, + editable: reuse ? cached.editable : null, + end: "touching", + }; + // Sent even with a cached answer, so the next tap reuses a fresh one. + send({ type: "probe", x: point.x, y: point.y }); }; const onPointerMove = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + const point = pagePoint(event.clientX, event.clientY, mousePressed !== null); + if (!point) return; + pendingMouse = { + type: "mouse", + action: "move", + x: point.x, + y: point.y, + button: mousePressed ?? "none", + buttons: event.buttons, + clickCount: 0, + modifiers: previewStreamModifiers(event), + }; + mouseFrame ??= requestAnimationFrame(flushMouse); + return; + } if (!touch || touch.pointerId !== event.pointerId) return; if ( !touch.panning && @@ -312,13 +428,36 @@ export function start(configuration: PreviewStreamConfiguration) { touch.lastX = event.clientX; touch.lastY = event.clientY; }; + const releaseMouse = (event: PointerEvent, cancelled: boolean) => { + const button = mousePressed; + if (button === null) return; + mousePressed = null; + flushMouse(); + flushWheel(); + const point = pagePoint(event.clientX, event.clientY, true); + if (!point) return; + send({ + type: "mouse", + action: "up", + x: point.x, + y: point.y, + button, + buttons: cancelled ? 0 : event.buttons, + clickCount: lastTap?.count ?? 1, + modifiers: previewStreamModifiers(event), + }); + }; const onPointerUp = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + releaseMouse(event, false); + return; + } const ended = touch; if (!ended || ended.pointerId !== event.pointerId) return; touch = null; const answered = probe; if (ended.panning) { - probe = null; + if (answered) answered.end = "panned"; return; } const point = pagePoint(event.clientX, event.clientY, false); @@ -326,24 +465,22 @@ export function start(configuration: PreviewStreamConfiguration) { // Focusing inside the tap's user activation is what lets iOS raise the keyboard. if (answered?.editable === true) input.focus({ preventScroll: true }); else if (answered?.editable === false) input.blur(); - if (answered?.editable === null) answered.tapped = true; - else probe = null; + if (answered) answered.end = answered.editable === null ? "late" : "acted"; flushWheel(); - const last = lastTap; - const clickCount = - last && - event.timeStamp - last.time < MULTI_CLICK_MS && - Math.hypot(event.clientX - last.x, event.clientY - last.y) < MULTI_CLICK_SLOP_PX - ? last.count + 1 - : 1; - lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count: clickCount }; + const clickCount = countClick(event, "left"); const at = { x: point.x, y: point.y, modifiers: 0 }; send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 }); send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount }); send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); }; const onPointerCancel = (event: PointerEvent) => { - if (touch?.pointerId === event.pointerId) touch = null; + if (event.pointerType !== "touch") { + releaseMouse(event, true); + return; + } + if (touch?.pointerId !== event.pointerId) return; + touch = null; + if (probe) probe.end = "panned"; }; // Trackpads and mice on tablets scroll with wheel events. const onWheel = (event: WheelEvent) => { @@ -389,18 +526,38 @@ export function start(configuration: PreviewStreamConfiguration) { ...(action === "down" && text !== undefined ? { text } : {}), modifiers: previewStreamModifiers(event), }); + // Some Android keyboards edit the textarea even when keydown is prevented. + // Shortcuts keep their default, so a paste still arrives as text. + keySent = action === "down" && !shortcut; if (!shortcut) event.preventDefault(); }; const onKeyDown = (event: KeyboardEvent) => onKey("down", event); const onKeyUp = (event: KeyboardEvent) => onKey("up", event); + const resetInput = () => { + input.value = SENTINEL; + input.setSelectionRange(SENTINEL.length, SENTINEL.length); + }; + const pressKey = (key: "Backspace" | "Delete", keyCode: number) => { + for (const action of ["down", "up"] as const) { + send({ type: "key", action, key, code: key, keyCode, modifiers: 0 }); + } + }; const onInput = (event: Event) => { if (event instanceof InputEvent && event.isComposing) return; - if (input.value) send({ type: "text", text: input.value }); - input.value = ""; + const inputType = event instanceof InputEvent ? event.inputType : ""; + if (keySent) keySent = false; + else if (inputType === "deleteContentBackward") pressKey("Backspace", 8); + else if (inputType === "deleteContentForward") pressKey("Delete", 46); + else { + const text = input.value.replaceAll(SENTINEL, ""); + if (text) send({ type: "text", text }); + } + resetInput(); }; const onCompositionEnd = (event: CompositionEvent) => { - if (event.data) send({ type: "text", text: event.data }); - input.value = ""; + const text = event.data.replaceAll(SENTINEL, ""); + if (text) send({ type: "text", text }); + resetInput(); }; if (interactive) { @@ -415,17 +572,21 @@ export function start(configuration: PreviewStreamConfiguration) { input.addEventListener("keyup", onKeyUp); input.addEventListener("input", onInput); input.addEventListener("compositionend", onCompositionEnd); + input.addEventListener("focus", resetInput); + resetInput(); } // Picture in picture plays the canvas as a muted video. It is created on first // use so a viewer that never pops out pays nothing for it. - const supportsPresentationMode = (element: PresentationVideo) => - element.webkitSupportsPresentationMode?.("picture-in-picture") === true; + // Checks the API, not an element: WebKit reports no support for a video + // that has not loaded yet. + const videoPrototype: PresentationVideo = HTMLVideoElement.prototype; const pictureInPictureSupported = interactive && typeof canvas.captureStream === "function" && - (document.pictureInPictureEnabled === true || - supportsPresentationMode(document.createElement("video"))); + ((document.pictureInPictureEnabled === true && + typeof videoPrototype.requestPictureInPicture === "function") || + typeof videoPrototype.webkitSetPresentationMode === "function"); let video: PresentationVideo | null = null; const pictureInPictureActive = () => video !== null && @@ -499,6 +660,7 @@ export function start(configuration: PreviewStreamConfiguration) { visualViewport?.removeEventListener("scroll", followVisualViewport); if (resizeTimer !== null) clearTimeout(resizeTimer); if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); + if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); painter.stop(); client?.stop(); client = null; diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index 9640a224e43e..0bb7ff4fdb74 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -39,21 +39,16 @@ const EMPTY_TABS: ThreadPreviewTabs = { }; const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty")); -function reconcileList(current: ThreadPreviewTabs, result: PreviewListResult): ThreadPreviewTabs { - if ( - current.listed && - current.serverEpoch === result.serverEpoch && - result.revision <= current.revision - ) { - return current; - } - return { - serverEpoch: result.serverEpoch, - revision: result.revision, - sessions: result.sessions, - listed: true, - }; -} +// Events kept for replay onto a list that lands after them. Only a list older +// than this many of the thread's events would miss one. +const MAX_REPLAY_EVENTS = 200; + +const listTabs = (result: PreviewListResult): ThreadPreviewTabs => ({ + serverEpoch: result.serverEpoch, + revision: result.revision, + sessions: result.sessions, + listed: true, +}); function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs { if (event.revision <= current.revision) return current; @@ -110,28 +105,46 @@ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { return Atom.make((get) => { let disposed = false; let state = EMPTY_TABS; + // The newest list applied, and this thread's events newer than it. A list + // can land after events it predates (the first one, or a stale cached one + // then its refresh), so each list is the base and newer events replay on top. + let list: PreviewListResult | null = null; + let events: ReadonlyArray = []; const publish = (next: ThreadPreviewTabs) => { if (next === state) return; state = next; get.setSelf(next); }; + const applyList = (result: PreviewListResult) => { + if (list?.serverEpoch === result.serverEpoch && result.revision < list.revision) return; + list = result; + events = events.filter( + (event) => event.serverEpoch === result.serverEpoch && event.revision > result.revision, + ); + return events.reduce(applyEvent, listTabs(result)); + }; get.addFinalizer(() => { disposed = true; }); get.subscribe(listAtom, (result) => { - if (AsyncResult.isSuccess(result)) publish(reconcileList(state, result.value)); + if (!AsyncResult.isSuccess(result)) return; + const next = applyList(result.value); + if (next) publish(next); }); get.subscribe(eventsAtom, (result) => { if (!AsyncResult.isSuccess(result) || result.value.threadId !== ref.threadId) return; + const event = result.value; + if (list?.serverEpoch === event.serverEpoch && event.revision <= list.revision) return; + events = [...events.slice(1 - MAX_REPLAY_EVENTS), event]; // A restarted server resets revisions; only a fresh list is authoritative. - if (state.serverEpoch !== null && result.value.serverEpoch !== state.serverEpoch) { + if (state.serverEpoch !== null && event.serverEpoch !== state.serverEpoch) { get.refresh(listAtom); return; } - publish(applyEvent(state, result.value)); + publish(applyEvent(state, event)); }); const cached = get.once(listAtom); - if (AsyncResult.isSuccess(cached)) state = reconcileList(state, cached.value); + if (AsyncResult.isSuccess(cached)) state = applyList(cached.value) ?? state; // The cached list can predate an agent-opened tab. queueMicrotask(() => { if (!disposed) get.refresh(listAtom); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index bc1ee0722ebc..2608e235b8fd 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -529,9 +529,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ) .sort( (left, right) => + Number(input.tabId !== undefined && ownsTargetTab(right)) - + Number(input.tabId !== undefined && ownsTargetTab(left)) || + Number(right.preferred) - Number(left.preferred) || Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || - Number(right.preferred) - Number(left.preferred) || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, )[0]; diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index e0a6aae47a10..83712712b4a9 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -126,6 +126,7 @@ const buildLoadingSnapshot = (input: { readonly viewport: PreviewViewportSetting; readonly profileId?: string | undefined; readonly runtime?: PreviewRuntime | undefined; + readonly reveal?: boolean | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -136,6 +137,7 @@ const buildLoadingSnapshot = (input: { viewport: input.viewport, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(input.runtime === undefined ? {} : { runtime: input.runtime }), + ...(input.reveal === undefined ? {} : { reveal: input.reveal }), updatedAt: input.updatedAt, }); @@ -145,6 +147,7 @@ const buildIdleSnapshot = (input: { readonly viewport: PreviewViewportSetting; readonly profileId?: string | undefined; readonly runtime?: PreviewRuntime | undefined; + readonly reveal?: boolean | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -155,6 +158,7 @@ const buildIdleSnapshot = (input: { viewport: input.viewport, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(input.runtime === undefined ? {} : { runtime: input.runtime }), + ...(input.reveal === undefined ? {} : { reveal: input.reveal }), updatedAt: input.updatedAt, }); @@ -242,6 +246,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport, profileId: input.profileId, runtime: input.runtime, + reveal: input.reveal, updatedAt, }) : buildIdleSnapshot({ @@ -250,6 +255,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport, profileId: input.profileId, runtime: input.runtime, + reveal: input.reveal, updatedAt, }); yield* SynchronizedRef.modifyEffect(stateRef, (state) => @@ -301,6 +307,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ...(session.snapshot.runtime === undefined ? {} : { runtime: session.snapshot.runtime }), + ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), updatedAt, }; return { @@ -338,6 +345,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ? {} : { profileId: session.snapshot.profileId }), ...(session.snapshot.runtime === undefined ? {} : { runtime: session.snapshot.runtime }), + ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), updatedAt, }; const emit: PreviewEventDraft = diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index fa1a25a35582..7961d321f500 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -20,6 +20,7 @@ */ import { FILL_PREVIEW_VIEWPORT, + INCOGNITO_BROWSER_PROFILE_ID, PREVIEW_AUTOMATION_OPERATIONS, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type PreviewAutomationActionEvent, @@ -84,6 +85,8 @@ const SCREENCAST_SETTLE_MS = 200; const SCREENCAST_MOTION_FRAMES = 4; const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; +const HOST_RECONNECT_DELAY = "1 second"; +const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; /** Resolves at `deadline`, in epoch ms. */ @@ -188,6 +191,8 @@ interface Recording { readonly encoder: Page; readonly session: CDPSession; readonly startedAt: string; + /** Frames still being handed to the encoder; stopping waits for them. */ + readonly framesInFlight: Set>; } interface ServerTab { @@ -204,6 +209,12 @@ interface ServerTab { loading: boolean; closing: boolean; recording: Recording | null; + /** The latest queued start, so a stop can find a recording still starting. */ + recordingStart: Promise | null; + /** Serializes captures and recording start/stop. */ + captureLock: Promise; + /** Scaled captures rendering now; screencasts stay stopped meanwhile. */ + capturing: number; } const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; @@ -251,20 +262,35 @@ const make = Effect.gen(function* () { const launchContext = async (profileId: string): Promise => { const resolved = await Effect.runPromise(toolchain.resolve); - const userDataDir = NodePath.join(profilesDir, encodeURIComponent(profileId)); - await NodeFSP.mkdir(userDataDir, { recursive: true }); // Loaded on first use so servers that never open a server tab skip it. const { chromium } = await import("playwright-core"); - const launch = (chromiumSandbox: boolean) => - chromium.launchPersistentContext(userDataDir, { + const launch = async (chromiumSandbox: boolean) => { + const launchOptions = { executablePath: resolved.executablePath, env: { ...process.env, ...resolved.env }, args: [...resolved.args, "--disable-gpu", `--force-device-scale-factor=${RENDER_SCALE}`], headless: true, chromiumSandbox, + }; + const contextOptions = { viewport: UNATTACHED_FILL_VIEWPORT, deviceScaleFactor: RENDER_SCALE, - }); + }; + if (profileId === INCOGNITO_BROWSER_PROFILE_ID) { + const browser = await chromium.launch(launchOptions); + try { + const context = await browser.newContext(contextOptions); + context.on("close", () => void browser.close().catch(() => undefined)); + return context; + } catch (cause) { + await browser.close().catch(() => undefined); + throw cause; + } + } + const userDataDir = NodePath.join(profilesDir, encodeURIComponent(profileId)); + await NodeFSP.mkdir(userDataDir, { recursive: true }); + return chromium.launchPersistentContext(userDataDir, { ...launchOptions, ...contextOptions }); + }; // The namespace sandbox needs unprivileged user namespaces, which some // containers disable. Running unsandboxed there matches what the agent // already has: a shell as the same user. @@ -384,6 +410,9 @@ const make = Effect.gen(function* () { loading: false, closing: false, recording: null, + recordingStart: null, + captureLock: Promise.resolve(), + capturing: 0, }; await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => @@ -457,6 +486,11 @@ const make = Effect.gen(function* () { page.on("close", () => { if (!tab.closing) dropTab(tab, true); }); + // Playwright cannot reload a crashed page. Remove it so clients stop + // displaying its stale frame and the agent can open a fresh tab. + page.on("crash", () => { + if (!tab.closing) dropTab(tab, true); + }); tabs.set(tabKey(tab.threadId, tab.tabId), tab); reportLiveTabs(); if (snapshot.navStatus._tag === "Loading") { @@ -477,7 +511,7 @@ const make = Effect.gen(function* () { return pending; }; - /** Server tabs that exist in PreviewManager but not yet here (e.g. after a crash). */ + /** Server tabs that exist in PreviewManager but have not finished opening here. */ const findTab = (threadId: string, tabId: string) => Effect.gen(function* () { const existing = tabs.get(tabKey(threadId, tabId)); @@ -589,122 +623,176 @@ const make = Effect.gen(function* () { .catch(() => undefined); }; - const startRecording = async (tab: ServerTab) => { - if (tab.recording) return tab.recording; - const encoder = await tab.page.context().newPage(); - await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); - const session = await tab.page.context().newCDPSession(tab.page); - session.on("Page.screencastFrame", (frame) => { - const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - void encoder - .evaluate( - ([data, width]) => { - const recorder = ( - globalThis as unknown as { - __t3Recorder: { frame(data: string, width: number): Promise }; - } - ).__t3Recorder; - return recorder.frame(data, width); - }, - [frame.data, cssWidth] as const, - ) - .catch(() => undefined) - .finally(() => { - void session - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(() => undefined); - }); + const withCaptureLock = (tab: ServerTab, operation: () => Promise): Promise => { + const run = tab.captureLock.then(() => { + if (tab.closing) throw new Error("The preview tab closed."); + return operation(); }); - await session.send("Page.startScreencast", RECORDING_SCREENCAST); - const recording: Recording = { encoder, session, startedAt: new Date().toISOString() }; - tab.recording = recording; - return recording; + tab.captureLock = run.then( + () => undefined, + () => undefined, + ); + return run; + }; + + const startRecording = (tab: ServerTab): Promise => { + const started = withCaptureLock(tab, async () => tab.recording ?? beginRecording(tab)).finally( + () => { + if (tab.recordingStart === started) tab.recordingStart = null; + }, + ); + tab.recordingStart = started; + return started; + }; + + const beginRecording = async (tab: ServerTab) => { + const encoder = await tab.page.context().newPage(); + let session: CDPSession | null = null; + try { + await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); + const opened = await tab.page.context().newCDPSession(tab.page); + session = opened; + const framesInFlight = new Set>(); + opened.on("Page.screencastFrame", (frame) => { + const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; + const delivered: Promise = encoder + .evaluate( + ([data, width]) => { + const recorder = ( + globalThis as unknown as { + __t3Recorder: { frame(data: string, width: number): Promise }; + } + ).__t3Recorder; + return recorder.frame(data, width); + }, + [frame.data, cssWidth] as const, + ) + .then(async (accepted) => { + if (!accepted) await opened.send("Page.stopScreencast"); + }) + .catch(() => undefined) + .finally(() => { + framesInFlight.delete(delivered); + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(() => undefined); + }); + framesInFlight.add(delivered); + }); + await opened.send("Page.startScreencast", RECORDING_SCREENCAST); + if (tab.closing) throw new Error("The tab closed while the recording started."); + const recording: Recording = { + encoder, + session: opened, + startedAt: new Date().toISOString(), + framesInFlight, + }; + tab.recording = recording; + return recording; + } catch (cause) { + // A start that fails partway must not leave its encoder page behind. + await encoder.close().catch(() => undefined); + await session?.detach().catch(() => undefined); + throw cause; + } }; /** * Runs a scaled capture with the tab's screencasts stopped, so viewers and * recordings never receive the frame it renders at the capture scale. */ - const withScreencastsPaused = async (tab: ServerTab, capture: () => Promise) => { - const viewers = [...tab.viewers]; - const recording = tab.recording; - await Promise.all([ - ...viewers.map((viewer) => viewer.pause()), - recording?.session.send("Page.stopScreencast").catch(() => undefined), - ]); - try { - return await capture(); - } finally { - await Promise.all([ - ...viewers.map((viewer) => viewer.resume()), - recording && tab.recording === recording - ? recording.session - .send("Page.startScreencast", RECORDING_SCREENCAST) - .catch(() => undefined) - : undefined, - ]); - } - }; + const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => + withCaptureLock(tab, async () => { + tab.capturing += 1; + const recording = tab.recording; + try { + await Promise.all([ + ...[...tab.viewers].map((viewer) => viewer.pause()), + recording?.session.send("Page.stopScreencast").catch(() => undefined), + ]); + return await capture(); + } finally { + tab.capturing -= 1; + // Viewers that attached during the capture start here too. + await Promise.all([ + ...[...tab.viewers].map((viewer) => viewer.resume()), + recording && tab.recording === recording + ? recording.session + .send("Page.startScreencast", RECORDING_SCREENCAST) + .catch(() => undefined) + : undefined, + ]); + } + }); - const stopRecording = async (tab: ServerTab) => { - const recording = tab.recording; - if (!recording) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingNotActiveError", - "No recording is active for this tab.", - ); - } - tab.recording = null; - await recording.session.send("Page.stopScreencast").catch(() => undefined); - await recording.session.detach().catch(() => undefined); - type EncoderWindow = { - __t3Recorder: { - stop(): Promise<{ mimeType: string | null; count: number }>; - chunk(index: number): Promise; + const stopRecording = (tab: ServerTab) => + withCaptureLock(tab, async () => { + const recording = tab.recording; + if (!recording) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this tab.", + ); + } + type EncoderWindow = { + __t3Recorder: { + stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; + chunk(index: number): Promise; + }; }; - }; - const { mimeType, count } = await recording.encoder.evaluate(() => - (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), - ); - const chunks: Array = []; - for (let index = 0; index < count; index += 1) { - const chunk = await recording.encoder.evaluate( - (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), - index, - ); - chunks.push(Buffer.from(chunk, "base64")); - } - await recording.encoder.close().catch(() => undefined); - const data = Buffer.concat(chunks); - if (!mimeType || data.byteLength === 0) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationExecutionError", - "The recording captured no frames.", - ); - } - if (data.byteLength > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingTooLargeError", - "The recording is larger than the attachment limit.", - ); - } - // Written where a desktop upload would land, so the MCP handler claims - // both the same way. - const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; - const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; - const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); - await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); - await NodeFSP.writeFile(path, data); - return { - id: pendingId, - tabId: tab.tabId, - path, - mimeType: mimeType.split(";")[0]!, - sizeBytes: data.byteLength, - createdAt: new Date().toISOString(), - uploadedAttachmentId: pendingId, - }; - }; + let mimeType: string | null; + const chunks: Array = []; + try { + await recording.session.send("Page.stopScreencast").catch(() => undefined); + // The last frames may still be on their way into the encoder. + await Promise.all(recording.framesInFlight); + await recording.session.detach().catch(() => undefined); + const stopped = await recording.encoder.evaluate(() => + (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), + ); + mimeType = stopped.mimeType; + // Checked before the transfer so an oversized video never lands in this process. + if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingTooLargeError", + "The recording is larger than the attachment limit.", + ); + } + for (let index = 0; index < stopped.count; index += 1) { + const chunk = await recording.encoder.evaluate( + (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), + index, + ); + chunks.push(Buffer.from(chunk, "base64")); + } + } finally { + await recording.encoder.close().catch(() => undefined); + tab.recording = null; + } + const data = Buffer.concat(chunks); + if (!mimeType || data.byteLength === 0) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "The recording captured no frames.", + ); + } + // Written where a desktop upload would land, so the MCP handler claims + // both the same way. + const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; + const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); + await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); + await NodeFSP.writeFile(path, data); + return { + id: pendingId, + tabId: tab.tabId, + path, + mimeType: mimeType.split(";")[0]!, + sizeBytes: data.byteLength, + createdAt: new Date().toISOString(), + uploadedAttachmentId: pendingId, + }; + }); const recordAction = (tab: ServerTab, action: string, run: () => Promise): Promise => { const event: { @@ -774,6 +862,7 @@ const make = Effect.gen(function* () { threadId: request.threadId, ...(url ? { url } : {}), runtime: "server", + reveal: (open.open ?? open.show) !== false, }), ); const tab = await ensureTab(snapshot); @@ -877,7 +966,9 @@ const make = Effect.gen(function* () { const tab = request.tabId === undefined ? [...tabs.values()].find( - (candidate) => candidate.threadId === request.threadId && candidate.recording, + (candidate) => + candidate.threadId === request.threadId && + (candidate.recording || candidate.recordingStart), ) : await requireTab(request); if (!tab) { @@ -1041,7 +1132,10 @@ const make = Effect.gen(function* () { const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) => Effect.gen(function* () { const tab = yield* findTab(input.threadId, input.tabId); - const output = yield* Queue.unbounded(); + const output = yield* Queue.make({ + capacity: VIEWER_OUTPUT_LIMIT, + strategy: "dropping", + }); const session = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => tab.page.context().newCDPSession(tab.page), @@ -1056,8 +1150,39 @@ const make = Effect.gen(function* () { .catch(() => undefined), ), ); + const quality = Math.min(100, Math.max(1, Math.round(input.quality))); + // Frames that arrive while others are in flight may have pushed later ones + // out, and a page that then goes still never repaints them. A still + // after the burst shows the final state. + let framesInFlight = 0; + let mayHaveDropped = false; + let motion = false; + const recentFrames: Array = []; + let screencastParams = Promise.resolve(); + let screencastScale = 1; + const startScreencast = (scale: number) => { + screencastScale = scale; + screencastParams = screencastParams.then(async () => { + // A scaled capture is rendering; its resume starts the stream. + if (tab.capturing > 0) return; + await session.send("Page.stopScreencast").catch(() => undefined); + await session + .send("Page.startScreencast", { + format: "jpeg", + quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, + maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), + maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), + }) + .catch(() => undefined); + }); + return screencastParams; + }; const viewer: ViewerState = { - push: (next) => void Queue.offerUnsafe(output, next), + push: (next) => { + // Bounded: a viewer that stops reading drops messages instead of + // growing this queue. A dropped frame still releases Chromium. + if (!Queue.offerUnsafe(output, next) && next._tag === "frame") runFork(next.ack); + }, pause: () => { screencastParams = screencastParams.then(() => session.send("Page.stopScreencast").then( @@ -1067,7 +1192,7 @@ const make = Effect.gen(function* () { ); return screencastParams; }, - resume: () => startScreencast(motion ? 0.5 : 1), + resume: () => startScreencast(screencastScale), scrolledAt: 0, }; yield* Effect.acquireRelease( @@ -1081,39 +1206,18 @@ const make = Effect.gen(function* () { reportLiveTabs(); }), ); - const quality = Math.min(100, Math.max(1, Math.round(input.quality))); // Full scale: a scaled capture would flash in every other viewer. const pushStill = async () => { - const data = await ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }).catch(() => null); + const data = await withCaptureLock(tab, () => + ServerBrowserPage.captureViewport(tab.page, session, { + format: "jpeg", + quality, + scale: 1, + }), + ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; - // Frames that arrive while two are in flight may have pushed later ones - // out, and a page that then goes still never repaints them. A still - // after the burst shows the final state. - let framesInFlight = 0; - let mayHaveDropped = false; - let motion = false; - const recentFrames: Array = []; - let screencastParams = Promise.resolve(); - const startScreencast = (scale: number) => { - screencastParams = screencastParams.then(async () => { - await session.send("Page.stopScreencast").catch(() => undefined); - await session - .send("Page.startScreencast", { - format: "jpeg", - quality: scale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, - maxWidth: Math.max(1, Math.round(input.maxWidth * scale)), - maxHeight: Math.max(1, Math.round(input.maxHeight * scale)), - }) - .catch(() => undefined); - }); - return screencastParams; - }; let settleTimer: ReturnType | null = null; yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -1183,7 +1287,7 @@ const make = Effect.gen(function* () { if (enabled) { yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped); const environmentId = yield* environment.getEnvironmentId; - yield* broker + const hostSession = broker .connect( { clientId: SERVER_HOST_CLIENT_ID, @@ -1207,8 +1311,16 @@ const make = Effect.gen(function* () { }), ), ), - Effect.forkScoped, ); + // The broker ends a host's stream when a request outlives its deadline, + // e.g. the first open while Chromium installs. Register again, or agents + // lose the browser until the server restarts. + yield* hostSession.pipe( + Effect.exit, + Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)), + Effect.forever, + Effect.forkScoped, + ); yield* Effect.addFinalizer(() => Effect.promise(async () => { for (const context of contexts.values()) { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 90ce25adda26..68fd2cc4f9bd 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics globalDate:off - Playwright callbacks run outside the Effect runtime. +// @effect-diagnostics globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. /** * Page-level automation for server-hosted preview tabs. * @@ -7,16 +7,17 @@ * MCP preview tools cannot tell the two runtimes apart. Failures carry the * broker's error tags (see `PreviewAutomationBroker.classifyResponseError`). */ -import type { - PreviewAutomationClickInput, - PreviewAutomationConsoleEntry, - PreviewAutomationEvaluateInput, - PreviewAutomationNetworkEntry, - PreviewAutomationPressInput, - PreviewAutomationScrollInput, - PreviewAutomationSnapshot, - PreviewAutomationTypeInput, - PreviewAutomationWaitForInput, +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type PreviewAutomationClickInput, + type PreviewAutomationConsoleEntry, + type PreviewAutomationEvaluateInput, + type PreviewAutomationNetworkEntry, + type PreviewAutomationPressInput, + type PreviewAutomationScrollInput, + type PreviewAutomationSnapshot, + type PreviewAutomationTypeInput, + type PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import type { CDPSession, Locator, Page } from "playwright-core"; @@ -25,6 +26,7 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const WAIT_POLL_MS = 100; export const DIAGNOSTIC_BUFFER_LIMIT = 200; export class ServerBrowserOperationError extends Error { @@ -214,13 +216,59 @@ export const click = async ( export const type = async (page: Page, input: PreviewAutomationTypeInput) => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); - if (locator !== null) { - if (input.clear) { - await locator.fill(input.text, { timeout }); - return; - } - await locator.focus({ timeout }); - } else if (input.clear) { + if (locator !== null && input.clear) { + await locator.fill(input.text, { timeout }); + return; + } + // A shadow host also matches :focus; use its innermost focused descendant. + const target = locator ?? page.locator("*:focus").last(); + const focused = + (locator !== null || (await target.count()) > 0) && + (await target.evaluate( + (element) => { + // Like the desktop host: an enabled text control or contenteditable + // that actually takes focus. Anything else would swallow the text or + // send it to whichever field had focus before. + const control = element as unknown as { + readonly type?: string; + readonly disabled?: boolean; + readonly readOnly?: boolean; + readonly isContentEditable?: boolean; + readonly focus?: () => void; + }; + const nonText = [ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", + ]; + const textControl = + element.tagName === "TEXTAREA" || + (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text")); + if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) { + return false; + } + control.focus?.(); + const root = element.getRootNode() as { readonly activeElement?: typeof element | null }; + const active = root.activeElement; + return active != null && (active === element || element.contains(active)); + }, + undefined, + { timeout }, + )); + if (focused !== true) { + throw new ServerBrowserOperationError( + "PreviewAutomationTargetNotEditableError", + "The target is not an enabled text field, so no text was typed.", + ); + } + if (input.clear) { await page.keyboard.press("ControlOrMeta+A"); await page.keyboard.press("Delete"); } @@ -267,26 +315,35 @@ export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluate return value; }; +/** Resolves once every supplied condition holds at the same moment. */ export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { - const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); - const remaining = () => Math.max(1, deadline - Date.now()); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; const locator = targetLocator(page, input); - if (locator !== null) await locator.waitFor({ state: "visible", timeout: remaining() }); - if (input.text !== undefined) { - await page.waitForFunction( - `(document.body?.innerText ?? "").includes(${JSON.stringify(input.text)})`, - undefined, - { timeout: remaining() }, + const { text, urlIncludes } = input; + const checks: Array<() => Promise> = []; + // Like the desktop host: the selector must match an element, visible or not. + if (locator !== null) checks.push(async () => (await locator.count()) > 0); + if (text !== undefined) { + // A navigation can destroy the context mid-check; that counts as not yet. + checks.push(() => + page + .evaluate(`(document.body?.innerText ?? "").includes(${JSON.stringify(text)})`) + .then((found) => found === true) + .catch(() => false), ); } - if (input.urlIncludes !== undefined) { - await page.waitForFunction( - `location.href.includes(${JSON.stringify(input.urlIncludes)})`, - undefined, - { - timeout: remaining(), - }, - ); + if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes)); + for (;;) { + const results = await Promise.all(checks.map((check) => check())); + if (results.every(Boolean)) return; + if (Date.now() >= deadline) { + throw new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + `Waited ${timeoutMs}ms without every condition holding.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); } }; @@ -322,6 +379,9 @@ export const RECORDING_ENCODER_SCRIPT = `(() => { const context = canvas.getContext("2d"); const chunks = []; let recorder = null; + let stopped = null; + let sizeBytes = 0; + let tooLarge = false; let frame = null; let cursor = null; let ring = null; @@ -374,15 +434,32 @@ export const RECORDING_ENCODER_SCRIPT = `(() => { }; 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 * 30 * 0.05)); recorder = new MediaRecorder(canvas.captureStream(30), { mimeType, videoBitsPerSecond: bitsPerSecond }); - recorder.ondataavailable = (event) => { if (event.data.size > 0) chunks.push(event.data); }; + 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. @@ -390,8 +467,10 @@ export const RECORDING_ENCODER_SCRIPT = `(() => { frame?.close(); frame = bitmap; paint(); + return true; }, cursor(x, y, click) { + if (tooLarge) return; cursor = { x, y }; if (click) { ring = { x, y, at: performance.now() }; @@ -403,12 +482,11 @@ export const RECORDING_ENCODER_SCRIPT = `(() => { } paint(); }, - stop() { - return new Promise((resolve) => { - if (!recorder) return resolve({ mimeType: null, count: 0 }); - recorder.onstop = () => resolve({ mimeType: recorder.mimeType, count: chunks.length }); - recorder.stop(); - }); + async stop() { + if (!recorder) return { mimeType: null, count: 0, bytes: 0 }; + if (recorder.state !== "inactive") recorder.stop(); + await stopped; + return { mimeType: recorder.mimeType, count: chunks.length, bytes: sizeBytes }; }, async chunk(index) { const bytes = new Uint8Array(await chunks[index].arrayBuffer()); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index a1c45f5da89d..1d27291deabd 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -2,11 +2,13 @@ * `/api/preview-stream/ws`: one server preview tab over a WebSocket. * * Frames go out as binary JPEG messages, viewport changes as JSON text, and - * viewer input comes back as JSON text. Each frame is acknowledged to Chromium - * only after the socket write drains, so a slow link (phone over T3 Connect) - * gets fewer frames instead of a growing buffer. Authentication matches the - * device hub proxy; the socket drives the page, so it needs operate scope. + * viewer input comes back as JSON text. The viewer acknowledges each frame and + * Chromium's acknowledgement waits for it, so a slow link (phone over T3 + * Connect) gets fewer frames instead of a growing buffer. Authentication + * matches the device hub proxy; the socket drives the page, so it needs + * operate scope. */ +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -22,6 +24,8 @@ export const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; const TAB_GONE_CODE = 4404; const DEFAULT_QUALITY = 70; +const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024; +const MAX_UNACKNOWLEDGED_FRAMES = 64; const textDecoder = new TextDecoder(); const intParam = (params: URLSearchParams, name: string, fallback: number, max: number) => { @@ -29,6 +33,9 @@ const intParam = (params: URLSearchParams, name: string, fallback: number, max: return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), max) : fallback; }; +const isAck = (message: unknown) => + typeof message === "object" && message !== null && "type" in message && message.type === "ack"; + const parseMessage = (chunk: Uint8Array | string): unknown => { try { return JSON.parse(typeof chunk === "string" ? chunk : textDecoder.decode(chunk)); @@ -69,7 +76,14 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => Effect.map(Option.some), Effect.catchTag("ServerBrowserTabNotFoundError", () => Effect.succeedNone), ); + const incoming = NodeHttpServerRequest.toIncomingMessage(request); + // JPEGs are already compressed. Disabling deflate also keeps all + // pending writes in the socket buffer we bound below, not a zlib queue. + delete incoming.headers["sec-websocket-extensions"]; + const transport = incoming.socket; const socket = yield* request.upgrade; + // The reader performs the upgrade; writes wait for it. + const reader = yield* socket.reader; const writer = yield* socket.writer; // A refused upgrade reads as an auth failure to ticket clients, so a // missing tab is a close code they stop on. @@ -79,18 +93,37 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => return HttpServerResponse.empty(); } const viewer = attached.value; - const reader = yield* socket.reader; + // `write` returns once the frame is queued, so Chromium's ack for each + // frame waits for the viewer's `ack` message instead. + const unacknowledged: Array> = []; + const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe( + Effect.andThen(Effect.interrupt), + ); + const write = (data: Uint8Array | string) => + Effect.suspend(() => { + const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; + // Include the WebSocket frame header in the budget. + if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) { + return disconnectSlowViewer; + } + return writer.write(data); + }); const sendOutput = Queue.take(viewer.output).pipe( Effect.flatMap((output) => { switch (output._tag) { case "frame": - return writer.write(output.data).pipe(Effect.ensuring(output.ack)); + if (unacknowledged.length >= MAX_UNACKNOWLEDGED_FRAMES) { + return disconnectSlowViewer; + } + return write(output.data).pipe( + Effect.andThen(Effect.sync(() => unacknowledged.push(output.ack))), + ); case "viewport": - return writer.write( + return write( JSON.stringify({ type: "viewport", width: output.width, height: output.height }), ); case "probe": - return writer.write( + return write( JSON.stringify({ type: "probe", x: output.x, @@ -103,10 +136,15 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => } }), ); + const receive = (chunk: Uint8Array | string) => { + const message = parseMessage(chunk); + if (!isAck(message)) return viewer.input(message); + const ack = unacknowledged.shift(); + // Forked: Chromium acks are paced and must not hold up input. + return ack ? Effect.forkScoped(ack).pipe(Effect.asVoid) : Effect.void; + }; const receiveInput = reader.pull.pipe( - Effect.flatMap((chunks) => - Effect.forEach(chunks, (chunk) => viewer.input(parseMessage(chunk)), { discard: true }), - ), + Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); // Whichever side ends first closes the other through scope teardown. return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); diff --git a/apps/server/src/preview/ServerBrowserToolchain.ts b/apps/server/src/preview/ServerBrowserToolchain.ts index 6dc645660ef7..6910e8ef6da2 100644 --- a/apps/server/src/preview/ServerBrowserToolchain.ts +++ b/apps/server/src/preview/ServerBrowserToolchain.ts @@ -110,6 +110,7 @@ const fontconfigFile = ( const BROWSER_INSTALL_TIMEOUT = Duration.minutes(10); const FONTS_INSTALL_TIMEOUT = Duration.minutes(3); +const FONTS_LAUNCH_WAIT = Duration.seconds(1); const browserInstallLock = Semaphore.makeUnsafe(1); const fontsInstallLock = Semaphore.makeUnsafe(1); @@ -258,10 +259,15 @@ const playwrightCacheDir = ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, path: Path.Path, + playwrightRoot: string | undefined, ): string | undefined => { - if (env.PLAYWRIGHT_BROWSERS_PATH && env.PLAYWRIGHT_BROWSERS_PATH !== "0") { - return env.PLAYWRIGHT_BROWSERS_PATH; + // Playwright's rules: "0" is the hermetic install beside playwright-core, + // and a relative path is relative to where the install ran. + const configured = env.PLAYWRIGHT_BROWSERS_PATH; + if (configured === "0") { + return playwrightRoot === undefined ? undefined : path.join(playwrightRoot, ".local-browsers"); } + if (configured) return path.resolve(env.INIT_CWD || ".", configured); if (platform === "win32") { return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "ms-playwright") : undefined; } @@ -484,7 +490,11 @@ const make = Effect.gen(function* () { ), ), ); - const ensureFonts = Effect.flatMap(fontsFiber, Fiber.join); + // The first launch waits briefly for fonts, then goes without; later + // launches pick them up once the background install lands. + const ensureFonts = Effect.flatMap(fontsFiber, (fiber) => + Fiber.join(fiber).pipe(Effect.timeoutOption(FONTS_LAUNCH_WAIT), Effect.asVoid), + ); const bundledLaunch: ServerBrowserLaunch = { executablePath: path.join(browserDir, "chromium"), @@ -541,7 +551,19 @@ const make = Effect.gen(function* () { source: "system", } satisfies ServerBrowserLaunch; } - const cacheDir = playwrightCacheDir(platform, hostEnv, path); + const playwrightRoot = yield* Effect.try(() => + import.meta.resolve("playwright-core/package.json"), + ).pipe( + Effect.flatMap((url) => path.fromFileUrl(new URL(url))), + Effect.map((file) => path.dirname(file)), + Effect.option, + ); + const cacheDir = playwrightCacheDir( + platform, + hostEnv, + path, + Option.getOrUndefined(playwrightRoot), + ); const relative = PLAYWRIGHT_HEADLESS_SHELL[`${platform}-${architecture}`]; if (cacheDir !== undefined && relative !== undefined) { const revisions = (yield* fs.readDirectory(cacheDir).pipe(Effect.orElseSucceed(() => []))) diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 5d8315a9d701..60f0c922e1c5 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -25,6 +25,7 @@ import { useState, } from "react"; +import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; @@ -39,7 +40,16 @@ export interface ServerBrowserHandle { const RESIZE_DEBOUNCE_MS = 150; const ACCESS_RETRY_MS = 10_000; -const UNAUTHORIZED_RETRY_MAX_MS = 30_000; +// A recent probe answer near a new tap stands in for that tap's own answer, +// which on a slow link arrives after the tap ends. +const PROBE_REUSE_PX = 24; +const PROBE_REUSE_MS = 10_000; +// Kept in the input so soft keyboards have something to delete: Android +// reports Backspace only as a deletion of text, not as a key. +const INPUT_SENTINEL = "\u200b"; +const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const; +const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const; +const MAX_UNAUTHORIZED_REFUSALS = 3; const TAP_SLOP_PX = 8; const MULTI_CLICK_MS = 500; const MULTI_CLICK_SLOP_PX = 4; @@ -69,6 +79,8 @@ interface TouchProbe { readonly x: number; readonly y: number; editable: boolean | null; + /** True only for this tap's own reply, never for a cached answer. */ + answered: boolean; /** The tap ended before the answer arrived. */ tapped: boolean; } @@ -119,6 +131,7 @@ export function ServerBrowserSurface(props: { const hasFrameRef = useRef(false); const pendingCommandRef = useRef(null); const unauthorizedRef = useRef(0); + const [accessDenied, setAccessDenied] = useState(false); const pendingMoveRef = useRef(null); const pendingWheelRef = useRef(null); const inputFrameRef = useRef(null); @@ -132,6 +145,7 @@ export function ServerBrowserSurface(props: { } | null>(null); const touchRef = useRef(null); const probeRef = useRef(null); + const lastProbeRef = useRef<{ x: number; y: number; editable: boolean; at: number } | null>(null); const firstFrame = useEffectEvent(() => onFirstFrame?.()); const viewportChanged = useEffectEvent((viewport: PreviewStreamViewport) => onViewport?.(viewport), @@ -217,22 +231,18 @@ export function ServerBrowserSurface(props: { const focusInput = () => inputRef.current?.focus({ preventScroll: true }); - useImperativeHandle( - ref, - () => { - const command = (input: PreviewStreamInput) => { - if (clientRef.current?.send(input)) return; - pendingCommandRef.current = input; - }; - return { - navigate: (url) => command({ type: "navigate", url }), - history: (delta) => command({ type: "history", delta }), - reload: () => command({ type: "reload" }), - canvas: () => (hasFrameRef.current ? canvasRef.current : null), - }; - }, - [], - ); + useImperativeHandle(ref, () => { + const command = (input: PreviewStreamInput) => { + if (clientRef.current?.send(input)) return; + pendingCommandRef.current = input; + }; + return { + navigate: (url) => command({ type: "navigate", url }), + history: (delta) => command({ type: "history", delta }), + reload: () => command({ type: "reload" }), + canvas: () => (hasFrameRef.current ? canvasRef.current : null), + }; + }, []); useEffect(() => { const element = containerRef.current; @@ -273,7 +283,7 @@ export function ServerBrowserSurface(props: { useEffect(() => { const canvas = canvasRef.current; - if (!visible || !access || !cap || !canvas) return; + if (!visible || accessDenied || !access || !cap || !canvas) return; let refreshTimer: ReturnType | null = null; const painter = createPreviewFramePainter(canvas, () => { if (hasFrameRef.current) return; @@ -288,9 +298,11 @@ export function ServerBrowserSurface(props: { painter.paint(jpeg); }, onProbe: (result) => { + lastProbeRef.current = { ...result, at: performance.now() }; const probe = probeRef.current; if (!probe || probe.x !== result.x || probe.y !== result.y) return; probe.editable = result.editable; + probe.answered = true; if (!probe.tapped) return; // Late answer: Android still raises the keyboard; iOS waits for the next tap. probeRef.current = null; @@ -310,12 +322,16 @@ export function ServerBrowserSurface(props: { if (command) client.send(command); }, onUnauthorized: () => { - // A fresh ticket re-runs this effect through `access`. Back off when - // new tickets keep getting refused, e.g. a session without operate scope. - const attempt = unauthorizedRef.current++; + // Fresh tickets re-run this effect. Repeated refusals need an explicit retry. + const refusals = ++unauthorizedRef.current; + if (refusals >= MAX_UNAUTHORIZED_REFUSALS) { + inputRef.current?.blur(); + setAccessDenied(true); + return; + } refreshTimer = setTimeout( () => refreshPreviewStreamAccess(environmentId), - attempt === 0 ? 0 : Math.min(1_000 * 2 ** attempt, UNAUTHORIZED_RETRY_MAX_MS), + refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), ); }, }, @@ -327,14 +343,14 @@ export function ServerBrowserSurface(props: { client.stop(); if (clientRef.current === client) clientRef.current = null; }; - }, [access, cap, environmentId, followSize, tabId, threadId, visible]); + }, [access, accessDenied, cap, environmentId, followSize, tabId, threadId, visible]); useEffect(() => { - if (!visible || access !== null) return; + if (!visible || accessDenied || access !== null) return; // A failed ticket mint, e.g. while the server restarts, never retries on its own. - const timer = setTimeout(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS); - return () => clearTimeout(timer); - }, [access, environmentId, visible]); + const timer = setInterval(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS); + return () => clearInterval(timer); + }, [access, accessDenied, environmentId, visible]); useEffect(() => { const canvas = canvasRef.current; @@ -375,7 +391,21 @@ export function ServerBrowserSurface(props: { panning: false, }; const point = pagePoint(event.clientX, event.clientY, false); - probeRef.current = point ? { x: point.x, y: point.y, editable: null, tapped: false } : null; + const last = lastProbeRef.current; + const reusable = + point !== null && + last !== null && + performance.now() - last.at < PROBE_REUSE_MS && + Math.hypot(point.x - last.x, point.y - last.y) < PROBE_REUSE_PX; + probeRef.current = point + ? { + x: point.x, + y: point.y, + editable: reusable ? last.editable : null, + answered: false, + tapped: false, + } + : null; if (point) send({ type: "probe", x: point.x, y: point.y }); return; } @@ -453,7 +483,7 @@ export function ServerBrowserSurface(props: { // Focusing inside the tap's user activation is what lets iOS raise the keyboard. if (probe?.editable === true) focusInput(); else if (probe?.editable === false) inputRef.current?.blur(); - if (probe?.editable === null) probe.tapped = true; + if (probe && !probe.answered) probe.tapped = true; else probeRef.current = null; flushInput(); const clickCount = countClick("left", event.clientX, event.clientY, event.timeStamp); @@ -481,7 +511,37 @@ export function ServerBrowserSurface(props: { }; const handlePointerCancel = (event: PointerEvent) => { - if (touchRef.current?.pointerId === event.pointerId) touchRef.current = null; + if (touchRef.current?.pointerId === event.pointerId) { + touchRef.current = null; + probeRef.current = null; + return; + } + if (!mousePressedRef.current) return; + // A cancelled drag still has to release the page's pressed button. + mousePressedRef.current = false; + const point = pagePoint(event.clientX, event.clientY, true); + if (!point) return; + flushInput(); + send({ + type: "mouse", + action: "up", + x: point.x, + y: point.y, + button: lastClickRef.current?.button ?? "left", + buttons: 0, + clickCount: lastClickRef.current?.count ?? 1, + modifiers: 0, + }); + }; + + const resetInput = (textarea: HTMLTextAreaElement) => { + textarea.value = INPUT_SENTINEL; + textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length); + }; + + const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => { + send({ type: "key", action: "down", ...key, modifiers: 0 }); + send({ type: "key", action: "up", ...key, modifiers: 0 }); }; const handleKey = (action: "down" | "up", event: KeyboardEvent) => { @@ -521,15 +581,22 @@ export function ServerBrowserSurface(props: { }; const handleInput = (event: FormEvent) => { - if (event.nativeEvent instanceof InputEvent && event.nativeEvent.isComposing) return; + const native = event.nativeEvent; + if (native instanceof InputEvent && native.isComposing) return; const textarea = event.currentTarget; - if (textarea.value) send({ type: "text", text: textarea.value }); - textarea.value = ""; + const inputType = native instanceof InputEvent ? native.inputType : ""; + if (inputType === "deleteContentBackward") sendKeyPress(BACKSPACE); + else if (inputType === "deleteContentForward") sendKeyPress(DELETE); + else { + const text = textarea.value.replaceAll(INPUT_SENTINEL, ""); + if (text) send({ type: "text", text }); + } + resetInput(textarea); }; const handleCompositionEnd = (event: CompositionEvent) => { if (event.data) send({ type: "text", text: event.data }); - event.currentTarget.value = ""; + resetInput(event.currentTarget); }; const handlePaste = (event: ClipboardEvent) => { @@ -564,6 +631,9 @@ export function ServerBrowserSurface(props: { autoComplete="off" autoCorrect="off" spellCheck={false} + defaultValue={INPUT_SENTINEL} + // The caret must sit after the sentinel for a deletion to have something to delete. + onFocus={(event) => resetInput(event.currentTarget)} className="sr-only top-0 left-0 text-base" onKeyDown={(event) => handleKey("down", event)} onKeyUp={(event) => handleKey("up", event)} @@ -571,6 +641,25 @@ export function ServerBrowserSurface(props: { onCompositionEnd={handleCompositionEnd} onPaste={handlePaste} /> + {visible && accessDenied ? ( + // The page can be invisible beneath an empty or unreachable state; reconnect must remain reachable. + + + Browser connection was refused. + + { + unauthorizedRef.current = 0; + setAccessDenied(false); + refreshPreviewStreamAccess(environmentId); + }} + > + Reconnect + + + ) : null} ); } diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index 7ecffb4593d5..ce79e6ac6a6a 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -8,10 +8,10 @@ * * @module browserLinkTarget */ -import type { BrowserLinkTarget } from "@t3tools/contracts"; +import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; -import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { isPreviewAvailableFor } from "./previewRuntime"; export interface ResolveLinkTargetInput { readonly url: string; @@ -62,7 +62,7 @@ export async function resolveBrowserLinkTargetPreference(): Promise(input: { if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const open = () => - input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - url: input.url, - // Built here rather than via `openPreviewSession` because this path - // maps the result differently, so the configured defaults have to be - // applied explicitly or file/link opens would ignore them. - viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), - // Same runtime rule as `openPreviewSession`. - ...(previewBridge ? {} : { runtime: "server" as const }), - }, - }); - const result = previewBridge - ? await open() - : await trackLocalServerTabOpen(input.threadRef, open); + const runtime = previewRuntimeFor(input.threadRef.environmentId); + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Built here rather than via `openPreviewSession` because this path + // maps the result differently, so the configured defaults have to be + // applied explicitly or file/link opens would ignore them. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + ...(runtime === undefined ? {} : { runtime }), + }, + }); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); @@ -109,7 +100,7 @@ export async function openFileInPreview(input: { AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError > > { - if (!isPreviewSupportedInRuntime()) { + if (!isPreviewAvailableFor(input.threadRef.environmentId)) { return AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts new file mode 100644 index 000000000000..ca8320cf4104 --- /dev/null +++ b/apps/web/src/browser/previewRuntime.ts @@ -0,0 +1,18 @@ +import type { EnvironmentId, PreviewRuntime } from "@t3tools/contracts"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { readEnvironmentSupportsServerBrowser } from "~/state/entities"; + +/** + * Where a new tab for this environment runs. An environment that hosts its own + * browser gets server tabs from every client, so they outlive this device; + * the desktop app's own environment keeps Electron tabs. + */ +export function previewRuntimeFor(environmentId: EnvironmentId): PreviewRuntime | undefined { + return readEnvironmentSupportsServerBrowser(environmentId) ? "server" : undefined; +} + +/** Whether this client can open a browser tab for the environment at all. */ +export function isPreviewAvailableFor(environmentId: EnvironmentId): boolean { + return isPreviewSupportedInRuntime() || readEnvironmentSupportsServerBrowser(environmentId); +} diff --git a/apps/web/src/browser/serverPictureInPicture.ts b/apps/web/src/browser/serverPictureInPicture.ts index d07ce18bfd2e..c4185026eb9a 100644 --- a/apps/web/src/browser/serverPictureInPicture.ts +++ b/apps/web/src/browser/serverPictureInPicture.ts @@ -19,6 +19,9 @@ import { readPreviewStreamAccess } from "~/state/previewStream"; /** Frames cap for the floating window, in device px. */ const STREAM_CAP_PX = 1280; const PLACEHOLDER = { width: 640, height: 400 }; +// Refused upgrades retry with backoff; past the limit the window closes. +const UNAUTHORIZED_RETRY_BASE_MS = 1_000; +const UNAUTHORIZED_RETRY_LIMIT = 5; interface WebKitVideo { webkitSupportsPresentationMode?: (mode: string) => boolean; @@ -114,6 +117,8 @@ export async function openServerPictureInPicture(input: { const painter = createPreviewFramePainter(canvas); let client: PreviewStreamClient | null = null; let stopped = false; + let refusals = 0; + let retryTimer: ReturnType | null = null; const connect = async (refresh: boolean) => { const access = await readPreviewStreamAccess(input.environmentId, refresh); if (stopped) return; @@ -130,10 +135,25 @@ export async function openServerPictureInPicture(input: { maxHeight: STREAM_CAP_PX, }, { - onFrame: painter.paint, + onFrame: (jpeg) => { + refusals = 0; + painter.paint(jpeg); + }, onViewport: () => undefined, onConnectedChange: () => undefined, - onUnauthorized: () => void connect(true), + onUnauthorized: () => { + if (stopped) return; + if (refusals >= UNAUTHORIZED_RETRY_LIMIT) { + if (active?.video === video) closeServerPictureInPicture(); + return; + } + const delay = UNAUTHORIZED_RETRY_BASE_MS * 2 ** refusals; + refusals += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + void connect(true); + }, delay); + }, onGone: () => { if (active?.video === video) closeServerPictureInPicture(); }, @@ -152,6 +172,7 @@ export async function openServerPictureInPicture(input: { video, stop: () => { stopped = true; + if (retryTimer !== null) clearTimeout(retryTimer); client?.stop(); painter.stop(); video.removeEventListener("leavepictureinpicture", onLeave); diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 2a1d122eedbf..d23d40a3f537 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -46,7 +46,7 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url, event: options.event ?? NO_MODIFIER, preference: await resolveBrowserLinkTargetPreference(), - canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), + canOpenInApp: canOpenLinksInApp(targetThreadRef), }); if (target === "app" && targetThreadRef) { const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7934b2c1c776..3b736cae2653 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -190,7 +190,7 @@ import { } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, @@ -2803,7 +2803,7 @@ function useChatMarkdownState({ revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && - isPreviewSupportedInRuntime() && + isPreviewAvailableFor(threadRef.environmentId) && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined @@ -3073,7 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = { : null; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId)); const linkChildren = {children}; const link = ( >()); useEffect(() => { if (!activeThreadRef || !activeEnvironmentServerBrowser) return; + // Nothing has loaded yet; an empty baseline would make every tab look new. + if (!activePreviewState.listLoaded) return; const threadKey = scopedThreadKey(activeThreadRef); - const serverTabIds = Object.values(activePreviewState.sessions) - .filter((session) => session.runtime === "server") - .map((session) => session.tabId); + const serverSessions = Object.values(activePreviewState.sessions).filter( + (session) => session.runtime === "server", + ); const previous = previousServerPreviewTabs.current.get(threadKey); - previousServerPreviewTabs.current.set(threadKey, new Set(serverTabIds)); + previousServerPreviewTabs.current.set( + threadKey, + new Set(serverSessions.map((session) => session.tabId)), + ); if (!previous || !autoShowFloatingPreview) return; - for (const tabId of serverTabIds) { - if (previous.has(tabId)) continue; + for (const session of serverSessions) { + if (previous.has(session.tabId) || session.reveal !== true) continue; const shown = rightPanelState.surfaces.some( - (surface) => surface.kind === "preview" && surface.resourceId === tabId, + (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, ); - if (!shown && !isLocalServerTab(activeThreadRef, tabId)) { - usePreviewMiniPlayerStore.getState().open(activeThreadRef, browserMiniPlayerSource(tabId)); + if (!shown) { + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, browserMiniPlayerSource(session.tabId)); } } }, [ activeEnvironmentServerBrowser, + activePreviewState.listLoaded, activePreviewState.sessions, activeThreadRef, autoShowFloatingPreview, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3caa82ade4c3..a51156706673 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -47,6 +47,7 @@ import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; +import { useEnvironmentSupportsServerBrowser } from "~/state/entities"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -930,6 +931,7 @@ export default function FilePreviewPanel({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const environmentServerBrowser = useEnvironmentSupportsServerBrowser(environmentId); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); @@ -1032,7 +1034,7 @@ export default function FilePreviewPanel({ previewPath !== null && attachment === undefined && !isVideo && - isPreviewSupportedInRuntime() && + (isPreviewSupportedInRuntime() || environmentServerBrowser) && isBrowserPreviewFile(previewPath); const absolutePath = relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index b20a188791f9..5d2b78ce4cc5 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -55,8 +55,6 @@ describe("addBrowserSurface", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: "profile-work", - // No Electron bridge here, so the environment hosts the tab. - runtime: "server", }); }); @@ -73,7 +71,6 @@ describe("addBrowserSurface", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, - runtime: "server", }); expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); expect( diff --git a/apps/web/src/components/preview/localServerTabs.ts b/apps/web/src/components/preview/localServerTabs.ts deleted file mode 100644 index 106b44e04240..000000000000 --- a/apps/web/src/components/preview/localServerTabs.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; - -/** - * Server tabs this client opened, so the floating player pops up only for tabs - * an agent opens. A tab can reach the session stream before its open call - * returns, so an open still in flight counts for its whole thread. - */ -const pendingOpens = new Map(); -const openedTabs = new Set(); - -const tabKey = (threadKey: string, tabId: string) => JSON.stringify([threadKey, tabId]); - -export async function trackLocalServerTabOpen( - threadRef: ScopedThreadRef, - open: () => Promise, -): Promise { - const threadKey = scopedThreadKey(threadRef); - pendingOpens.set(threadKey, (pendingOpens.get(threadKey) ?? 0) + 1); - try { - const result = await open(); - if (result._tag === "Success" && "value" in result) { - const snapshot = result.value as PreviewSessionSnapshot; - openedTabs.add(tabKey(threadKey, snapshot.tabId)); - } - return result; - } finally { - const remaining = (pendingOpens.get(threadKey) ?? 1) - 1; - if (remaining > 0) pendingOpens.set(threadKey, remaining); - else pendingOpens.delete(threadKey); - } -} - -export function isLocalServerTab(threadRef: ScopedThreadRef, tabId: string): boolean { - const threadKey = scopedThreadKey(threadRef); - return openedTabs.has(tabKey(threadKey, tabId)) || pendingOpens.has(threadKey); -} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 09bf8e6548c1..338f92cf68c0 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -9,17 +9,18 @@ import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/op import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; -import { previewBridge } from "./previewBridge"; +import { previewRuntimeFor } from "~/browser/previewRuntime"; export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; }): Promise> { - // Without the desktop bridge the tab runs on the environment, where loopback is already right. - const resolvedUrl = previewBridge - ? resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url) - : input.port.url; + // A server tab runs on the environment, where loopback is already right. + const resolvedUrl = + previewRuntimeFor(input.threadRef.environmentId) === "server" + ? input.port.url + : resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index d3b4fcb8e9b0..70751aef8342 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -62,8 +62,6 @@ describe("openPreviewSession", () => { threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, - // No Electron bridge here, so the environment hosts the tab. - runtime: "server", }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); @@ -83,7 +81,6 @@ describe("openPreviewSession", () => { url: "t3.chat", viewport: FILL_PREVIEW_VIEWPORT, profileId: DEFAULT_BROWSER_PROFILE_ID, - runtime: "server", }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); @@ -138,7 +135,6 @@ describe("openPreviewSession", () => { url: input.url, viewport, profileId: "work", - runtime: "server", }, }); }, diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 6a7f4f139b9c..456d98d13543 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -15,11 +15,9 @@ import { resolveBrowserDefaults, } from "~/browser/browserDefaults"; import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; +import { previewRuntimeFor } from "~/browser/previewRuntime"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; -import { trackLocalServerTabOpen } from "./localServerTabs"; -import { previewBridge } from "./previewBridge"; - interface OpenPreviewSessionInput { openPreview: (input: { readonly environmentId: EnvironmentId; @@ -44,22 +42,17 @@ export async function openPreviewSession( if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const open = () => - input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { - threadId: input.threadRef.threadId, - ...(input.url === undefined ? {} : { url: input.url }), - viewport: input.viewport ?? browserDefaultOpenViewport(defaults), - profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), - // Only Electron can host a desktop tab; every other client asks the - // environment to run the page in its own browser. - ...(previewBridge ? {} : { runtime: "server" as const }), - }, - }); - const result = previewBridge - ? await open() - : await trackLocalServerTabOpen(input.threadRef, open); + const runtime = previewRuntimeFor(input.threadRef.environmentId); + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + ...(input.url === undefined ? {} : { url: input.url }), + viewport: input.viewport ?? browserDefaultOpenViewport(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), + ...(runtime === undefined ? {} : { runtime }), + }, + }); if (result._tag === "Failure") { return result; } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 23ef35a87829..0dfd5ec5e994 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -9,8 +9,9 @@ import { } from "~/browser/browserDefaults"; import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime"; import { recordVisitForThread } from "~/browserHistoryStore"; -import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { applyPreviewServerSnapshot } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; const terminalLinkErrorContext = { @@ -48,7 +49,7 @@ export async function openTerminalLinkInPreview( const supportsPreview = !input.forceBrowser && isWebUrl(input.url) && - isPreviewSupportedInRuntime() && + isPreviewAvailableFor(input.threadRef.environmentId) && input.threadRef.threadId.length > 0 && (await resolveBrowserLinkTargetPreference()) === "app"; @@ -64,6 +65,7 @@ export async function openTerminalLinkInPreview( }; const defaults = await resolveBrowserDefaults(); + const runtime = previewRuntimeFor(input.threadRef.environmentId); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -73,6 +75,7 @@ export async function openTerminalLinkInPreview( // mapping, so the configured defaults are applied explicitly. viewport: browserDefaultOpenViewport(defaults), profileId: browserDefaultOpenProfileId(defaults), + ...(runtime === undefined ? {} : { runtime }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 26be8c497740..62934e8ea895 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -43,6 +43,8 @@ export interface ThreadPreviewState { desktopOverlay: DesktopPreviewOverlay | null; desktopByTabId: Record; recentlySeenUrls: string[]; + /** Whether the first authoritative tab list has arrived. */ + listLoaded: boolean; /** Server process currently authoritative for revision ordering. */ serverEpoch: string | null; /** Latest ordered server revision applied from a list response or event. */ @@ -57,6 +59,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ desktopOverlay: null, desktopByTabId: {}, recentlySeenUrls: [] as string[], + listLoaded: false, serverEpoch: null, serverRevision: 0, }); @@ -311,7 +314,9 @@ export function reconcilePreviewServerSessions( ): void { updateThreadPreviewState(ref, (current) => { const sameServer = current.serverEpoch === result.serverEpoch; - if (sameServer && result.revision < current.serverRevision) return current; + if (sameServer && result.revision < current.serverRevision) { + return current.listLoaded ? current : { ...current, listLoaded: true }; + } const snapshots = result.sessions; const sessions: Record = {}; const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set(); @@ -349,6 +354,7 @@ export function reconcilePreviewServerSessions( desktopByTabId, desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, recentlySeenUrls, + listLoaded: true, serverEpoch: result.serverEpoch, serverRevision: result.revision, }; diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index c8e86cbec21a..809af2c522c2 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -5,8 +5,9 @@ * The environment runs the page in headless Chromium and streams it over * `/api/preview-stream/ws`. Binary messages are complete JPEG frames; text * messages report the page viewport in CSS px. Input goes back as JSON in page - * CSS px. The server keeps one frame in flight per socket, so a slow viewer - * gets fewer frames instead of a growing buffer. + * CSS px. The client acknowledges every frame and the server keeps only a few + * unacknowledged, so a slow viewer gets fewer frames instead of a growing + * buffer. * * The socket reconnects with backoff while the owner keeps it running. A * refused upgrade stops it and asks the owner for fresh credentials. @@ -112,6 +113,7 @@ export interface PreviewStreamClient { readonly stop: () => void; } +const ACK_MESSAGE = JSON.stringify({ type: "ack" }); const RETRY_BASE_MS = 500; const RETRY_MAX_MS = 10_000; @@ -157,6 +159,7 @@ export function createPreviewStreamClient( if (socket !== ws) return; if (event.data instanceof ArrayBuffer) { failures = 0; + ws.send(ACK_MESSAGE); events.onFrame(event.data); return; } diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 9a8dc8858915..308c98c74544 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -186,6 +186,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ */ profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), + /** An agent opened this tab and asked to show it, so viewers float it. */ + reveal: Schema.optional(Schema.Boolean), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; @@ -205,6 +207,8 @@ export const PreviewOpenInput = Schema.Struct({ profileId: Schema.optional(BrowserProfileId), /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */ runtime: Schema.optional(PreviewRuntime), + /** Set by agent opens that should float for viewers; see the snapshot field. */ + reveal: Schema.optional(Schema.Boolean), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; From db37859e0b6ee2a7e8ae6032af3b2b22e1a93dbc Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:57:38 +0000 Subject: [PATCH 03/68] fix(preview): restore presentation and cold client synchronization --- .../features/browser/ThreadBrowserFloat.tsx | 13 +++-- apps/mobile/src/state/preview.ts | 2 + apps/server/src/preview/Manager.test.ts | 38 +++++++++++++ apps/server/src/preview/Manager.ts | 38 +++++++++++++ apps/server/src/preview/ServerBrowser.ts | 56 +++++++++++++------ apps/web/src/components/ChatView.tsx | 33 +++++++---- .../components/preview/usePreviewSession.ts | 2 + packages/contracts/src/preview.ts | 2 + scripts/lib/cli-external-packages.test.ts | 2 +- scripts/lib/cli-external-packages.ts | 2 + 10 files changed, 156 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index c33127a2226b..a934923f2639 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -27,15 +27,20 @@ export function ThreadBrowserFloat(props: { readonly onOpen: (tabId: string) => void; }) { const [tabId, setTabId] = useState(null); - const known = useRef | null>(null); + const known = useRef | null>(null); useEffect(() => { if (!props.loaded) return; - const ids = props.tabs.map((tab) => tab.tabId); const previous = known.current; - known.current = new Set(ids); + known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])); // The first list is a baseline, so reopening a thread does not resurface old tabs. if (previous === null) return; - const opened = props.tabs.findLast((tab) => tab.reveal === true && !previous.has(tab.tabId)); + const opened = props.tabs.findLast( + (tab) => + tab.reveal === true && + (tab.revealRequest + ? previous.get(tab.tabId) !== tab.revealRequest.id + : !previous.has(tab.tabId)), + ); if (opened !== undefined) setTabId(opened.tabId); }, [props.loaded, props.tabs]); const focused = useIsFocused(); diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index 0bb7ff4fdb74..38d3d5d0db4c 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -143,6 +143,8 @@ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { } publish(applyEvent(state, event)); }); + get.mount(listAtom); + get.mount(eventsAtom); const cached = get.once(listAtom); if (AsyncResult.isSuccess(cached)) state = applyList(cached.value) ?? state; // The cached list can predate an agent-opened tab. diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index cdf4a5af828a..40d1d4093207 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -92,6 +92,44 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan }), ); + it.effect("reissues presentation requests without replaying them on navigation", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const opened = yield* manager.open({ threadId, runtime: "server", reveal: false }); + yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true }); + const first = (yield* manager.list({ threadId })).sessions[0]; + expect(first?.reveal).toBe(true); + expect(first?.revealRequest?.force).toBe(true); + const navigated = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "localhost:5173", + }); + expect(navigated.revealRequest).toEqual(first?.revealRequest); + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, + canGoBack: false, + canGoForward: false, + }); + expect((yield* manager.list({ threadId })).sessions[0]?.revealRequest).toEqual( + first?.revealRequest, + ); + yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: false }); + const second = (yield* manager.list({ threadId })).sessions[0]; + expect(second?.revealRequest?.id).not.toBe(first?.revealRequest?.id); + expect(second?.revealRequest?.force).toBe(false); + const events = yield* collector.drain; + const last = events.at(-1); + expect(last?.type).toBe("navigated"); + if (last?.type === "navigated") + expect(last.snapshot.revealRequest).toEqual(second?.revealRequest); + }), + ); + it.effect("opens an Idle tab when no URL is supplied", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 83712712b4a9..f52af9559644 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -50,6 +50,9 @@ export class PreviewManager extends Context.Service< input: PreviewNavigateInput, ) => Effect.Effect; readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly requestReveal: ( + input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, + ) => Effect.Effect; readonly resize: ( input: PreviewResizeInput, ) => Effect.Effect; @@ -308,6 +311,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { ? {} : { runtime: session.snapshot.runtime }), ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), + ...(session.snapshot.revealRequest === undefined + ? {} + : { revealRequest: session.snapshot.revealRequest }), updatedAt, }; return { @@ -346,6 +352,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { : { profileId: session.snapshot.profileId }), ...(session.snapshot.runtime === undefined ? {} : { runtime: session.snapshot.runtime }), ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), + ...(session.snapshot.revealRequest === undefined + ? {} + : { revealRequest: session.snapshot.revealRequest }), updatedAt, }; const emit: PreviewEventDraft = @@ -465,8 +474,37 @@ export const make = Effect.gen(function* PreviewManagerMake() { }, ); + const requestReveal: PreviewManager["Service"]["requestReveal"] = Effect.fn( + "PreviewManager.requestReveal", + )(function* (input) { + yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.revealSession")(function* (session) { + const snapshot = { + ...session.snapshot, + reveal: true, + revealRequest: { id: NodeCrypto.randomUUID(), force: input.force }, + updatedAt: yield* currentIsoTimestamp, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "navigated" as const, + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: undefined, + }; + }), + ); + }); + return PreviewManager.of({ open, + requestReveal, navigate, reportStatus, resize, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 7961d321f500..45872c67651c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -185,6 +185,8 @@ interface ViewerState { readonly resume: () => Promise; /** Last wheel input from this viewer, for motion mode. */ scrolledAt: number; + /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ + requestedSize: { width: number; height: number; order: number } | null; } interface Recording { @@ -257,6 +259,7 @@ const make = Effect.gen(function* () { const pendingTabs = new Map>(); const contexts = new Map>(); let hostConnectionId: string | null = null; + let viewerResizeOrder = 0; const profilesDir = NodePath.join(config.stateDir, "server-browser", "profiles"); @@ -373,8 +376,13 @@ const make = Effect.gen(function* () { const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => { tab.setting = setting; - const size = fixedViewportSize(setting); - if (size) await tab.page.setViewportSize(size); + const size = + fixedViewportSize(setting) ?? + [...tab.viewers] + .map((viewer) => viewer.requestedSize) + .filter((requested) => requested !== null) + .sort((left, right) => right.order - left.order)[0]; + if (size) await tab.page.setViewportSize({ width: size.width, height: size.height }); broadcastViewport(tab); }; @@ -853,8 +861,20 @@ const make = Effect.gen(function* () { reuse && request.tabId !== undefined ? tabs.get(tabKey(request.threadId, request.tabId)) : undefined; + const reveal = open.open ?? open.show; + const revealTab = async (tab: ServerTab) => { + if (reveal === false) return; + await Effect.runPromise( + manager.requestReveal({ + threadId: tab.threadId, + tabId: tab.tabId, + force: reveal === true, + }), + ); + }; if (existing) { if (url) await navigate(existing, url, "load", request.timeoutMs); + await revealTab(existing); return statusWithTitle(existing); } const snapshot = await Effect.runPromise( @@ -862,10 +882,11 @@ const make = Effect.gen(function* () { threadId: request.threadId, ...(url ? { url } : {}), runtime: "server", - reveal: (open.open ?? open.show) !== false, + reveal: false, }), ); const tab = await ensureTab(snapshot); + await revealTab(tab); if (url) { await tab.page .waitForLoadState("load", { timeout: request.timeoutMs }) @@ -963,14 +984,15 @@ const make = Effect.gen(function* () { return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt }; } case "recordingStop": { + const recordings = [...tabs.values()].filter( + (candidate) => + candidate.threadId === request.threadId && + (candidate.recording || candidate.recordingStart), + ); + const targetTabId = request.tabId ?? latestThreadTab(request.threadId)?.tabId; const tab = - request.tabId === undefined - ? [...tabs.values()].find( - (candidate) => - candidate.threadId === request.threadId && - (candidate.recording || candidate.recordingStart), - ) - : await requireTab(request); + recordings.find((candidate) => candidate.tabId === targetTabId) ?? + (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined); if (!tab) { throw new ServerBrowserPage.ServerBrowserOperationError( "PreviewAutomationRecordingNotActiveError", @@ -1090,15 +1112,14 @@ const make = Effect.gen(function* () { } return; case "resize": { - const width = Math.round(num(message.width)); - const height = Math.round(num(message.height)); - if (tab.setting._tag !== "fill" || width < 100 || height < 100) return; + const width = Math.min(Math.round(num(message.width)), 3840); + const height = Math.min(Math.round(num(message.height)), 2160); + if (width < 100 || height < 100) return; + viewer.requestedSize = { width, height, order: ++viewerResizeOrder }; + if (tab.setting._tag !== "fill") return; const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; - await tab.page.setViewportSize({ - width: Math.min(width, 3840), - height: Math.min(height, 2160), - }); + await tab.page.setViewportSize({ width, height }); broadcastViewport(tab); return; } @@ -1194,6 +1215,7 @@ const make = Effect.gen(function* () { }, resume: () => startScreencast(screencastScale), scrolledAt: 0, + requestedSize: null, }; yield* Effect.acquireRelease( Effect.sync(() => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ee0b5de7d30..855813f97a85 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2267,6 +2267,7 @@ export default function ChatView(props: ChatViewProps) { ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; + const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded; const resolvePreviewRuntimeTabId = useMemo( () => activeThreadRef @@ -2341,21 +2342,26 @@ export default function ChatView(props: ChatViewProps) { ); useEffect(() => { - if (!activeThreadRef) return; + if (!activeThreadRef || !previewSessionsReady) return; useRightPanelStore .getState() .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); - }, [activePreviewState.sessions, activeThreadRef]); + }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); useEffect(() => { - if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return; + if ( + !activeThreadRef || + !previewSessionsReady || + activePreviewMiniPlayer?.source.kind !== "browser" + ) + return; const miniTabStillExists = Boolean( activePreviewState.sessions[activePreviewMiniPlayer.source.tabId], ); if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); + }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -5360,7 +5366,7 @@ export default function ChatView(props: ChatViewProps) { // desktop automation host does for its own tabs. Tabs present when the // thread's preview state first loads are a baseline, so reloads do not // resurrect them, and tabs already shown in a surface stay where they are. - const previousServerPreviewTabs = useRef(new Map>()); + const previousServerPreviewTabs = useRef(new Map>()); useEffect(() => { if (!activeThreadRef || !activeEnvironmentServerBrowser) return; // Nothing has loaded yet; an empty baseline would make every tab look new. @@ -5372,15 +5378,22 @@ export default function ChatView(props: ChatViewProps) { const previous = previousServerPreviewTabs.current.get(threadKey); previousServerPreviewTabs.current.set( threadKey, - new Set(serverSessions.map((session) => session.tabId)), + new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])), ); - if (!previous || !autoShowFloatingPreview) return; + if (!previous) return; for (const session of serverSessions) { - if (previous.has(session.tabId) || session.reveal !== true) continue; - const shown = rightPanelState.surfaces.some( + const requested = session.revealRequest; + const fresh = requested + ? previous.get(session.tabId) !== requested.id + : !previous.has(session.tabId); + if (!fresh || session.reveal !== true) continue; + if (!autoShowFloatingPreview && requested?.force !== true) continue; + const surface = rightPanelState.surfaces.find( (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, ); - if (!shown) { + if (surface && requested?.force === true) { + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + } else if (!surface) { usePreviewMiniPlayerStore .getState() .open(activeThreadRef, browserMiniPlayerSource(session.tabId)); diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index dc1b310daf50..c99b46cc145e 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -67,6 +67,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { eventsVersion += 1; applyLatestEvent(result); }); + get.mount(sessionsAtom); + get.mount(eventsAtom); queueMicrotask(() => { if (disposed) return; // The cached list can predate an automation-created tab. Keep the local diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 308c98c74544..3cdf7ce70cd9 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -188,6 +188,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ runtime: Schema.optional(PreviewRuntime), /** An agent opened this tab and asked to show it, so viewers float it. */ reveal: Schema.optional(Schema.Boolean), + /** A fresh presentation request, including whether it overrides automatic-floating settings. */ + revealRequest: Schema.optional(Schema.Struct({ id: Schema.String, force: Schema.Boolean })), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index d3255f9edf06..33c2e897b047 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -83,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => { it("selects every external root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"], + ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty", "playwright-core"], ); }); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 32e61e66f73e..a1846e1175c2 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -28,6 +28,8 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ // Cursor ships computed Webpack imports and platform helper packages. "@cursor/sdk", + // Playwright reads package.json and browsers.json beside its runtime modules. + "playwright-core", "node-pty", "ffi-rs", "@yuuang/", From 4ed7d74f3ce09e409bf4dccc3acc8edb99309993 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:04:35 +0000 Subject: [PATCH 04/68] fix(preview): handle cold opens and recover stream credentials --- .../features/browser/PreviewStreamWebView.tsx | 8 +++- .../src/mcp/PreviewAutomationBroker.test.ts | 38 +++++++++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 6 ++- apps/server/src/preview/ServerBrowser.ts | 32 ++++++++++------ .../src/preview/ServerBrowserToolchain.ts | 13 ++----- apps/web/src/browser/ServerBrowserSurface.tsx | 19 +++++++++- .../components/preview/usePreviewSession.ts | 7 ++++ apps/web/src/previewStateStore.test.ts | 13 +++++++ apps/web/src/previewStateStore.ts | 2 +- knip.jsonc | 1 + .../src/preview/serverBrowserStream.ts | 8 +--- 11 files changed, 115 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index eb7ff27d34c5..2623827c6fbd 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -56,6 +56,12 @@ export function PreviewStreamWebView({ ...props }: PreviewStreamConfiguration & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); + const [previousAccess, setPreviousAccess] = useState(props.access); + // Wait for refreshed access, including cookie credentials with unchanged JSON. + if (props.access !== previousAccess) { + setPreviousAccess(props.access); + setAttempt((current) => current + 1); + } const processRetried = useRef(false); const unauthorized = useRef(0); const refreshTimer = useRef | null>(null); @@ -91,7 +97,6 @@ export function PreviewStreamWebView({ refreshTimer.current = setTimeout( () => { refreshTimer.current = null; - setAttempt((current) => current + 1); props.onUnauthorized(); }, refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1), @@ -101,7 +106,6 @@ export function PreviewStreamWebView({ onRetry={() => { processRetried.current = false; unauthorized.current = 0; - setAttempt((current) => current + 1); props.onUnauthorized(); }} onStreaming={() => { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index bb3b3183baa5..5488c1a39eaf 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -332,6 +332,44 @@ it.effect("announces a live replacement stream before delivering requests", () = ), ); +it.effect( + "keeps a server-host open alive for installation without extending other operations", + () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const received = yield* Deferred.make(); + const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true })); + yield* Stream.runForEach(requests, (request) => + request.operation === "open" + ? Deferred.succeed(received, request) + : broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: request.timeoutMs, + }), + ).pipe(Effect.forkScoped); + const opening = yield* broker + .invoke({ scope, operation: "open", input: {} }) + .pipe(Effect.forkScoped); + const request = yield* Deferred.await(received); + yield* TestClock.adjust(16_000); + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "opened", + }); + expect(yield* Fiber.join(opening)).toBe("opened"); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe(15_000); + expect(yield* broker.invoke({ scope, operation: "navigate", input: {} })).toBe(15_000); + }), + ), +); + it.effect("preserves bounded request and remote selector diagnostics", () => { const locator = "role=button[name='request-secret']"; const remoteMessage = "Unexpected token near remote-secret."; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 2608e235b8fd..e0a24164cbce 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -487,7 +487,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( input: Parameters[0], ): Effect.fn.Return { - const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); const route = yield* SynchronizedRef.modify(state, (current) => { const assignments = new Map( @@ -541,6 +540,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { if (!hasLiveAssignment) assignments.delete(assignmentKey); return [undefined, { ...current, assignments }] as const; } + // The environment host may install Chromium on its first open (up to + // ten minutes). Keep that request alive without replaying its effects. + const timeoutMs = + input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000); const canReuseAssignedTab = assigned !== undefined && assigned.connectionId === connection.connectionId && @@ -589,6 +592,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); } const { connection, requestId, requestContext, requestSequence } = route; + const { timeoutMs } = requestContext; input.onTargetTab?.(requestContext.tabId); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 45872c67651c..c95945f81688 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -211,6 +211,7 @@ interface ServerTab { loading: boolean; closing: boolean; recording: Recording | null; + initialNavigation: Promise | null; /** The latest queued start, so a stop can find a recording still starting. */ recordingStart: Promise | null; /** Serializes captures and recording start/stop. */ @@ -381,8 +382,9 @@ const make = Effect.gen(function* () { [...tab.viewers] .map((viewer) => viewer.requestedSize) .filter((requested) => requested !== null) - .sort((left, right) => right.order - left.order)[0]; - if (size) await tab.page.setViewportSize({ width: size.width, height: size.height }); + .sort((left, right) => right.order - left.order)[0] ?? + UNATTACHED_FILL_VIEWPORT; + await tab.page.setViewportSize({ width: size.width, height: size.height }); broadcastViewport(tab); }; @@ -419,6 +421,7 @@ const make = Effect.gen(function* () { closing: false, recording: null, recordingStart: null, + initialNavigation: null, captureLock: Promise.resolve(), capturing: 0, }; @@ -502,21 +505,25 @@ const make = Effect.gen(function* () { tabs.set(tabKey(tab.threadId, tab.tabId), tab); reportLiveTabs(); if (snapshot.navStatus._tag === "Loading") { - void page.goto(snapshot.navStatus.url).catch(() => undefined); + tab.initialNavigation = page + .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS }) + .then( + () => undefined, + () => undefined, + ); } return tab; }; const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { const key = tabKey(snapshot.threadId, snapshot.tabId); + const pending = pendingTabs.get(key); + if (pending) return pending; const existing = tabs.get(key); if (existing) return Promise.resolve(existing); - let pending = pendingTabs.get(key); - if (!pending) { - pending = createTab(snapshot).finally(() => pendingTabs.delete(key)); - pendingTabs.set(key, pending); - } - return pending; + const opening = createTab(snapshot).finally(() => pendingTabs.delete(key)); + pendingTabs.set(key, opening); + return opening; }; /** Server tabs that exist in PreviewManager but have not finished opening here. */ @@ -872,8 +879,9 @@ const make = Effect.gen(function* () { }), ); }; + const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); if (existing) { - if (url) await navigate(existing, url, "load", request.timeoutMs); + if (url) await navigate(existing, url, "load", navigationTimeout); await revealTab(existing); return statusWithTitle(existing); } @@ -886,10 +894,12 @@ const make = Effect.gen(function* () { }), ); const tab = await ensureTab(snapshot); + // Wait for the requested document without blocking manager events for other tabs. + await tab.initialNavigation; await revealTab(tab); if (url) { await tab.page - .waitForLoadState("load", { timeout: request.timeoutMs }) + .waitForLoadState("load", { timeout: navigationTimeout }) .catch(() => undefined); } return statusWithTitle(tab); diff --git a/apps/server/src/preview/ServerBrowserToolchain.ts b/apps/server/src/preview/ServerBrowserToolchain.ts index 6910e8ef6da2..b5a3bc5d01b8 100644 --- a/apps/server/src/preview/ServerBrowserToolchain.ts +++ b/apps/server/src/preview/ServerBrowserToolchain.ts @@ -298,7 +298,7 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - /** Stages, publishes, and prunes `/tools//` unless it is already complete. */ + /** Stages and atomically publishes `/tools//` if not already complete. */ const installOnce = Effect.fn("ServerBrowserToolchain.installOnce")(function* ( tool: string, version: string, @@ -310,9 +310,6 @@ const make = Effect.gen(function* () { const installDir = path.join(parentDir, version); if (yield* isInstalled(installDir, version)) return; - yield* fs - .remove(installDir, { recursive: true, force: true }) - .pipe(Effect.mapError(fail("removing an incomplete install"))); yield* fs .makeDirectory(parentDir, { recursive: true }) .pipe(Effect.mapError(fail("preparing the install directory"))); @@ -325,6 +322,8 @@ const make = Effect.gen(function* () { yield* fs .writeFileString(path.join(stagingDir, SENTINEL), `${version}\n`) .pipe(Effect.mapError(fail("recording the completed install"))); + // Published versions may be in use by other servers. Never remove them; + // rename cannot replace a completed, nonempty install directory. yield* fs.rename(stagingDir, installDir).pipe( Effect.catch((cause) => // A concurrent server may have published the same version first. @@ -335,12 +334,6 @@ const make = Effect.gen(function* () { ), ), ); - // Only runs right after an upgrade; dot entries are other installs' staging dirs. - const names = yield* fs.readDirectory(parentDir).pipe(Effect.orElseSucceed(() => [])); - yield* Effect.forEach( - names.filter((name) => !name.startsWith(".") && name !== version), - (name) => fs.remove(path.join(parentDir, name), { recursive: true, force: true }), - ).pipe(Effect.ignore); }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), ); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 60f0c922e1c5..0eee5b34976d 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -125,6 +125,7 @@ export function ServerBrowserSurface(props: { const containerRef = useRef(null); const canvasRef = useRef(null); const inputRef = useRef(null); + const keySentRef = useRef(false); const clientRef = useRef(null); const viewportRef = useRef(null); const sizeRef = useRef<{ width: number; height: number } | null>(null); @@ -535,6 +536,7 @@ export function ServerBrowserSurface(props: { }; const resetInput = (textarea: HTMLTextAreaElement) => { + keySentRef.current = false; textarea.value = INPUT_SENTINEL; textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length); }; @@ -545,6 +547,7 @@ export function ServerBrowserSurface(props: { }; const handleKey = (action: "down" | "up", event: KeyboardEvent) => { + keySentRef.current = false; // IME and soft keyboards deliver text through composition and input events. if ( event.nativeEvent.isComposing || @@ -554,6 +557,13 @@ export function ServerBrowserSurface(props: { ) { return; } + // Keep plain Escape and Tab in the page; Shift+Escape returns to the app. + if (event.key === "Escape" && event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + if (action === "down") event.currentTarget.blur(); + return; + } const shortcut = event.ctrlKey || event.metaKey; // Paste arrives as a paste event carrying this device's clipboard. if (shortcut && event.key.toLowerCase() === "v") return; @@ -574,6 +584,11 @@ export function ServerBrowserSurface(props: { ...(action === "down" && text !== undefined ? { text } : {}), modifiers: previewStreamModifiers(event), }); + // Some Android keyboards edit the textarea even when keydown is prevented. + keySentRef.current = + action === "down" && + !shortcut && + (text !== undefined || event.key === "Backspace" || event.key === "Delete"); // Shortcuts stay with the app (copy, paste, keybindings); other keys belong to the page. if (shortcut) return; event.preventDefault(); @@ -585,7 +600,8 @@ export function ServerBrowserSurface(props: { if (native instanceof InputEvent && native.isComposing) return; const textarea = event.currentTarget; const inputType = native instanceof InputEvent ? native.inputType : ""; - if (inputType === "deleteContentBackward") sendKeyPress(BACKSPACE); + if (keySentRef.current) keySentRef.current = false; + else if (inputType === "deleteContentBackward") sendKeyPress(BACKSPACE); else if (inputType === "deleteContentForward") sendKeyPress(DELETE); else { const text = textarea.value.replaceAll(INPUT_SENTINEL, ""); @@ -627,6 +643,7 @@ export function ServerBrowserSurface(props: { { const reconcileSessions = (result: Atom.Type) => { if (!AsyncResult.isSuccess(result)) return; reconcilePreviewServerSessions(threadRef, result.value); + if (!result.waiting && !readThreadPreviewState(threadRef).listLoaded) { + // An event overtook the first list. Retry the authoritative baseline; + // do not declare a partial event-only index ready for surface cleanup. + queueMicrotask(() => { + if (!disposed) get.refresh(sessionsAtom); + }); + } }; const applyLatestEvent = (result: Atom.Type) => { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index f9956f9c02c8..9fa1fae7e54f 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -466,6 +466,19 @@ describe("previewStateStore (single-tab)", () => { reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 }); expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot }); + expect(readThreadPreviewState(ref).listLoaded).toBe(false); + + const olderTab = makeSnapshot({ tabId: "tab_before_event" }); + reconcilePreviewServerSessions(ref, { + sessions: [olderTab, snapshot], + serverEpoch, + revision: 2, + }); + expect(readThreadPreviewState(ref).listLoaded).toBe(true); + expect(readThreadPreviewState(ref).sessions).toEqual({ + [olderTab.tabId]: olderTab, + [snapshot.tabId]: snapshot, + }); }); it("does not resurrect a tab from an event older than its close", () => { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 62934e8ea895..13ce14a26dfa 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -315,7 +315,7 @@ export function reconcilePreviewServerSessions( updateThreadPreviewState(ref, (current) => { const sameServer = current.serverEpoch === result.serverEpoch; if (sameServer && result.revision < current.serverRevision) { - return current.listLoaded ? current : { ...current, listLoaded: true }; + return current; } const snapshots = result.sessions; const sessions: Record = {}; diff --git a/knip.jsonc b/knip.jsonc index 5ba7414158a7..24d0f85db881 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -77,6 +77,7 @@ "plugins/*.cjs", "scripts/generate-device-stream.mts", "src/features/devices/device-stream.browser.ts!", + "src/features/browser/preview-stream.browser.ts!", ], // Fonts are configured as asset paths; Expo autolinks the native packages. "ignoreDependencies": [ diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 809af2c522c2..0ab46732d8dc 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -188,12 +188,8 @@ export function createPreviewStreamClient( events.onGone?.(); return; } - // A rejected upgrade surfaces as 1006 before open, including an expired ticket. - if ( - event.code === 1008 || - event.code === 4401 || - (!opened && event.code === 1006 && target.access.query.wsTicket !== undefined) - ) { + // Rejected upgrades surface as 1006 before open for both cookies and tickets. + if (event.code === 1008 || event.code === 4401 || (!opened && event.code === 1006)) { stopped = true; events.onUnauthorized(); return; From b1f58466dc5df5fc6f5cab4f9dc9b2049625f647 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:52:05 +0000 Subject: [PATCH 05/68] fix(preview): isolate restarted tabs and forward mouse chords --- .../browser/preview-stream.browser.ts | 77 ++++++++++++----- apps/server/src/preview/Manager.ts | 3 +- apps/server/src/preview/ServerBrowser.ts | 7 +- apps/server/src/preview/ServerBrowserPage.ts | 2 +- .../server/src/preview/ServerBrowserStream.ts | 2 +- .../src/preview/ServerBrowserToolchain.ts | 2 +- apps/web/src/browser/ServerBrowserSurface.tsx | 82 ++++++++++++++----- apps/web/src/components/ChatView.tsx | 11 ++- apps/web/src/rightPanelStore.test.ts | 26 ++++++ apps/web/src/rightPanelStore.ts | 10 ++- .../src/preview/serverBrowserStream.ts | 2 +- 11 files changed, 172 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index f88aa0895fd9..bd0fae9c161d 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -144,7 +144,8 @@ export function start(configuration: PreviewStreamConfiguration) { let pendingWheel: WheelInput | null = null; let mouseFrame: number | null = null; let pendingMouse: MouseInput | null = null; - let mousePressed: MouseInput["button"] | null = null; + let mouseButtons = 0; + const mouseClicks = { left: 1, middle: 1, right: 1, none: 1 }; // The latest probe. `editable` is the answer its tap acts on, null until known; // `end` records what touch end did, so a late answer can still act or correct it. let probe: { @@ -349,15 +350,18 @@ export function start(configuration: PreviewStreamConfiguration) { input.focus({ preventScroll: true }); flushMouse(); flushWheel(); - mousePressed = mouseButton(event.button); + mouseButtons = event.buttons & 7; + const button = mouseButton(event.button); + const clickCount = countClick(event, button); + mouseClicks[button] = clickCount; send({ type: "mouse", action: "down", x: point.x, y: point.y, - button: mousePressed, + button, buttons: event.buttons, - clickCount: countClick(event, mousePressed), + clickCount, modifiers: previewStreamModifiers(event), }); return; @@ -393,14 +397,44 @@ export function start(configuration: PreviewStreamConfiguration) { }; const onPointerMove = (event: PointerEvent) => { if (event.pointerType !== "touch") { - const point = pagePoint(event.clientX, event.clientY, mousePressed !== null); + const point = pagePoint(event.clientX, event.clientY, mouseButtons !== 0); if (!point) return; + // Chorded presses and releases arrive as pointermove while another button is held. + const changed = mouseButtons ^ (event.buttons & 7); + if (mouseButtons !== 0 && changed !== 0) { + flushMouse(); + flushWheel(); + for (const bit of [1, 2, 4]) { + if (!(changed & bit)) continue; + const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle"; + const down = (event.buttons & bit) !== 0; + mouseButtons ^= bit; + if (down) mouseClicks[button] = countClick(event, button); + send({ + type: "mouse", + action: down ? "down" : "up", + x: point.x, + y: point.y, + button, + buttons: mouseButtons, + clickCount: mouseClicks[button], + modifiers: previewStreamModifiers(event), + }); + } + } pendingMouse = { type: "mouse", action: "move", x: point.x, y: point.y, - button: mousePressed ?? "none", + button: + mouseButtons & 1 + ? "left" + : mouseButtons & 2 + ? "right" + : mouseButtons & 4 + ? "middle" + : "none", buttons: event.buttons, clickCount: 0, modifiers: previewStreamModifiers(event), @@ -429,23 +463,28 @@ export function start(configuration: PreviewStreamConfiguration) { touch.lastY = event.clientY; }; const releaseMouse = (event: PointerEvent, cancelled: boolean) => { - const button = mousePressed; - if (button === null) return; - mousePressed = null; + let buttons = mouseButtons; + if (buttons === 0) return; + mouseButtons = cancelled ? 0 : event.buttons & 7; flushMouse(); flushWheel(); const point = pagePoint(event.clientX, event.clientY, true); if (!point) return; - send({ - type: "mouse", - action: "up", - x: point.x, - y: point.y, - button, - buttons: cancelled ? 0 : event.buttons, - clickCount: lastTap?.count ?? 1, - modifiers: previewStreamModifiers(event), - }); + for (const bit of [1, 2, 4]) { + if (!(buttons & bit) || (mouseButtons & bit) !== 0) continue; + buttons &= ~bit; + const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle"; + send({ + type: "mouse", + action: "up", + x: point.x, + y: point.y, + button, + buttons, + clickCount: mouseClicks[button], + modifiers: previewStreamModifiers(event), + }); + } }; const onPointerUp = (event: PointerEvent) => { if (event.pointerType !== "touch") { diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index f52af9559644..ba32fe950873 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -234,7 +234,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")( function* (input) { - const tabId = newPreviewTabId(); + // Persisted client surfaces must not bind to a different tab after a server restart. + const tabId = `${newPreviewTabId()}${input.runtime === "server" ? `_${serverEpoch}` : ""}`; const updatedAt = yield* currentIsoTimestamp; // Clients with a configured default send the viewport up front so the // session is born at the right size; older clients omit it and keep the diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index c95945f81688..b603fd60382a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -507,10 +507,9 @@ const make = Effect.gen(function* () { if (snapshot.navStatus._tag === "Loading") { tab.initialNavigation = page .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS }) - .then( - () => undefined, - () => undefined, - ); + .then(() => undefined); + // Background creation keeps the failed tab; automation awaits the original error. + void tab.initialNavigation.catch(() => undefined); } return tab; }; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 68fd2cc4f9bd..ddb0d791b3b7 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -156,7 +156,7 @@ export const captureViewport = async ( * the render scale, so this needs no image library on the server. */ -export const captureViewportPng = async (page: Page, cdp: CDPSession, renderScale: number) => { +const captureViewportPng = async (page: Page, cdp: CDPSession, renderScale: number) => { const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); const data = await captureViewport(page, cdp, { format: "png", scale }); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 1d27291deabd..f9a2d8e4a7d9 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -19,7 +19,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { authenticateMediaRequest } from "../auth/http.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; -export const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; +const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; /** Matches `PREVIEW_STREAM_TAB_GONE_CODE` in the client. */ const TAB_GONE_CODE = 4404; diff --git a/apps/server/src/preview/ServerBrowserToolchain.ts b/apps/server/src/preview/ServerBrowserToolchain.ts index b5a3bc5d01b8..26fe3b500de2 100644 --- a/apps/server/src/preview/ServerBrowserToolchain.ts +++ b/apps/server/src/preview/ServerBrowserToolchain.ts @@ -41,7 +41,7 @@ import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; /** `@sparticuz/chromium` release; ships Chromium 153.0.8010.0. */ -export const SERVER_BROWSER_VERSION = "153.0.0"; +const SERVER_BROWSER_VERSION = "153.0.0"; /** Bump when `FONTS` changes. */ const FONTS_VERSION = "1"; diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 0eee5b34976d..93becfd367f3 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -136,7 +136,8 @@ export function ServerBrowserSurface(props: { const pendingMoveRef = useRef(null); const pendingWheelRef = useRef(null); const inputFrameRef = useRef(null); - const mousePressedRef = useRef(false); + const mouseButtonsRef = useRef(0); + const mouseClicksRef = useRef({ left: 1, middle: 1, right: 1, none: 1 }); const lastClickRef = useRef<{ button: PreviewStreamMouseButton; time: number; @@ -415,8 +416,10 @@ export function ServerBrowserSurface(props: { if (!point) return; event.currentTarget.setPointerCapture(event.pointerId); flushInput(); - mousePressedRef.current = true; + mouseButtonsRef.current = event.buttons & 7; const button = buttonOf(event.button); + const clickCount = countClick(button, event.clientX, event.clientY, event.timeStamp); + mouseClicksRef.current[button] = clickCount; send({ type: "mouse", action: "down", @@ -424,7 +427,7 @@ export function ServerBrowserSurface(props: { y: point.y, button, buttons: event.buttons, - clickCount: countClick(button, event.clientX, event.clientY, event.timeStamp), + clickCount, modifiers: previewStreamModifiers(event), }); }; @@ -454,8 +457,41 @@ export function ServerBrowserSurface(props: { touch.lastY = event.clientY; return; } - const point = pagePoint(event.clientX, event.clientY, event.buttons !== 0); + const point = pagePoint( + event.clientX, + event.clientY, + mouseButtonsRef.current !== 0 || event.buttons !== 0, + ); if (!point) return; + // Chorded presses and releases arrive as pointermove while another button is held. + const changed = mouseButtonsRef.current ^ (event.buttons & 7); + if (mouseButtonsRef.current !== 0 && changed !== 0) { + flushInput(); + for (const bit of [1, 2, 4]) { + if (!(changed & bit)) continue; + const button = pressedButtonOf(bit); + const down = (event.buttons & bit) !== 0; + mouseButtonsRef.current ^= bit; + if (down) { + mouseClicksRef.current[button] = countClick( + button, + event.clientX, + event.clientY, + event.timeStamp, + ); + } + send({ + type: "mouse", + action: down ? "down" : "up", + x: point.x, + y: point.y, + button, + buttons: mouseButtonsRef.current, + clickCount: mouseClicksRef.current[button], + modifiers: previewStreamModifiers(event), + }); + } + } pendingMoveRef.current = { type: "mouse", action: "move", @@ -494,8 +530,8 @@ export function ServerBrowserSurface(props: { send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); return; } - if (!mousePressedRef.current) return; - mousePressedRef.current = false; + if (mouseButtonsRef.current === 0) return; + mouseButtonsRef.current = event.buttons & 7; const point = pagePoint(event.clientX, event.clientY, true); if (!point) return; flushInput(); @@ -506,7 +542,7 @@ export function ServerBrowserSurface(props: { y: point.y, button: buttonOf(event.button), buttons: event.buttons, - clickCount: lastClickRef.current?.count ?? 1, + clickCount: mouseClicksRef.current[buttonOf(event.button)], modifiers: previewStreamModifiers(event), }); }; @@ -517,22 +553,28 @@ export function ServerBrowserSurface(props: { probeRef.current = null; return; } - if (!mousePressedRef.current) return; - // A cancelled drag still has to release the page's pressed button. - mousePressedRef.current = false; + let buttons = mouseButtonsRef.current; + if (buttons === 0) return; + // A cancelled drag must release every button held in the page. + mouseButtonsRef.current = 0; const point = pagePoint(event.clientX, event.clientY, true); if (!point) return; flushInput(); - send({ - type: "mouse", - action: "up", - x: point.x, - y: point.y, - button: lastClickRef.current?.button ?? "left", - buttons: 0, - clickCount: lastClickRef.current?.count ?? 1, - modifiers: 0, - }); + for (const bit of [1, 2, 4]) { + if (!(buttons & bit)) continue; + buttons &= ~bit; + const button = pressedButtonOf(bit); + send({ + type: "mouse", + action: "up", + x: point.x, + y: point.y, + button, + buttons, + clickCount: mouseClicksRef.current[button], + modifiers: previewStreamModifiers(event), + }); + } }; const resetInput = (textarea: HTMLTextAreaElement) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 855813f97a85..0cae00a362bc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2343,9 +2343,18 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !previewSessionsReady) return; + const hiddenTabIds = new Set( + Object.values(activePreviewState.sessions) + .filter((session) => session.runtime === "server" && session.reveal === false) + .map((session) => session.tabId), + ); useRightPanelStore .getState() - .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + .reconcileBrowserSurfaces( + activeThreadRef, + Object.keys(activePreviewState.sessions), + hiddenTabIds, + ); }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); useEffect(() => { diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 2b75df19d4c4..aa3dfa8e43d9 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1049,6 +1049,32 @@ describe("rightPanelStore", () => { }); }); + it("does not replace a stale surface with a hidden tab, but preserves explicit opens", () => { + const store = useRightPanelStore.getState(); + store.openBrowser(refA, "stale-tab"); + store.reconcileBrowserSurfaces(refA, ["hidden-tab"], new Set(["hidden-tab"])); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA), + ).toMatchObject({ + activeSurfaceId: null, + surfaces: [], + }); + + store.openBrowser(refA, "hidden-tab"); + store.reconcileBrowserSurfaces( + refA, + ["hidden-tab", "other-tab"], + new Set(["hidden-tab", "other-tab"]), + ); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "browser:hidden-tab", + surfaces: [{ id: "browser:hidden-tab", kind: "preview", resourceId: "hidden-tab" }], + }); + }); + it("reconciles browser surfaces without deleting other surface kinds", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openBrowser(refA, "tab-a"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index ec645e62c409..214d4a4abba5 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -163,7 +163,11 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; - reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; + reconcileBrowserSurfaces: ( + ref: ScopedThreadRef, + tabIds: readonly string[], + hiddenTabIds?: ReadonlySet, + ) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; @@ -850,7 +854,7 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), - reconcileBrowserSurfaces: (ref, tabIds) => + reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); @@ -863,7 +867,7 @@ export const useRightPanelStore = create()( ); const knownIds = new Set(existingBrowser.map((surface) => surface.id)); const added = tabIds - .filter((tabId) => !knownIds.has(`browser:${tabId}`)) + .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); const surfaces = [...nonBrowser, ...existingBrowser, ...added]; const activeStillExists = surfaces.some( diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 0ab46732d8dc..9ae5ed426e77 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -71,7 +71,7 @@ export interface PreviewStreamProbe { } /** Close code for a tab that no longer exists. The client stops instead of retrying. */ -export const PREVIEW_STREAM_TAB_GONE_CODE = 4404; +const PREVIEW_STREAM_TAB_GONE_CODE = 4404; /** CDP modifier bitmask: Alt 1, Ctrl 2, Meta 4, Shift 8. */ export const previewStreamModifiers = (event: { From 88bfb70f9f88941beadb70f682e935a765b8d23c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 03:37:53 +0000 Subject: [PATCH 06/68] fix(preview): open client tabs when the server browser is off and skip remote paste shortcut --- .../src/features/browser/preview-stream.browser.ts | 2 ++ apps/server/src/ws.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index bd0fae9c161d..988d07eaddcc 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -548,6 +548,8 @@ export function start(configuration: PreviewStreamConfiguration) { return; } const shortcut = event.ctrlKey || event.metaKey; + // Paste arrives as input text from this device's clipboard. + if (shortcut && event.key.toLowerCase() === "v") return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c98daa920421..c1cff8f768a6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -188,6 +188,7 @@ import { attachmentRelativePath, createDeterministicAttachmentId } from "./attac import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import { isServerBrowserEnabled } from "./preview/serverBrowserEnabled.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; @@ -3477,10 +3478,14 @@ const layerWsRpc = ( ), { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.previewOpen]: (input) => - observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { + [WS_METHODS.previewOpen]: (input) => { + // Without a server browser nothing renders server tabs, so the client renders it. + const { runtime, ...rest } = input; + const open = runtime === "server" && !isServerBrowserEnabled(config.mode) ? rest : input; + return observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(open), { "rpc.aggregate": "preview", - }), + }); + }, [WS_METHODS.previewNavigate]: (input) => observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { "rpc.aggregate": "preview", From 9f17d4980a8cd8f2612f53435daaa9580c413b17 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 03:50:15 +0000 Subject: [PATCH 07/68] fix(preview): keep load failures, local clipboard, and Cmd editing shortcuts working --- .../src/features/browser/preview-stream.browser.ts | 8 ++++++-- apps/server/src/preview/ServerBrowser.ts | 14 +++++++++++++- apps/web/src/browser/ServerBrowserSurface.tsx | 8 ++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 988d07eaddcc..c89c752cd118 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -548,8 +548,9 @@ export function start(configuration: PreviewStreamConfiguration) { return; } const shortcut = event.ctrlKey || event.metaKey; - // Paste arrives as input text from this device's clipboard. - if (shortcut && event.key.toLowerCase() === "v") return; + // Paste arrives as input text from this device's clipboard. Cut is not forwarded: + // the page's selection never reaches this clipboard, so it would be lost. + if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined @@ -614,6 +615,9 @@ export function start(configuration: PreviewStreamConfiguration) { input.addEventListener("input", onInput); input.addEventListener("compositionend", onCompositionEnd); input.addEventListener("focus", resetInput); + // Copying the input would put its sentinel on this device's clipboard. + input.addEventListener("copy", preventDefault); + input.addEventListener("cut", preventDefault); resetInput(); } diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index b603fd60382a..817267357dda 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -247,6 +247,16 @@ const modifiersOf = (message: Record) => { return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0; }; +// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless +// Chromium, so they carry the command. Ctrl already works natively on Linux. +const metaEditingCommand = (key: string, modifiers: number) => { + if ((modifiers & 0b0111) !== 4) return null; + const lower = key.toLowerCase(); + const command = + lower === "a" ? "selectAll" : lower === "z" ? (modifiers & 8 ? "redo" : "undo") : null; + return command ? { commands: [command] } : null; +}; + const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const enabled = isServerBrowserEnabled(config.mode); @@ -342,7 +352,8 @@ const make = Effect.gen(function* () { const reportLoaded = async (tab: ServerTab) => { const url = tab.page.url(); - if (url === "about:blank") return; + // Chromium's error page loads after `requestfailed` and must not clear LoadFailed. + if (url === "about:blank" || url.startsWith("chrome-error://")) return; const title = (await tab.page.title().catch(() => "")).slice(0, 512); report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); }; @@ -1108,6 +1119,7 @@ const make = Effect.gen(function* () { code, modifiers, ...(text ? { text, unmodifiedText: text } : {}), + ...metaEditingCommand(key, modifiers), windowsVirtualKeyCode: num( message.keyCode, key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 93becfd367f3..74aa850fdeb5 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -607,8 +607,9 @@ export function ServerBrowserSurface(props: { return; } const shortcut = event.ctrlKey || event.metaKey; - // Paste arrives as a paste event carrying this device's clipboard. - if (shortcut && event.key.toLowerCase() === "v") return; + // Paste arrives as a paste event carrying this device's clipboard. Cut is not forwarded: + // the page's selection never reaches this clipboard, so it would be lost. + if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined @@ -698,6 +699,9 @@ export function ServerBrowserSurface(props: { onKeyUp={(event) => handleKey("up", event)} onInput={handleInput} onCompositionEnd={handleCompositionEnd} + // Copying the input would put its sentinel on this device's clipboard. + onCopy={(event) => event.preventDefault()} + onCut={(event) => event.preventDefault()} onPaste={handlePaste} /> {visible && accessDenied ? ( From 895d46d783866e8cb7aca9fd92fae113726b761d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 03:56:37 +0000 Subject: [PATCH 08/68] fix(preview): stop forwarding Shift+Delete cut to the remote page --- apps/mobile/src/features/browser/preview-stream.browser.ts | 3 ++- apps/web/src/browser/ServerBrowserSurface.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index c89c752cd118..8017589b476c 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -549,8 +549,9 @@ export function start(configuration: PreviewStreamConfiguration) { } const shortcut = event.ctrlKey || event.metaKey; // Paste arrives as input text from this device's clipboard. Cut is not forwarded: - // the page's selection never reaches this clipboard, so it would be lost. + // the page's selection never reaches this clipboard, so it would be lost (also Shift+Delete). if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; + if (event.shiftKey && event.key === "Delete") return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 74aa850fdeb5..a951e126b9a7 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -608,8 +608,9 @@ export function ServerBrowserSurface(props: { } const shortcut = event.ctrlKey || event.metaKey; // Paste arrives as a paste event carrying this device's clipboard. Cut is not forwarded: - // the page's selection never reaches this clipboard, so it would be lost. + // the page's selection never reaches this clipboard, so it would be lost (also Shift+Delete). if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; + if (event.shiftKey && event.key === "Delete") return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined From a563eeaf44b7f4b159c11b0ee9eeef93bc76ac6f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:01:08 +0000 Subject: [PATCH 09/68] fix(preview): keep Shift+Insert paste local --- apps/mobile/src/features/browser/preview-stream.browser.ts | 5 +++-- apps/web/src/browser/ServerBrowserSurface.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 8017589b476c..b9ab5f90bedd 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -549,9 +549,10 @@ export function start(configuration: PreviewStreamConfiguration) { } const shortcut = event.ctrlKey || event.metaKey; // Paste arrives as input text from this device's clipboard. Cut is not forwarded: - // the page's selection never reaches this clipboard, so it would be lost (also Shift+Delete). + // the page's selection never reaches this clipboard, so it would be lost. + // Shift+Insert and Shift+Delete are the same paste and cut. if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; - if (event.shiftKey && event.key === "Delete") return; + if (event.shiftKey && ["Delete", "Insert"].includes(event.key)) return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index a951e126b9a7..69d543514b74 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -608,9 +608,10 @@ export function ServerBrowserSurface(props: { } const shortcut = event.ctrlKey || event.metaKey; // Paste arrives as a paste event carrying this device's clipboard. Cut is not forwarded: - // the page's selection never reaches this clipboard, so it would be lost (also Shift+Delete). + // the page's selection never reaches this clipboard, so it would be lost. + // Shift+Insert and Shift+Delete are the same paste and cut. if (shortcut && ["v", "x"].includes(event.key.toLowerCase())) return; - if (event.shiftKey && event.key === "Delete") return; + if (event.shiftKey && ["Delete", "Insert"].includes(event.key)) return; // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. const text = shortcut ? undefined From 0b20ab68c0518e2f2c561c84a1c5031c2a1abef9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:10:14 +0000 Subject: [PATCH 10/68] fix(preview): gate server tabs in the preview service and harden stream edge cases --- .../features/browser/preview-stream.browser.ts | 8 ++++++-- .../toolkits/previewControls/handlers.test.ts | 13 ++++++++++++- apps/server/src/preview/Manager.test.ts | 10 +++++++++- apps/server/src/preview/Manager.ts | 17 +++++++++++++---- apps/server/src/preview/ServerBrowser.ts | 16 ++++++++++++++-- apps/server/src/ws.ts | 11 +++-------- apps/web/src/browser/ServerBrowserSurface.tsx | 5 +++-- 7 files changed, 60 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index b9ab5f90bedd..a1076d571d83 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -572,7 +572,10 @@ export function start(configuration: PreviewStreamConfiguration) { }); // Some Android keyboards edit the textarea even when keydown is prevented. // Shortcuts keep their default, so a paste still arrives as text. - keySent = action === "down" && !shortcut; + keySent = + action === "down" && + !shortcut && + (text !== undefined || event.key === "Backspace" || event.key === "Delete"); if (!shortcut) event.preventDefault(); }; const onKeyDown = (event: KeyboardEvent) => onKey("down", event); @@ -593,7 +596,8 @@ export function start(configuration: PreviewStreamConfiguration) { else if (inputType === "deleteContentBackward") pressKey("Backspace", 8); else if (inputType === "deleteContentForward") pressKey("Delete", 46); else { - const text = input.value.replaceAll(SENTINEL, ""); + const value = input.value; + const text = value.startsWith(SENTINEL) ? value.slice(SENTINEL.length) : value; if (text) send({ type: "text", text }); } resetInput(); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 838c25efa2be..57a818be44d1 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, @@ -12,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as ServerConfig from "../../../config.ts"; import * as Preview from "../../../preview/Manager.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -46,7 +48,16 @@ it.effect.each([ capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), issuedAt: 0, }; - const manager = yield* Preview.make.pipe(Effect.provide(NodeCrypto.layer)); + const manager = yield* Preview.make.pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe( + Layer.provide(NodeServices.layer), + ), + NodeCrypto.layer, + ), + ), + ); const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); const layerDependencies = Layer.mergeAll( Layer.succeed(Preview.PreviewManager, manager), diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 40d1d4093207..5d5a402e052e 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; @@ -7,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import { expect } from "vite-plus/test"; +import * as ServerConfig from "../config.ts"; import * as PreviewManager from "./Manager.ts"; const DRAIN_LIMIT = 100; @@ -38,7 +40,13 @@ const collectEvents = Effect.gen(function* () { return collector; }).pipe(Effect.withSpan("preview.test.collectEvents")); -it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewManager", (it) => { +const layer = PreviewManager.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-manager-" })), + Layer.provide(NodeCrypto.layer), + Layer.provide(NodeServices.layer), +); + +it.layer(layer)("PreviewManager", (it) => { it.effect("opens a session and emits opened with normalized URL", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index ba32fe950873..d7bc141807f6 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -41,6 +41,8 @@ import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ServerConfig from "../config.ts"; +import { isServerBrowserEnabled } from "./serverBrowserEnabled.ts"; export class PreviewManager extends Context.Service< PreviewManager, @@ -167,6 +169,8 @@ const buildIdleSnapshot = (input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PreviewManagerMake() { + const config = yield* ServerConfig.ServerConfig; + const serverBrowser = isServerBrowserEnabled(config.mode); const crypto = yield* Crypto.Crypto; const serverEpoch = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const stateRef = yield* SynchronizedRef.make(initialState); @@ -234,8 +238,10 @@ export const make = Effect.gen(function* PreviewManagerMake() { const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")( function* (input) { + // Without a server browser nothing renders server tabs, so the client renders it. + const runtime = input.runtime === "server" && !serverBrowser ? undefined : input.runtime; // Persisted client surfaces must not bind to a different tab after a server restart. - const tabId = `${newPreviewTabId()}${input.runtime === "server" ? `_${serverEpoch}` : ""}`; + const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`; const updatedAt = yield* currentIsoTimestamp; // Clients with a configured default send the viewport up front so the // session is born at the right size; older clients omit it and keep the @@ -249,7 +255,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { title: "", viewport, profileId: input.profileId, - runtime: input.runtime, + runtime, reveal: input.reveal, updatedAt, }) @@ -258,7 +264,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId, viewport, profileId: input.profileId, - runtime: input.runtime, + runtime, reveal: input.reveal, updatedAt, }); @@ -485,7 +491,10 @@ export const make = Effect.gen(function* PreviewManagerMake() { const snapshot = { ...session.snapshot, reveal: true, - revealRequest: { id: NodeCrypto.randomUUID(), force: input.force }, + revealRequest: { + id: yield* crypto.randomUUIDv4.pipe(Effect.orDie), + force: input.force, + }, updatedAt: yield* currentIsoTimestamp, }; return { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 817267357dda..1333ce756628 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -355,6 +355,8 @@ const make = Effect.gen(function* () { // Chromium's error page loads after `requestfailed` and must not clear LoadFailed. if (url === "about:blank" || url.startsWith("chrome-error://")) return; const title = (await tab.page.title().catch(() => "")).slice(0, 512); + // A navigation that started while reading the title owns the status now. + if (tab.loading || tab.page.url() !== url) return; report(tab, { _tag: "Success", url: url.slice(0, 2048), title }); }; @@ -874,9 +876,14 @@ const make = Effect.gen(function* () { const open = input as PreviewAutomationOpenInput; const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url); const reuse = open.reuseExistingTab ?? true; + // A tab still launching exists only as a session, so resolve it like a viewer would. const existing = reuse && request.tabId !== undefined - ? tabs.get(tabKey(request.threadId, request.tabId)) + ? await Effect.runPromise( + findTab(request.threadId, request.tabId).pipe( + Effect.catchTag("ServerBrowserTabNotFoundError", () => Effect.succeed(undefined)), + ), + ) : undefined; const reveal = open.open ?? open.show; const revealTab = async (tab: ServerTab) => { @@ -1223,7 +1230,12 @@ const make = Effect.gen(function* () { push: (next) => { // Bounded: a viewer that stops reading drops messages instead of // growing this queue. A dropped frame still releases Chromium. - if (!Queue.offerUnsafe(output, next) && next._tag === "frame") runFork(next.ack); + if (Queue.offerUnsafe(output, next)) return; + if (next._tag === "frame") runFork(next.ack); + // The stream only ends on `gone`, so it replaces a stalled backlog. + else if (next._tag === "gone") { + runFork(Queue.clear(output).pipe(Effect.andThen(Queue.offer(output, next)))); + } }, pause: () => { screencastParams = screencastParams.then(() => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c1cff8f768a6..c98daa920421 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -188,7 +188,6 @@ import { attachmentRelativePath, createDeterministicAttachmentId } from "./attac import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; -import { isServerBrowserEnabled } from "./preview/serverBrowserEnabled.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; @@ -3478,14 +3477,10 @@ const layerWsRpc = ( ), { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.previewOpen]: (input) => { - // Without a server browser nothing renders server tabs, so the client renders it. - const { runtime, ...rest } = input; - const open = runtime === "server" && !isServerBrowserEnabled(config.mode) ? rest : input; - return observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(open), { + [WS_METHODS.previewOpen]: (input) => + observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { "rpc.aggregate": "preview", - }); - }, + }), [WS_METHODS.previewNavigate]: (input) => observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { "rpc.aggregate": "preview", diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 69d543514b74..7063e2653c5d 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -411,9 +411,9 @@ export function ServerBrowserSurface(props: { if (point) send({ type: "probe", x: point.x, y: point.y }); return; } - focusInput(); const point = pagePoint(event.clientX, event.clientY, false); if (!point) return; + focusInput(); event.currentTarget.setPointerCapture(event.pointerId); flushInput(); mouseButtonsRef.current = event.buttons & 7; @@ -649,7 +649,8 @@ export function ServerBrowserSurface(props: { else if (inputType === "deleteContentBackward") sendKeyPress(BACKSPACE); else if (inputType === "deleteContentForward") sendKeyPress(DELETE); else { - const text = textarea.value.replaceAll(INPUT_SENTINEL, ""); + const value = textarea.value; + const text = value.startsWith(INPUT_SENTINEL) ? value.slice(INPUT_SENTINEL.length) : value; if (text) send({ type: "text", text }); } resetInput(textarea); From 158e975def13c5c2e1395ffca42ffc26a484c9b9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:16:00 +0000 Subject: [PATCH 11/68] fix(mobile): keep zero-width spaces in committed IME text --- apps/mobile/src/features/browser/preview-stream.browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index a1076d571d83..50c48cfa7011 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -603,7 +603,7 @@ export function start(configuration: PreviewStreamConfiguration) { resetInput(); }; const onCompositionEnd = (event: CompositionEvent) => { - const text = event.data.replaceAll(SENTINEL, ""); + const text = event.data.startsWith(SENTINEL) ? event.data.slice(SENTINEL.length) : event.data; if (text) send({ type: "text", text }); resetInput(); }; From 3478c8d751697c4cacff56b2d26d6f78143e3f2f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:22:03 +0000 Subject: [PATCH 12/68] fix(web): drop the input sentinel from committed IME text --- apps/web/src/browser/ServerBrowserSurface.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 7063e2653c5d..183f777bd5e8 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -657,7 +657,9 @@ export function ServerBrowserSurface(props: { }; const handleCompositionEnd = (event: CompositionEvent) => { - if (event.data) send({ type: "text", text: event.data }); + const data = event.data; + const text = data.startsWith(INPUT_SENTINEL) ? data.slice(INPUT_SENTINEL.length) : data; + if (text) send({ type: "text", text }); resetInput(event.currentTarget); }; From b9ac5dd8cb9aff3b46cb8e4398e640564dcc6f13 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:59:26 +0000 Subject: [PATCH 13/68] refactor(preview): simplify server browser and client stream plumbing --- apps/mobile/metro.config.js | 14 +- .../mobile/scripts/generate-device-stream.mts | 24 +- .../browser/BrowserPreviewRouteScreen.tsx | 83 +--- .../features/browser/PreviewStreamWebView.tsx | 60 ++- .../features/browser/ThreadBrowserFloat.tsx | 41 +- .../browser/preview-stream-document.ts | 52 +- .../browser/preview-stream.browser.ts | 202 ++------ apps/mobile/src/state/preview.ts | 84 +--- apps/server/src/preview/Manager.ts | 111 +---- apps/server/src/preview/ServerBrowser.ts | 461 +++++++----------- apps/server/src/preview/ServerBrowserPage.ts | 134 ++--- .../server/src/preview/ServerBrowserStream.ts | 30 +- .../src/preview/ServerBrowserToolchain.ts | 134 ++--- apps/web/src/browser/ServerBrowserSurface.tsx | 325 +++++------- apps/web/src/browser/previewRuntime.ts | 6 - .../web/src/browser/serverPictureInPicture.ts | 68 +-- apps/web/src/components/ChatView.tsx | 25 +- .../preview/ThreadPreviewMiniPlayer.tsx | 45 +- apps/web/src/state/previewStream.ts | 7 +- .../src/preview/serverBrowserStream.ts | 56 +-- 20 files changed, 609 insertions(+), 1353 deletions(-) diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 5a9068675280..8fbbbc151d8d 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -108,29 +108,25 @@ async function prepareStreamScripts() { let rebuild = Promise.resolve(); for (const [directory, files, generate] of [ [ - path.join(__dirname, "src/features/devices"), + "apps/mobile/src/features/devices", ["device-stream.browser.ts"], generateDeviceStreamScript, ], [ - path.join(__dirname, "src/features/browser"), + "apps/mobile/src/features/browser", ["preview-stream.browser.ts"], generatePreviewStreamScript, ], // The preview transport also imports `hubAccess.ts`. + ["packages/client-runtime/src/device", ["stream.ts", "hubAccess.ts"], generateAll], [ - path.join(workspaceRoot, "packages/client-runtime/src/device"), - ["stream.ts", "hubAccess.ts"], - generateAll, - ], - [ - path.join(workspaceRoot, "packages/client-runtime/src/preview"), + "packages/client-runtime/src/preview", ["serverBrowserStream.ts"], generatePreviewStreamScript, ], ]) { // The generated modules participate in Metro's normal Fast Refresh. - fs.watch(directory, { persistent: false }, (_event, filename) => { + fs.watch(path.join(workspaceRoot, directory), { persistent: false }, (_event, filename) => { if (filename && !files.includes(String(filename))) return; rebuild = rebuild .then(generate) diff --git a/apps/mobile/scripts/generate-device-stream.mts b/apps/mobile/scripts/generate-device-stream.mts index eede4dbf9bd6..18281ee93148 100644 --- a/apps/mobile/scripts/generate-device-stream.mts +++ b/apps/mobile/scripts/generate-device-stream.mts @@ -6,7 +6,8 @@ import { build } from "vite-plus"; const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); /** Metro embeds each shared browser transport as a small script in a native WebView. */ -async function generateWebViewScript(entry: string, name: string, output: string) { +async function generateWebViewScript(feature: string, name: string) { + const stem = `${name.toLowerCase()}-stream`; const result = await build({ configFile: false, logLevel: "silent", @@ -15,8 +16,8 @@ async function generateWebViewScript(entry: string, name: string, output: string target: "es2022", minify: true, lib: { - entry: NodePath.join(mobileRoot, entry), - name, + entry: NodePath.join(mobileRoot, "src/features", feature, `${stem}.browser.ts`), + name: `T3${name}Stream`, formats: ["iife"], }, }, @@ -26,7 +27,7 @@ async function generateWebViewScript(entry: string, name: string, output: string .flatMap((bundle) => ("output" in bundle ? bundle.output : [])) .find((output) => output.type === "chunk"); if (!chunk) throw new Error(`${name} build did not emit a script.`); - const root = NodePath.join(mobileRoot, output); + const root = NodePath.join(mobileRoot, ".generated", stem); await NodeFSP.mkdir(root, { recursive: true }); for (const [file, contents] of [ ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`], @@ -38,16 +39,5 @@ async function generateWebViewScript(entry: string, name: string, output: string } } -export const generateDeviceStreamScript = () => - generateWebViewScript( - "src/features/devices/device-stream.browser.ts", - "T3DeviceStream", - ".generated/device-stream", - ); - -export const generatePreviewStreamScript = () => - generateWebViewScript( - "src/features/browser/preview-stream.browser.ts", - "T3PreviewStream", - ".generated/preview-stream", - ); +export const generateDeviceStreamScript = () => generateWebViewScript("devices", "Device"); +export const generatePreviewStreamScript = () => generateWebViewScript("browser", "Preview"); diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index 68f7c3a9d5f9..b2e8f24dfdb6 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -2,27 +2,14 @@ import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navi import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { - ActivityIndicator, - Alert, - AppState, - Platform, - Pressable, - TextInput, - View, -} from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { ScreenHeader } from "../../components/ScreenHeader"; import { NativeHeaderToolbar } from "../../native/StackHeader"; -import { - refreshPreviewStreamAccess, - usePreviewStreamAccess, - useThreadServerBrowserTabs, -} from "../../state/preview"; +import { useThreadServerBrowserTabs } from "../../state/preview"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; import { @@ -178,12 +165,14 @@ function BrowserPreviewScreen({ onCommand={(input) => streamRef.current?.command(input)} /> {live ? ( - @@ -262,61 +251,3 @@ function BrowserAddressBar({ ); } - -function OpenBrowserPreview({ - environmentId, - threadId, - tabId, - streamRef, - onPictureInPicture, - onStreamingChange, -}: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly tabId: string; - readonly streamRef: RefObject; - readonly onPictureInPicture: (state: PreviewPictureInPictureState, detail?: string) => void; - readonly onStreamingChange: (streaming: boolean) => void; -}) { - const { themeVariables } = useAppearancePreferences(); - const { access, error, refresh } = usePreviewStreamAccess(environmentId); - const onUnauthorized = useCallback( - () => refreshPreviewStreamAccess(environmentId), - [environmentId], - ); - return access ? ( - - ) : ( - - {error ? ( - <> - - {error} - - - Retry - - > - ) : ( - <> - - Connecting to browser... - > - )} - - ); -} diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 2623827c6fbd..08bfd1f669d9 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -1,5 +1,6 @@ import previewStreamScript from "@t3tools/mobile-preview-stream"; import type { PreviewStreamInput } from "@t3tools/client-runtime/preview/server-browser-stream"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, @@ -14,6 +15,7 @@ import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { WebView } from "react-native-webview"; import { AppText } from "../../components/AppText"; +import { usePreviewStreamAccess } from "../../state/preview"; import { previewStreamDocument, @@ -50,8 +52,52 @@ type NativeStreamBridge = { // session without operate scope. const MAX_REFUSALS = 3; -/** A server-hosted preview tab streamed into a WebView that runs the shared transport. */ -export function PreviewStreamWebView({ +export function PreviewStreamWebView( + props: Omit & + Omit & { + readonly environmentId: EnvironmentId; + readonly paused?: boolean; + }, +) { + const { access, error, refresh } = usePreviewStreamAccess(props.environmentId); + if (access && !props.paused) { + return ; + } + return ( + + {props.compact || !error ? : null} + {!props.compact && ( + <> + + {error || "Connecting to browser..."} + + {error && ( + + Retry + + )} + > + )} + + ); +} + +function AuthorizedPreviewStream({ ref, ...props }: PreviewStreamConfiguration & NativeStreamBridge) { @@ -81,14 +127,9 @@ export function PreviewStreamWebView({ return ( { // The client has stopped. Restart it with a fresh ticket, backing off between refusals. const refusals = ++unauthorized.current; @@ -135,10 +176,9 @@ function PreviewStreamDocumentView({ onRetry, onStreaming, onRecoverProcess, -}: Omit & { +}: Omit & { readonly configuration: string; readonly background: string; - readonly compact: boolean; /** False when the view should stop retrying and fail. */ readonly onUnauthorized: () => boolean; readonly onRetry: () => void; diff --git a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx index a934923f2639..715992eb7850 100644 --- a/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx +++ b/apps/mobile/src/features/browser/ThreadBrowserFloat.tsx @@ -1,11 +1,10 @@ import { useIsFocused } from "@react-navigation/native"; import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, AppState, Pressable, View } from "react-native"; +import { AppState, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; -import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "../../state/preview"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { browserTabTitle } from "./browserTabs"; import { PreviewStreamWebView } from "./PreviewStreamWebView"; @@ -14,10 +13,6 @@ const PLAYER_LONG_SIDE = 184; const PLAYER_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const PLAYER_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); -/** - * Floats a server browser tab over chat when the agent opens one, like web's - * "Float preview over chat". View-only: a tap opens the browser screen. - */ export function ThreadBrowserFloat(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -82,13 +77,7 @@ function FloatingBrowserPlayer(props: { readonly onOpen: () => void; readonly onClose: () => void; }) { - const { environmentId } = props; const { themeVariables } = useAppearancePreferences(); - const { access } = usePreviewStreamAccess(environmentId); - const onUnauthorized = useCallback( - () => refreshPreviewStreamAccess(environmentId), - [environmentId], - ); const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null); // The page keeps its own size; the player scales it into a box of the same shape. const aspect = Math.min(Math.max(viewport ? viewport.width / viewport.height : 16 / 10, 0.5), 2); @@ -110,23 +99,17 @@ function FloatingBrowserPlayer(props: { className="flex-1" > - {access && props.live ? ( - - ) : ( - - - - )} + `; + return ` +`; } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - export function previewStreamMessage(data: string): PreviewStreamMessage | null { - let message: unknown; try { - message = JSON.parse(data); + // Only our bundled viewer runs in this WebView; the remote page never does. + return JSON.parse(data) as PreviewStreamMessage; } catch { - // Ignore messages that are not part of the stream bridge. return null; } - if (!isRecord(message)) return null; - const detail = typeof message.detail === "string" ? message.detail : undefined; - switch (message.type) { - case "unauthorized": - case "gone": - return { type: message.type }; - case "status": - return message.status === "connecting" || - message.status === "streaming" || - message.status === "error" - ? { type: "status", status: message.status, ...(detail ? { detail } : {}) } - : null; - case "viewport": - return typeof message.width === "number" && typeof message.height === "number" - ? { type: "viewport", width: message.width, height: message.height } - : null; - case "pictureInPicture": - return typeof message.supported === "boolean" && typeof message.active === "boolean" - ? { - type: "pictureInPicture", - supported: message.supported, - active: message.active, - ...(detail ? { detail } : {}), - } - : null; - default: - return null; - } } diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 50c48cfa7011..3ab290eccf1e 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -63,12 +63,6 @@ export function pictureInPicture() { void activeViewer?.togglePictureInPicture(); } -/** - * Bundled into a native WebView without React or Expo's web runtime. Draws a - * server tab's JPEG frames into a letterboxed canvas. Interactive viewers turn - * taps into clicks, drags into wheel scrolls, and soft keyboard input into key - * and text messages, and resize fill-mode tabs to the view. - */ export function start(configuration: PreviewStreamConfiguration) { stop(); const post = (message: PreviewStreamMessage) => { @@ -76,59 +70,18 @@ export function start(configuration: PreviewStreamConfiguration) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); }; const { interactive } = configuration; - Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" }); - Object.assign(document.body.style, { - margin: "0", - height: "100%", - overflow: "hidden", - background: configuration.background, - }); + document.body.style.background = configuration.background; const container = document.createElement("div"); - Object.assign(container.style, { - position: "fixed", - left: "0", - top: "0", - width: "100%", - height: "100%", - overflow: "hidden", - }); const canvas = document.createElement("canvas"); canvas.setAttribute("role", "img"); canvas.setAttribute("aria-label", "Browser page"); - Object.assign(canvas.style, { - position: "absolute", - inset: "0", - width: "100%", - height: "100%", - objectFit: "contain", - touchAction: "none", - userSelect: "none", - webkitUserSelect: "none", - webkitTouchCallout: "none", - }); container.append(canvas); - // Focus target for page keyboard input, visually hidden like `sr-only`. Pinned - // top-left so focusing it never scrolls; 16px keeps iOS from zooming on focus. const input = document.createElement("textarea"); input.setAttribute("aria-label", "Browser page input"); input.autocapitalize = "off"; input.autocomplete = "off"; input.spellcheck = false; input.setAttribute("autocorrect", "off"); - Object.assign(input.style, { - position: "fixed", - left: "0", - top: "0", - width: "1px", - height: "1px", - padding: "0", - margin: "-1px", - border: "0", - overflow: "hidden", - clip: "rect(0, 0, 0, 0)", - whiteSpace: "nowrap", - fontSize: "16px", - }); document.body.replaceChildren(container, ...(interactive ? [input] : [])); let stopped = false; @@ -165,11 +118,8 @@ export function start(configuration: PreviewStreamConfiguration) { // A keydown already sent this key; its `input` must not send it again. let keySent = false; let touch: { - pointerId: number; - startX: number; - startY: number; - lastX: number; - lastY: number; + start: PointerEvent; + last: PointerEvent; panning: boolean; } | null = null; let lastTap: { @@ -340,6 +290,23 @@ export function start(configuration: PreviewStreamConfiguration) { lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count, button }; return count; }; + const mouseInput = ( + event: PointerEvent, + point: { x: number; y: number }, + action: MouseInput["action"], + button: MouseInput["button"], + buttons = event.buttons, + clickCount = mouseClicks[button], + ): MouseInput => ({ + type: "mouse", + action, + x: point.x, + y: point.y, + button, + buttons, + clickCount, + modifiers: previewStreamModifiers(event), + }); const onPointerDown = (event: PointerEvent) => { if (!event.isPrimary) return; event.preventDefault(); @@ -352,28 +319,11 @@ export function start(configuration: PreviewStreamConfiguration) { flushWheel(); mouseButtons = event.buttons & 7; const button = mouseButton(event.button); - const clickCount = countClick(event, button); - mouseClicks[button] = clickCount; - send({ - type: "mouse", - action: "down", - x: point.x, - y: point.y, - button, - buttons: event.buttons, - clickCount, - modifiers: previewStreamModifiers(event), - }); + mouseClicks[button] = countClick(event, button); + send(mouseInput(event, point, "down", button)); return; } - touch = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - lastX: event.clientX, - lastY: event.clientY, - panning: false, - }; + touch = { start: event, last: event, panning: false }; const point = pagePoint(event.clientX, event.clientY, false); if (!point) { probe = null; @@ -410,42 +360,26 @@ export function start(configuration: PreviewStreamConfiguration) { const down = (event.buttons & bit) !== 0; mouseButtons ^= bit; if (down) mouseClicks[button] = countClick(event, button); - send({ - type: "mouse", - action: down ? "down" : "up", - x: point.x, - y: point.y, - button, - buttons: mouseButtons, - clickCount: mouseClicks[button], - modifiers: previewStreamModifiers(event), - }); + send(mouseInput(event, point, down ? "down" : "up", button, mouseButtons)); } } - pendingMouse = { - type: "mouse", - action: "move", - x: point.x, - y: point.y, - button: - mouseButtons & 1 - ? "left" - : mouseButtons & 2 - ? "right" - : mouseButtons & 4 - ? "middle" - : "none", - buttons: event.buttons, - clickCount: 0, - modifiers: previewStreamModifiers(event), - }; + const button = + mouseButtons & 1 + ? "left" + : mouseButtons & 2 + ? "right" + : mouseButtons & 4 + ? "middle" + : "none"; + pendingMouse = mouseInput(event, point, "move", button, event.buttons, 0); mouseFrame ??= requestAnimationFrame(flushMouse); return; } - if (!touch || touch.pointerId !== event.pointerId) return; + if (!touch || touch.start.pointerId !== event.pointerId) return; if ( !touch.panning && - Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) < TAP_SLOP_PX + Math.hypot(event.clientX - touch.start.clientX, event.clientY - touch.start.clientY) < + TAP_SLOP_PX ) { return; } @@ -455,12 +389,11 @@ export function start(configuration: PreviewStreamConfiguration) { if (point) { queueWheel( point, - (touch.lastX - event.clientX) * point.scale, - (touch.lastY - event.clientY) * point.scale, + (touch.last.clientX - event.clientX) * point.scale, + (touch.last.clientY - event.clientY) * point.scale, ); } - touch.lastX = event.clientX; - touch.lastY = event.clientY; + touch.last = event; }; const releaseMouse = (event: PointerEvent, cancelled: boolean) => { let buttons = mouseButtons; @@ -474,28 +407,20 @@ export function start(configuration: PreviewStreamConfiguration) { if (!(buttons & bit) || (mouseButtons & bit) !== 0) continue; buttons &= ~bit; const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle"; - send({ - type: "mouse", - action: "up", - x: point.x, - y: point.y, - button, - buttons, - clickCount: mouseClicks[button], - modifiers: previewStreamModifiers(event), - }); + send(mouseInput(event, point, "up", button, buttons)); } }; - const onPointerUp = (event: PointerEvent) => { + const onPointerEnd = (event: PointerEvent) => { + const cancelled = event.type === "pointercancel"; if (event.pointerType !== "touch") { - releaseMouse(event, false); + releaseMouse(event, cancelled); return; } const ended = touch; - if (!ended || ended.pointerId !== event.pointerId) return; + if (!ended || ended.start.pointerId !== event.pointerId) return; touch = null; const answered = probe; - if (ended.panning) { + if (cancelled || ended.panning) { if (answered) answered.end = "panned"; return; } @@ -512,15 +437,6 @@ export function start(configuration: PreviewStreamConfiguration) { send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount }); send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); }; - const onPointerCancel = (event: PointerEvent) => { - if (event.pointerType !== "touch") { - releaseMouse(event, true); - return; - } - if (touch?.pointerId !== event.pointerId) return; - touch = null; - if (probe) probe.end = "panned"; - }; // Trackpads and mice on tablets scroll with wheel events. const onWheel = (event: WheelEvent) => { const point = pagePoint(event.clientX, event.clientY, false); @@ -578,8 +494,6 @@ export function start(configuration: PreviewStreamConfiguration) { (text !== undefined || event.key === "Backspace" || event.key === "Delete"); if (!shortcut) event.preventDefault(); }; - const onKeyDown = (event: KeyboardEvent) => onKey("down", event); - const onKeyUp = (event: KeyboardEvent) => onKey("up", event); const resetInput = () => { input.value = SENTINEL; input.setSelectionRange(SENTINEL.length, SENTINEL.length); @@ -611,13 +525,13 @@ export function start(configuration: PreviewStreamConfiguration) { if (interactive) { canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointermove", onPointerMove); - canvas.addEventListener("pointerup", onPointerUp); - canvas.addEventListener("pointercancel", onPointerCancel); + canvas.addEventListener("pointerup", onPointerEnd); + canvas.addEventListener("pointercancel", onPointerEnd); canvas.addEventListener("wheel", onWheel, { passive: false }); canvas.addEventListener("mousedown", preventDefault); canvas.addEventListener("contextmenu", preventDefault); - input.addEventListener("keydown", onKeyDown); - input.addEventListener("keyup", onKeyUp); + input.addEventListener("keydown", (event) => onKey("down", event)); + input.addEventListener("keyup", (event) => onKey("up", event)); input.addEventListener("input", onInput); input.addEventListener("compositionend", onCompositionEnd); input.addEventListener("focus", resetInput); @@ -627,10 +541,7 @@ export function start(configuration: PreviewStreamConfiguration) { resetInput(); } - // Picture in picture plays the canvas as a muted video. It is created on first - // use so a viewer that never pops out pays nothing for it. - // Checks the API, not an element: WebKit reports no support for a video - // that has not loaded yet. + // WebKit reports no support on a video that has not loaded yet; check the prototype. const videoPrototype: PresentationVideo = HTMLVideoElement.prototype; const pictureInPictureSupported = interactive && @@ -656,16 +567,7 @@ export function start(configuration: PreviewStreamConfiguration) { element.muted = true; element.playsInline = true; element.autoplay = true; - element.setAttribute("playsinline", ""); // Under the canvas at full size: WebKit pauses muted video it considers off screen. - Object.assign(element.style, { - position: "absolute", - inset: "0", - width: "100%", - height: "100%", - objectFit: "contain", - pointerEvents: "none", - }); element.srcObject = canvas.captureStream(); // A static page sends no new frames; repaint once so the stream has one. if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); @@ -675,7 +577,6 @@ export function start(configuration: PreviewStreamConfiguration) { "webkitpresentationmodechanged", ]) { element.addEventListener(name, () => { - // The hidden inline copy stops decoding once the window closes. if (!pictureInPictureActive()) element.pause(); reportPictureInPicture(); }); @@ -703,7 +604,7 @@ export function start(configuration: PreviewStreamConfiguration) { } }; - const viewer: Viewer = { + activeViewer = { stop: () => { stopped = true; observer.disconnect(); @@ -729,7 +630,6 @@ export function start(configuration: PreviewStreamConfiguration) { }, togglePictureInPicture, }; - activeViewer = viewer; window.addEventListener("pagehide", stop, { once: true }); reportStatus("connecting"); if (interactive) reportPictureInPicture(); diff --git a/apps/mobile/src/state/preview.ts b/apps/mobile/src/state/preview.ts index 38d3d5d0db4c..9092a895b46d 100644 --- a/apps/mobile/src/state/preview.ts +++ b/apps/mobile/src/state/preview.ts @@ -16,14 +16,12 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { environmentSession, usePreparedConnection } from "./session"; export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); interface ThreadPreviewTabs { - /** Null until the first list result or event arrives. */ readonly serverEpoch: string | null; readonly revision: number; readonly sessions: ReadonlyArray; @@ -39,61 +37,36 @@ const EMPTY_TABS: ThreadPreviewTabs = { }; const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty")); -// Events kept for replay onto a list that lands after them. Only a list older -// than this many of the thread's events would miss one. const MAX_REPLAY_EVENTS = 200; -const listTabs = (result: PreviewListResult): ThreadPreviewTabs => ({ - serverEpoch: result.serverEpoch, - revision: result.revision, - sessions: result.sessions, - listed: true, -}); - function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs { if (event.revision <= current.revision) return current; - const others = current.sessions.filter((session) => session.tabId !== event.tabId); - const existing = current.sessions.find((session) => session.tabId === event.tabId); - const sessions = (() => { - switch (event.type) { - case "opened": - case "navigated": - case "resized": - // Keep a tab's place so the picker order is stable. - return existing - ? current.sessions.map((session) => - session.tabId === event.tabId ? event.snapshot : session, - ) - : [...others, event.snapshot]; - case "failed": - return existing - ? current.sessions.map((session) => - session.tabId === event.tabId - ? { - ...session, - navStatus: { - _tag: "LoadFailed" as const, - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - } - : session, - ) - : current.sessions; - case "closed": - return others; + const index = current.sessions.findIndex((session) => session.tabId === event.tabId); + const sessions = [...current.sessions]; + if (event.type === "closed") { + if (index !== -1) sessions.splice(index, 1); + } else if (event.type === "failed") { + const existing = sessions[index]; + if (existing) { + sessions[index] = { + ...existing, + navStatus: { + _tag: "LoadFailed", + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }; } - })(); + } else { + // Keep a tab's place so the picker order is stable. + sessions[index === -1 ? sessions.length : index] = event.snapshot; + } return { ...current, serverEpoch: event.serverEpoch, revision: event.revision, sessions }; } -/** - * One thread's preview tabs: the `preview.list` result kept current by - * environment preview events, the same pairing web's preview session sync uses. - */ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { const ref = parseScopedThreadKey(threadKey); if (ref === null) return emptyTabsAtom; @@ -105,9 +78,7 @@ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { return Atom.make((get) => { let disposed = false; let state = EMPTY_TABS; - // The newest list applied, and this thread's events newer than it. A list - // can land after events it predates (the first one, or a stale cached one - // then its refresh), so each list is the base and newer events replay on top. + // Lists can land after events they predate; replay newer events on each list. let list: PreviewListResult | null = null; let events: ReadonlyArray = []; const publish = (next: ThreadPreviewTabs) => { @@ -121,7 +92,7 @@ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { events = events.filter( (event) => event.serverEpoch === result.serverEpoch && event.revision > result.revision, ); - return events.reduce(applyEvent, listTabs(result)); + return events.reduce(applyEvent, { ...result, listed: true }); }; get.addFinalizer(() => { disposed = true; @@ -155,7 +126,6 @@ const threadPreviewTabsAtom = Atom.family((threadKey: string) => { }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`mobile-preview-tabs:${threadKey}`)); }); -/** The thread's server-hosted browser tabs. `loaded` turns true once the tab list has arrived. */ export function useThreadServerBrowserTabs(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -188,12 +158,6 @@ const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), ); -/** Fetches a fresh stream ticket; a refused socket calls this before reconnecting. */ -export function refreshPreviewStreamAccess(environmentId: EnvironmentId) { - appAtomRegistry.refresh(previewStreamAccessAtom(environmentId)); -} - -/** Stream credentials for server tabs, with the same ticket flow as the device hub. */ export function usePreviewStreamAccess(environmentId: EnvironmentId) { const prepared = usePreparedConnection(environmentId); const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index d7bc141807f6..bc6e0748065f 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -21,11 +21,9 @@ import { type PreviewRefreshInput, type PreviewReportStatusInput, type PreviewResizeInput, - type PreviewRuntime, FILL_PREVIEW_VIEWPORT, PreviewSessionLookupError, type PreviewSessionSnapshot, - type PreviewViewportSetting, } from "@t3tools/contracts"; import { isPreviewUrlNormalizationError, @@ -123,50 +121,6 @@ const normalizeUrl = (rawUrl: string): Effect.Effect ({ - threadId: input.threadId, - tabId: input.tabId, - navStatus: { _tag: "Loading", url: input.url, title: input.title }, - canGoBack: false, - canGoForward: false, - viewport: input.viewport, - ...(input.profileId === undefined ? {} : { profileId: input.profileId }), - ...(input.runtime === undefined ? {} : { runtime: input.runtime }), - ...(input.reveal === undefined ? {} : { reveal: input.reveal }), - updatedAt: input.updatedAt, -}); - -const buildIdleSnapshot = (input: { - readonly threadId: string; - readonly tabId: string; - readonly viewport: PreviewViewportSetting; - readonly profileId?: string | undefined; - readonly runtime?: PreviewRuntime | undefined; - readonly reveal?: boolean | undefined; - readonly updatedAt: string; -}): PreviewSessionSnapshot => ({ - threadId: input.threadId, - tabId: input.tabId, - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - viewport: input.viewport, - ...(input.profileId === undefined ? {} : { profileId: input.profileId }), - ...(input.runtime === undefined ? {} : { runtime: input.runtime }), - ...(input.reveal === undefined ? {} : { reveal: input.reveal }), - updatedAt: input.updatedAt, -}); - /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PreviewManagerMake() { const config = yield* ServerConfig.ServerConfig; @@ -243,31 +197,20 @@ export const make = Effect.gen(function* PreviewManagerMake() { // Persisted client surfaces must not bind to a different tab after a server restart. const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`; const updatedAt = yield* currentIsoTimestamp; - // Clients with a configured default send the viewport up front so the - // session is born at the right size; older clients omit it and keep the - // historical fill-panel behaviour. - const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT; - const snapshot = input.url - ? buildLoadingSnapshot({ - threadId: input.threadId, - tabId, - url: yield* normalizeUrl(input.url), - title: "", - viewport, - profileId: input.profileId, - runtime, - reveal: input.reveal, - updatedAt, - }) - : buildIdleSnapshot({ - threadId: input.threadId, - tabId, - viewport, - profileId: input.profileId, - runtime, - reveal: input.reveal, - updatedAt, - }); + const snapshot: PreviewSessionSnapshot = { + threadId: input.threadId, + tabId, + navStatus: input.url + ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" } + : { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(runtime === undefined ? {} : { runtime }), + ...(input.reveal === undefined ? {} : { reveal: input.reveal }), + updatedAt, + }; yield* SynchronizedRef.modifyEffect(stateRef, (state) => Effect.gen(function* () { const revision = state.revision + 1; @@ -305,22 +248,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; const resolvedTitle = input.resolvedTitle ?? previousTitle; const snapshot: PreviewSessionSnapshot = { - threadId: session.threadId, - tabId: session.tabId, + ...session.snapshot, navStatus: { _tag: "Success", url, title: resolvedTitle }, - canGoBack: session.snapshot.canGoBack, - canGoForward: session.snapshot.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, - ...(session.snapshot.profileId === undefined - ? {} - : { profileId: session.snapshot.profileId }), - ...(session.snapshot.runtime === undefined - ? {} - : { runtime: session.snapshot.runtime }), - ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), - ...(session.snapshot.revealRequest === undefined - ? {} - : { revealRequest: session.snapshot.revealRequest }), updatedAt, }; return { @@ -348,20 +278,11 @@ export const make = Effect.gen(function* PreviewManagerMake() { Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { const updatedAt = yield* currentIsoTimestamp; const snapshot: PreviewSessionSnapshot = { - threadId: session.threadId, - tabId: session.tabId, + ...session.snapshot, navStatus: input.navStatus, canGoBack: input.canGoBack, canGoForward: input.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, - ...(session.snapshot.profileId === undefined - ? {} - : { profileId: session.snapshot.profileId }), - ...(session.snapshot.runtime === undefined ? {} : { runtime: session.snapshot.runtime }), - ...(session.snapshot.reveal === undefined ? {} : { reveal: session.snapshot.reveal }), - ...(session.snapshot.revealRequest === undefined - ? {} - : { revealRequest: session.snapshot.revealRequest }), updatedAt, }; const emit: PreviewEventDraft = diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 1333ce756628..f829ed35452e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1,23 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. -/** - * Headless Chromium preview tabs owned by the environment server. - * - * Tabs opened with `runtime: "server"` live here instead of in a desktop - * ``, so agents keep browsing with no client attached and any client - * (web, phone, a desktop on another machine) watches through - * `/api/preview-stream`. PreviewManager stays the tab list: this service - * mirrors it (`opened` creates a page, `resized` applies the viewport, `closed` - * drops it) and reports page state back through `reportStatus`, the same path - * desktops use. Agents reach these tabs through PreviewAutomationBroker, where - * this service registers as a preferred in-process automation host. - * - * Rendering is tuned for servers without a GPU. `--disable-gpu` selects - * software compositing, which measured about six times cheaper than - * SwiftShader GL compositing for a busy page; WebGL still works through - * SwiftShader. Screencast frames ignore emulated device scale, so the process - * renders at a real 2x for crisp screenshots, recordings, and retina viewers; - * small viewers get downscaled frames. - */ +// Screencasts ignore emulated device scale; real 2x keeps captures sharp. +// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL. import { FILL_PREVIEW_VIEWPORT, INCOGNITO_BROWSER_PROFILE_ID, @@ -51,6 +34,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import { constVoid } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; @@ -69,19 +53,10 @@ import { isServerBrowserEnabled } from "./serverBrowserEnabled.ts"; const SERVER_HOST_CLIENT_ID = "server-browser"; const RENDER_SCALE = 2; -/** - * Chromium keeps three screencast frames in flight and drops frames past - * that, so holding each ack this long caps a viewer near 30 fps. - */ +// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps. const SCREENCAST_ACK_PACE_MS = 100; -/** Quiet time after which a viewer that may have missed frames gets a still. */ const SCREENCAST_SETTLE_MS = 200; -/** - * This many frames inside the window, while the viewer is scrolling, is - * motion. Motion streams at half size and lower quality, and the sharp frame - * follows once the page settles. Clicks and the page's own animations stay - * sharp. - */ +// Only scrolling triggers reduced-quality motion frames; animations stay sharp. const SCREENCAST_MOTION_FRAMES = 4; const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; @@ -89,15 +64,10 @@ const HOST_RECONNECT_DELAY = "1 second"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; -/** Resolves at `deadline`, in epoch ms. */ const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); -/** - * Whether a tap at a page point lands on something that takes text, so a touch - * viewer raises its soft keyboard only then. Frames count as editable because - * their contents are out of reach. - */ +// Touch viewers raise the keyboard for editable targets, including opaque frames. const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { let element = document.elementFromPoint(x, y); while (element && element.shadowRoot) { @@ -114,7 +84,6 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; return !nonText.includes(element.type) && !element.disabled && !element.readOnly; }`; -/** Fill-mode size before any viewer reports its panel. */ const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; const ACTION_TIMELINE_LIMIT = 50; @@ -141,7 +110,6 @@ export type ServerBrowserViewerOutput = | { readonly _tag: "frame"; readonly data: Uint8Array; - /** Releases the next frame; call after the bytes reach the socket. */ readonly ack: Effect.Effect; } | { readonly _tag: "viewport"; readonly width: number; readonly height: number } @@ -151,7 +119,6 @@ export type ServerBrowserViewerOutput = readonly y: number; readonly editable: boolean; } - /** The tab closed; the socket ends. */ | { readonly _tag: "gone" }; export interface ServerBrowserViewer { @@ -163,7 +130,6 @@ export class ServerBrowser extends Context.Service< ServerBrowser, { readonly enabled: boolean; - /** Streams one tab to a viewer until the scope closes. */ readonly attachViewer: (input: { readonly threadId: string; readonly tabId: string; @@ -180,15 +146,22 @@ export class ServerBrowser extends Context.Service< interface ViewerState { readonly push: (output: ServerBrowserViewerOutput) => void; - /** Stops and restarts this viewer's screencast around a scaled capture. */ readonly pause: () => Promise; readonly resume: () => Promise; - /** Last wheel input from this viewer, for motion mode. */ scrolledAt: number; /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ requestedSize: { width: number; height: number; order: number } | null; } +interface EncoderWindow { + __t3Recorder: { + cursor(x: number, y: number, click: boolean): void; + frame(data: string, width: number): Promise; + stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; + chunk(index: number): Promise; + }; +} + interface Recording { readonly encoder: Page; readonly session: CDPSession; @@ -231,10 +204,8 @@ const pushBounded = ( if (buffer.length > limit) buffer.splice(0, buffer.length - limit); }; -const fixedViewportSize = (setting: PreviewViewportSetting | undefined) => - setting === undefined || setting._tag === "fill" - ? null - : { width: setting.width, height: setting.height }; +const fixedViewportSize = (setting: PreviewViewportSetting) => + setting._tag === "fill" ? null : { width: setting.width, height: setting.height }; const asRecord = (value: unknown): Record | null => typeof value === "object" && value !== null ? (value as Record) : null; @@ -282,7 +253,7 @@ const make = Effect.gen(function* () { const launchOptions = { executablePath: resolved.executablePath, env: { ...process.env, ...resolved.env }, - args: [...resolved.args, "--disable-gpu", `--force-device-scale-factor=${RENDER_SCALE}`], + args: ["--disable-gpu", `--force-device-scale-factor=${RENDER_SCALE}`], headless: true, chromiumSandbox, }; @@ -294,10 +265,10 @@ const make = Effect.gen(function* () { const browser = await chromium.launch(launchOptions); try { const context = await browser.newContext(contextOptions); - context.on("close", () => void browser.close().catch(() => undefined)); + context.on("close", () => void browser.close().catch(constVoid)); return context; } catch (cause) { - await browser.close().catch(() => undefined); + await browser.close().catch(constVoid); throw cause; } } @@ -305,11 +276,9 @@ const make = Effect.gen(function* () { await NodeFSP.mkdir(userDataDir, { recursive: true }); return chromium.launchPersistentContext(userDataDir, { ...launchOptions, ...contextOptions }); }; - // The namespace sandbox needs unprivileged user namespaces, which some - // containers disable. Running unsandboxed there matches what the agent - // already has: a shell as the same user. + // Containers may disable the unprivileged namespaces Chromium needs for its sandbox. const context = await launch(true).catch(() => launch(false)); - for (const page of context.pages()) await page.close().catch(() => undefined); + for (const page of context.pages()) await page.close().catch(constVoid); context.on("close", () => { contexts.delete(profileId); for (const tab of tabs.values()) { @@ -407,8 +376,8 @@ const make = Effect.gen(function* () { tabs.delete(key); tab.closing = true; for (const viewer of tab.viewers) viewer.push({ _tag: "gone" }); - void tab.page.close().catch(() => undefined); - void tab.recording?.encoder.close().catch(() => undefined); + void tab.page.close().catch(constVoid); + void tab.recording?.encoder.close().catch(constVoid); reportLiveTabs(); if (closeSession) { runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); @@ -492,7 +461,7 @@ const make = Effect.gen(function* () { }); }); // A blocking dialog would freeze the page for every viewer and agent. - page.on("dialog", (dialog) => void dialog.dismiss().catch(() => undefined)); + page.on("dialog", (dialog) => void dialog.dismiss().catch(constVoid)); // One page per tab: a popup becomes a navigation of its opener. page.on("popup", (popup) => { void popup @@ -500,29 +469,24 @@ const make = Effect.gen(function* () { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "commit", }) - .catch(() => undefined) + .catch(constVoid) .then(async () => { const url = popup.url(); - await popup.close().catch(() => undefined); - if (url !== "about:blank") await page.goto(url).catch(() => undefined); + await popup.close().catch(constVoid); + if (url !== "about:blank") await page.goto(url).catch(constVoid); }); }); - page.on("close", () => { - if (!tab.closing) dropTab(tab, true); - }); - // Playwright cannot reload a crashed page. Remove it so clients stop - // displaying its stale frame and the agent can open a fresh tab. - page.on("crash", () => { - if (!tab.closing) dropTab(tab, true); - }); + // Playwright cannot reload a crashed page, so it must leave the tab list. + page.on("close", () => dropTab(tab, true)); + page.on("crash", () => dropTab(tab, true)); tabs.set(tabKey(tab.threadId, tab.tabId), tab); reportLiveTabs(); if (snapshot.navStatus._tag === "Loading") { tab.initialNavigation = page .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS }) - .then(() => undefined); + .then(constVoid); // Background creation keeps the failed tab; automation awaits the original error. - void tab.initialNavigation.catch(() => undefined); + void tab.initialNavigation.catch(constVoid); } return tab; }; @@ -538,7 +502,6 @@ const make = Effect.gen(function* () { return opening; }; - /** Server tabs that exist in PreviewManager but have not finished opening here. */ const findTab = (threadId: string, tabId: string) => Effect.gen(function* () { const existing = tabs.get(tabKey(threadId, tabId)); @@ -559,7 +522,7 @@ const make = Effect.gen(function* () { .filter((tab) => tab.threadId === threadId) .sort((left, right) => right.createdAt - left.createdAt)[0]; - const statusOf = (tab: ServerTab | undefined): PreviewAutomationStatus => { + const statusWithTitle = async (tab: ServerTab | undefined): Promise => { if (!tab) { return { available: false, @@ -572,7 +535,7 @@ const make = Effect.gen(function* () { } const url = tab.page.url(); const viewport = tab.page.viewportSize(); - return { + const status = { available: true, visible: tab.viewers.size > 0, tabId: tab.tabId, @@ -582,11 +545,7 @@ const make = Effect.gen(function* () { viewportSetting: tab.setting, ...(viewport ? { viewport } : {}), }; - }; - - const statusWithTitle = async (tab: ServerTab | undefined) => { - const status = statusOf(tab); - if (!tab || status.url === null) return status; + if (status.url === null) return status; return { ...status, title: (await tab.page.title().catch(() => "")) || null }; }; @@ -615,7 +574,7 @@ const make = Effect.gen(function* () { : "load", }); if (readiness === "none") { - void navigation.catch(() => undefined); + void navigation.catch(constVoid); return; } await navigation.catch((cause: unknown) => { @@ -630,104 +589,69 @@ const make = Effect.gen(function* () { }); }; - const moveAgentCursor = ( - tab: ServerTab, - point: { readonly x: number; readonly y: number }, - click: boolean, - ) => { - void tab.recording?.encoder - .evaluate( - ([x, y, isClick]) => { - const recorder = ( - globalThis as unknown as { - __t3Recorder?: { cursor(x: number, y: number, click: boolean): void }; - } - ).__t3Recorder; - recorder?.cursor(x, y, isClick); - }, - [point.x, point.y, click] as const, - ) - .catch(() => undefined); - }; - const withCaptureLock = (tab: ServerTab, operation: () => Promise): Promise => { const run = tab.captureLock.then(() => { if (tab.closing) throw new Error("The preview tab closed."); return operation(); }); - tab.captureLock = run.then( - () => undefined, - () => undefined, - ); + tab.captureLock = run.then(constVoid, constVoid); return run; }; const startRecording = (tab: ServerTab): Promise => { - const started = withCaptureLock(tab, async () => tab.recording ?? beginRecording(tab)).finally( - () => { - if (tab.recordingStart === started) tab.recordingStart = null; - }, - ); + const started = withCaptureLock(tab, async () => { + if (tab.recording) return tab.recording; + const encoder = await tab.page.context().newPage(); + let session: CDPSession | null = null; + try { + await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); + const opened = await tab.page.context().newCDPSession(tab.page); + session = opened; + const framesInFlight = new Set>(); + opened.on("Page.screencastFrame", (frame) => { + const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; + const delivered: Promise = encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + [frame.data, cssWidth] as const, + ) + .then(async (accepted) => { + if (!accepted) await opened.send("Page.stopScreencast"); + }) + .catch(constVoid) + .finally(() => { + framesInFlight.delete(delivered); + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); + }); + framesInFlight.add(delivered); + }); + await opened.send("Page.startScreencast", RECORDING_SCREENCAST); + if (tab.closing) throw new Error("The tab closed while the recording started."); + const recording: Recording = { + encoder, + session: opened, + startedAt: new Date().toISOString(), + framesInFlight, + }; + tab.recording = recording; + return recording; + } catch (cause) { + // A start that fails partway must not leave its encoder page behind. + await encoder.close().catch(constVoid); + await session?.detach().catch(constVoid); + throw cause; + } + }).finally(() => { + if (tab.recordingStart === started) tab.recordingStart = null; + }); tab.recordingStart = started; return started; }; - const beginRecording = async (tab: ServerTab) => { - const encoder = await tab.page.context().newPage(); - let session: CDPSession | null = null; - try { - await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); - const opened = await tab.page.context().newCDPSession(tab.page); - session = opened; - const framesInFlight = new Set>(); - opened.on("Page.screencastFrame", (frame) => { - const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - const delivered: Promise = encoder - .evaluate( - ([data, width]) => { - const recorder = ( - globalThis as unknown as { - __t3Recorder: { frame(data: string, width: number): Promise }; - } - ).__t3Recorder; - return recorder.frame(data, width); - }, - [frame.data, cssWidth] as const, - ) - .then(async (accepted) => { - if (!accepted) await opened.send("Page.stopScreencast"); - }) - .catch(() => undefined) - .finally(() => { - framesInFlight.delete(delivered); - void opened - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(() => undefined); - }); - framesInFlight.add(delivered); - }); - await opened.send("Page.startScreencast", RECORDING_SCREENCAST); - if (tab.closing) throw new Error("The tab closed while the recording started."); - const recording: Recording = { - encoder, - session: opened, - startedAt: new Date().toISOString(), - framesInFlight, - }; - tab.recording = recording; - return recording; - } catch (cause) { - // A start that fails partway must not leave its encoder page behind. - await encoder.close().catch(() => undefined); - await session?.detach().catch(() => undefined); - throw cause; - } - }; - - /** - * Runs a scaled capture with the tab's screencasts stopped, so viewers and - * recordings never receive the frame it renders at the capture scale. - */ + // Scaled captures repaint every screencast; pause them to avoid leaking that frame. const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; @@ -735,7 +659,7 @@ const make = Effect.gen(function* () { try { await Promise.all([ ...[...tab.viewers].map((viewer) => viewer.pause()), - recording?.session.send("Page.stopScreencast").catch(() => undefined), + recording?.session.send("Page.stopScreencast").catch(constVoid), ]); return await capture(); } finally { @@ -744,9 +668,7 @@ 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(() => undefined) + ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid) : undefined, ]); } @@ -761,19 +683,13 @@ const make = Effect.gen(function* () { "No recording is active for this tab.", ); } - type EncoderWindow = { - __t3Recorder: { - stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; - chunk(index: number): Promise; - }; - }; let mimeType: string | null; const chunks: Array = []; try { - await recording.session.send("Page.stopScreencast").catch(() => undefined); + await recording.session.send("Page.stopScreencast").catch(constVoid); // The last frames may still be on their way into the encoder. await Promise.all(recording.framesInFlight); - await recording.session.detach().catch(() => undefined); + await recording.session.detach().catch(constVoid); const stopped = await recording.encoder.evaluate(() => (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), ); @@ -793,7 +709,7 @@ const make = Effect.gen(function* () { chunks.push(Buffer.from(chunk, "base64")); } } finally { - await recording.encoder.close().catch(() => undefined); + await recording.encoder.close().catch(constVoid); tab.recording = null; } const data = Buffer.concat(chunks); @@ -803,8 +719,7 @@ const make = Effect.gen(function* () { "The recording captured no frames.", ); } - // Written where a desktop upload would land, so the MCP handler claims - // both the same way. + // Use the desktop upload location so the MCP handler can claim the recording. const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); @@ -850,10 +765,9 @@ const make = Effect.gen(function* () { const tab = request.tabId === undefined ? latestThreadTab(request.threadId) - : (tabs.get(tabKey(request.threadId, request.tabId)) ?? - (await Effect.runPromise( - findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(() => undefined)), - ))); + : await Effect.runPromise( + findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)), + ); if (!tab) { throw new ServerBrowserPage.ServerBrowserOperationError( "PreviewAutomationTabNotFoundError", @@ -885,9 +799,27 @@ const make = Effect.gen(function* () { ), ) : undefined; + const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); + const tab = + existing ?? + (await ensureTab( + await Effect.runPromise( + manager.open({ + threadId: request.threadId, + ...(url ? { url } : {}), + runtime: "server", + reveal: false, + }), + ), + )); + if (existing) { + if (url) await navigate(tab, url, "load", navigationTimeout); + } else { + // Await the original navigation failure even though background creation keeps the tab. + await tab.initialNavigation; + } const reveal = open.open ?? open.show; - const revealTab = async (tab: ServerTab) => { - if (reveal === false) return; + if (reveal !== false) { await Effect.runPromise( manager.requestReveal({ threadId: tab.threadId, @@ -895,34 +827,34 @@ const make = Effect.gen(function* () { force: reveal === true, }), ); - }; - const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); - if (existing) { - if (url) await navigate(existing, url, "load", navigationTimeout); - await revealTab(existing); - return statusWithTitle(existing); } - const snapshot = await Effect.runPromise( - manager.open({ - threadId: request.threadId, - ...(url ? { url } : {}), - runtime: "server", - reveal: false, - }), - ); - const tab = await ensureTab(snapshot); - // Wait for the requested document without blocking manager events for other tabs. - await tab.initialNavigation; - await revealTab(tab); - if (url) { - await tab.page - .waitForLoadState("load", { timeout: navigationTimeout }) - .catch(() => undefined); + if (!existing && url) { + await tab.page.waitForLoadState("load", { timeout: navigationTimeout }).catch(constVoid); } return statusWithTitle(tab); } + case "recordingStop": { + const recordings = [...tabs.values()].filter( + (candidate) => + candidate.threadId === request.threadId && + (candidate.recording || candidate.recordingStart), + ); + const targetTabId = request.tabId ?? latestThreadTab(request.threadId)?.tabId; + const tab = + recordings.find((candidate) => candidate.tabId === targetTabId) ?? + (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined); + if (!tab) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationRecordingNotActiveError", + "No recording is active for this thread.", + ); + } + return stopRecording(tab); + } + } + const tab = await requireTab(request); + switch (request.operation) { case "navigate": { - const tab = await requireTab(request); const navigateInput = input as PreviewAutomationNavigateInput; await recordAction(tab, "navigate", () => navigate( @@ -935,7 +867,6 @@ const make = Effect.gen(function* () { return statusWithTitle(tab); } case "resize": { - const tab = await requireTab(request); const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); await Effect.runPromise( manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), @@ -948,86 +879,48 @@ const make = Effect.gen(function* () { }; } case "setColorScheme": { - const tab = await requireTab(request); const { colorScheme } = input as PreviewAutomationSetColorSchemeInput; await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme }); return { tabId: tab.tabId, colorScheme }; } case "snapshot": { - const tab = await requireTab(request); return withScreencastsPaused(tab, () => - ServerBrowserPage.snapshot({ - page: tab.page, - cdp: tab.cdp, - renderScale: RENDER_SCALE, - consoleEntries: tab.consoleEntries, - networkEntries: tab.networkEntries, - actionTimeline: tab.actionTimeline, - }), + ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }), ); } case "click": { - const tab = await requireTab(request); const clickInput = input as PreviewAutomationClickInput; const point = await recordAction(tab, "click", () => ServerBrowserPage.click(tab.page, clickInput), ); - moveAgentCursor(tab, point, true); + void tab.recording?.encoder + .evaluate( + ([x, y]) => (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(x, y, true), + [point.x, point.y] as const, + ) + .catch(constVoid); return undefined; } - case "type": { - const tab = await requireTab(request); - await recordAction(tab, "type", () => + case "type": + return recordAction(tab, "type", () => ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput), ); - return undefined; - } - case "press": { - const tab = await requireTab(request); - await recordAction(tab, "press", () => + case "press": + return recordAction(tab, "press", () => ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput), ); - return undefined; - } - case "scroll": { - const tab = await requireTab(request); - await recordAction(tab, "scroll", () => + case "scroll": + return recordAction(tab, "scroll", () => ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), ); - return undefined; - } - case "evaluate": { - const tab = await requireTab(request); + case "evaluate": return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput); - } - case "waitFor": { - const tab = await requireTab(request); - await ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); - return undefined; - } + case "waitFor": + return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); case "recordingStart": { - const tab = await requireTab(request); const recording = await startRecording(tab); return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt }; } - case "recordingStop": { - const recordings = [...tabs.values()].filter( - (candidate) => - candidate.threadId === request.threadId && - (candidate.recording || candidate.recordingStart), - ); - const targetTabId = request.tabId ?? latestThreadTab(request.threadId)?.tabId; - const tab = - recordings.find((candidate) => candidate.tabId === targetTabId) ?? - (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined); - if (!tab) { - throw new ServerBrowserPage.ServerBrowserOperationError( - "PreviewAutomationRecordingNotActiveError", - "No recording is active for this thread.", - ); - } - return stopRecording(tab); - } } }; @@ -1061,13 +954,13 @@ const make = Effect.gen(function* () { const mirrorManagerEvent = (event: PreviewEvent) => Effect.promise(async () => { if (event.type === "opened" && event.snapshot.runtime === "server") { - await ensureTab(event.snapshot).catch(() => undefined); + await ensureTab(event.snapshot).catch(constVoid); return; } const tab = tabs.get(tabKey(event.threadId, event.tabId)); if (!tab) return; if (event.type === "resized" && event.snapshot.viewport) { - await applySetting(tab, event.snapshot.viewport).catch(() => undefined); + await applySetting(tab, event.snapshot.viewport).catch(constVoid); } else if (event.type === "closed") { dropTab(tab, false); } @@ -1154,16 +1047,14 @@ const make = Effect.gen(function* () { case "navigate": if (typeof message.url === "string") { const url = normalizePreviewUrl(message.url); - void tab.page.goto(url).catch(() => undefined); + void tab.page.goto(url).catch(constVoid); } return; case "history": - void (num(message.delta) < 0 ? tab.page.goBack() : tab.page.goForward()).catch( - () => undefined, - ); + void (num(message.delta) < 0 ? tab.page.goBack() : tab.page.goForward()).catch(constVoid); return; case "reload": - void tab.page.reload().catch(() => undefined); + void tab.page.reload().catch(constVoid); return; case "probe": { const x = num(message.x); @@ -1194,15 +1085,13 @@ const make = Effect.gen(function* () { Effect.promise(() => session .send("Page.stopScreencast") - .catch(() => undefined) + .catch(constVoid) .then(() => session.detach()) - .catch(() => undefined), + .catch(constVoid), ), ); const quality = Math.min(100, Math.max(1, Math.round(input.quality))); - // Frames that arrive while others are in flight may have pushed later ones - // out, and a page that then goes still never repaints them. A still - // after the burst shows the final state. + // Chromium may drop the final frame during a burst; send a still when it settles. let framesInFlight = 0; let mayHaveDropped = false; let motion = false; @@ -1214,7 +1103,7 @@ const make = Effect.gen(function* () { screencastParams = screencastParams.then(async () => { // A scaled capture is rendering; its resume starts the stream. if (tab.capturing > 0) return; - await session.send("Page.stopScreencast").catch(() => undefined); + await session.send("Page.stopScreencast").catch(constVoid); await session .send("Page.startScreencast", { format: "jpeg", @@ -1222,14 +1111,13 @@ const make = Effect.gen(function* () { maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), }) - .catch(() => undefined); + .catch(constVoid); }); return screencastParams; }; const viewer: ViewerState = { push: (next) => { - // Bounded: a viewer that stops reading drops messages instead of - // growing this queue. A dropped frame still releases Chromium. + // Dropped frames must still release Chromium. if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); // The stream only ends on `gone`, so it replaces a stalled backlog. @@ -1239,10 +1127,7 @@ const make = Effect.gen(function* () { }, pause: () => { screencastParams = screencastParams.then(() => - session.send("Page.stopScreencast").then( - () => undefined, - () => undefined, - ), + session.send("Page.stopScreencast").then(constVoid, constVoid), ); return screencastParams; }, @@ -1320,7 +1205,7 @@ const make = Effect.gen(function* () { framesInFlight -= 1; return session .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(() => undefined); + .catch(constVoid); }), ), }); @@ -1333,9 +1218,7 @@ const make = Effect.gen(function* () { return { output, input: (message: unknown) => - Effect.promise(() => - dispatchViewerInput(tab, session, viewer, message).catch(() => undefined), - ), + Effect.promise(() => dispatchViewerInput(tab, session, viewer, message).catch(constVoid)), } satisfies ServerBrowserViewer; }); @@ -1367,9 +1250,7 @@ const make = Effect.gen(function* () { ), ), ); - // The broker ends a host's stream when a request outlives its deadline, - // e.g. the first open while Chromium installs. Register again, or agents - // lose the browser until the server restarts. + // The broker disconnects timed-out hosts, including slow first installs. Reconnect. yield* hostSession.pipe( Effect.exit, Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)), @@ -1379,7 +1260,7 @@ const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.promise(async () => { for (const context of contexts.values()) { - await context.then((resolved) => resolved.close()).catch(() => undefined); + await context.then((resolved) => resolved.close()).catch(constVoid); } }), ); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index ddb0d791b3b7..937ff619dcb5 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -1,12 +1,5 @@ // @effect-diagnostics globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime. -/** - * Page-level automation for server-hosted preview tabs. - * - * Plain async helpers over one Playwright page, called by `ServerBrowser`. - * Results mirror what desktop hosts return for the same operations, so the - * MCP preview tools cannot tell the two runtimes apart. Failures carry the - * broker's error tags (see `PreviewAutomationBroker.classifyResponseError`). - */ + import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, type PreviewAutomationClickInput, @@ -40,7 +33,6 @@ export class ServerBrowserOperationError extends Error { } } -/** Maps a Playwright failure onto the tag the broker classifies. */ export const toOperationError = (cause: unknown): ServerBrowserOperationError => { if (cause instanceof ServerBrowserOperationError) return cause; const message = cause instanceof Error ? cause.message : String(cause); @@ -113,61 +105,31 @@ const SNAPSHOT_SCRIPT = `(() => { }; })()`; -/** - * The visible viewport as an image, `scale` relative to the rendered pixels. - * - * A scaled capture briefly re-renders the page at that scale, and every live - * screencast on the page streams that frame; callers pause them around it. - * At full scale there is no clip and nothing re-renders. The clip is - * document-relative, so it starts at the scroll offset. - */ +// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. export const captureViewport = async ( page: Page, cdp: CDPSession, options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number }, ) => { - const quality = options.quality === undefined ? {} : { quality: options.quality }; - if (options.scale >= 1) { - const { data } = await cdp.send("Page.captureScreenshot", { - format: options.format, - ...quality, - }); - return data; - } - const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - const { data } = await cdp.send("Page.captureScreenshot", { - format: options.format, - ...quality, - clip: { + let clip; + if (options.scale < 1) { + const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; + const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); + clip = { x: cssVisualViewport.pageX, y: cssVisualViewport.pageY, - width: viewport.width, - height: viewport.height, + ...viewport, scale: options.scale, - }, + }; + } + const { data } = await cdp.send("Page.captureScreenshot", { + format: options.format, + ...(options.quality === undefined ? {} : { quality: options.quality }), + ...(clip ? { clip } : {}), }); return data; }; -/** - * Screenshot of the current viewport at device resolution, scaled down to at - * most `MAX_SCREENSHOT_WIDTH` like desktop snapshots. `clip.scale` multiplies - * the render scale, so this needs no image library on the server. - */ - -const captureViewportPng = async (page: Page, cdp: CDPSession, renderScale: number) => { - const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; - const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); - const data = await captureViewport(page, cdp, { format: "png", scale }); - return { - mimeType: "image/png" as const, - data, - width: Math.round(viewport.width * renderScale * scale), - height: Math.round(viewport.height * renderScale * scale), - }; -}; - export const snapshot = async (input: { readonly page: Page; readonly cdp: CDPSession; @@ -176,14 +138,16 @@ export const snapshot = async (input: { readonly networkEntries: ReadonlyArray; readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }): Promise => { - const [page, screenshot] = await Promise.all([ + const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; + const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * input.renderScale)); + const [page, data] = await Promise.all([ input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< Pick< PreviewAutomationSnapshot, "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewportPng(input.page, input.cdp, input.renderScale), + captureViewport(input.page, input.cdp, { format: "png", scale }), ]); return { ...page, @@ -192,11 +156,15 @@ export const snapshot = async (input: { consoleEntries: [...input.consoleEntries], networkEntries: [...input.networkEntries], actionTimeline: [...input.actionTimeline], - screenshot, + screenshot: { + mimeType: "image/png", + data, + width: Math.round(viewport.width * input.renderScale * scale), + height: Math.round(viewport.height * input.renderScale * scale), + }, }; }; -/** Resolves the viewport point an action targets, for the agent cursor. */ export const click = async ( page: Page, input: PreviewAutomationClickInput, @@ -225,40 +193,16 @@ export const type = async (page: Page, input: PreviewAutomationTypeInput) => { const focused = (locator !== null || (await target.count()) > 0) && (await target.evaluate( - (element) => { - // Like the desktop host: an enabled text control or contenteditable - // that actually takes focus. Anything else would swallow the text or - // send it to whichever field had focus before. - const control = element as unknown as { - readonly type?: string; - readonly disabled?: boolean; - readonly readOnly?: boolean; - readonly isContentEditable?: boolean; - readonly focus?: () => void; - }; - const nonText = [ - "button", - "checkbox", - "color", - "file", - "hidden", - "image", - "radio", - "range", - "reset", - "submit", - ]; - const textControl = - element.tagName === "TEXTAREA" || - (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text")); - if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) { - return false; - } - control.focus?.(); - const root = element.getRootNode() as { readonly activeElement?: typeof element | null }; - const active = root.activeElement; + // Source avoids duplicating DOM types in this server-only module. + `(element) => { + const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; + const textControl = element.tagName === "TEXTAREA" || + (element.tagName === "INPUT" && !nonText.includes(element.type ?? "text")); + if (!(textControl || element.isContentEditable) || element.disabled || element.readOnly) return false; + element.focus?.(); + const active = element.getRootNode().activeElement; return active != null && (active === element || element.contains(active)); - }, + }`, undefined, { timeout }, )); @@ -315,7 +259,6 @@ export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluate return value; }; -/** Resolves once every supplied condition holds at the same moment. */ export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; @@ -347,7 +290,6 @@ export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) } }; -/** Chromium net error names reported for failed main-frame loads. */ const NET_ERROR_CODES: Readonly> = { ERR_FAILED: -2, ERR_TIMED_OUT: -7, @@ -366,14 +308,8 @@ export const parseNetError = (errorText: string) => { return { description, code: NET_ERROR_CODES[description] ?? -2 }; }; -/** - * In-page video encoder for recordings. Screencast frames are drawn onto a - * canvas and encoded by the browser's own MediaRecorder, so recording needs - * no ffmpeg on the server and costs about the same CPU as piping to x264. - * The agent cursor is drawn on the canvas, not injected into the page. The - * video keeps its first frame's size; a page resized mid-recording is - * letterboxed into it. - */ +// Browser-side encoding avoids ffmpeg. Resizes are letterboxed into the first frame; +// the agent cursor is drawn here so it never changes the page under test. export const RECORDING_ENCODER_SCRIPT = `(() => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index f9a2d8e4a7d9..9ffbc43b1405 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -1,13 +1,3 @@ -/** - * `/api/preview-stream/ws`: one server preview tab over a WebSocket. - * - * Frames go out as binary JPEG messages, viewport changes as JSON text, and - * viewer input comes back as JSON text. The viewer acknowledges each frame and - * Chromium's acknowledgement waits for it, so a slow link (phone over T3 - * Connect) gets fewer frames instead of a growing buffer. Authentication - * matches the device hub proxy; the socket drives the page, so it needs - * operate scope. - */ import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -119,18 +109,10 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => Effect.andThen(Effect.sync(() => unacknowledged.push(output.ack))), ); case "viewport": - return write( - JSON.stringify({ type: "viewport", width: output.width, height: output.height }), - ); - case "probe": - return write( - JSON.stringify({ - type: "probe", - x: output.x, - y: output.y, - editable: output.editable, - }), - ); + case "probe": { + const { _tag: type, ...data } = output; + return write(JSON.stringify({ type, ...data })); + } case "gone": return gone.pipe(Effect.andThen(Effect.interrupt)); } @@ -146,7 +128,6 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => const receiveInput = reader.pull.pipe( Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); - // Whichever side ends first closes the other through scope teardown. return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); }), ).pipe( @@ -160,8 +141,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => ); }); -// Route handlers only see request-scoped services, so the browser is captured -// when the route is registered. +// Capture the browser because handlers only see request-scoped services. export const routeLayer = HttpRouter.use((router) => Effect.flatMap(ServerBrowser.ServerBrowser, (browser) => router.add("GET", `${PREVIEW_STREAM_ROUTE_PREFIX}/*`, makeHandler(browser)), diff --git a/apps/server/src/preview/ServerBrowserToolchain.ts b/apps/server/src/preview/ServerBrowserToolchain.ts index 26fe3b500de2..b88a8abc0bca 100644 --- a/apps/server/src/preview/ServerBrowserToolchain.ts +++ b/apps/server/src/preview/ServerBrowserToolchain.ts @@ -1,19 +1,5 @@ -/** - * The Chromium the environment server runs for server-side preview tabs. - * - * Servers are often GPU-less containers without root or Chrome's system - * libraries, so on glibc Linux we install a pinned headless build from the - * `@sparticuz/chromium` GitHub release (its npm package ships x64 only): the - * executable with SwiftShader beside it, and NSS/NSPR/expat in `lib/` for - * `LD_LIBRARY_PATH`. A private fontconfig file layers optional T3-owned emoji - * and CJK fonts over whatever the system has. Everywhere else nothing is - * downloaded: we use an installed Chrome, Chromium, or Edge, or a Playwright - * cache build. - * - * Installs follow the pinned-runtime recipe: stage into a temp sibling, write - * the sentinel only after the tree is complete (and, for the browser, after - * the executable runs), then rename into place. - */ +// glibc Linux uses a pinned Chromium pack with its own libraries and fonts. +// Other hosts use installed browsers or the Playwright cache. import * as NodeStream from "@effect/platform-node/NodeStream"; import { HostProcessArchitecture, @@ -88,12 +74,8 @@ const SENTINEL = ".install-complete"; const xmlText = (value: string) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -/** - * Our cache dir comes first so fontconfig writes there (a relative cachedir - * resolves against the cwd, so it must be absolute). is scanned - * recursively and dot entries are skipped, so the fonts tool's staging dirs - * stay invisible and whichever fonts version is installed gets picked up. - */ +// Fontconfig skips dot staging directories and scans every installed fonts version. +// Put our absolute cache directory first so fontconfig can write without root. const fontconfigFile = ( browserDir: string, fontsToolDir: string, @@ -114,15 +96,9 @@ const FONTS_LAUNCH_WAIT = Duration.seconds(1); const browserInstallLock = Semaphore.makeUnsafe(1); const fontsInstallLock = Semaphore.makeUnsafe(1); -export type ServerBrowserSource = "override" | "bundled" | "system" | "playwright-cache"; - export interface ServerBrowserLaunch { readonly executablePath: string; - /** Merge over the server's environment when spawning. */ readonly env: Readonly>; - /** Flags this build needs beyond the launcher's own. */ - readonly args: ReadonlyArray; - readonly source: ServerBrowserSource; } export class ServerBrowserInstallError extends Schema.TaggedError()( @@ -138,6 +114,9 @@ export class ServerBrowserInstallError extends Schema.TaggedError (cause?: unknown) => + new ServerBrowserInstallError({ tool, step, cause }); + export class ServerBrowserNotFoundError extends Schema.TaggedError()( "ServerBrowserNotFoundError", { @@ -163,18 +142,10 @@ export class ServerBrowserToolchain extends Context.Service< } >()("t3/preview/ServerBrowserToolchain") {} -interface TarEntry { - readonly name: string; - readonly type: string; - readonly mode: number; - readonly data: Uint8Array; -} - /** Reads the plain GNU/ustar tarballs in the pinned pack: files, directories, long names. */ -const readTar = (archive: Uint8Array): ReadonlyArray => { +function* readTar(archive: Uint8Array) { const decoder = new TextDecoder(); const text = (bytes: Uint8Array) => decoder.decode(bytes).replace(/\0[\s\S]*$/, ""); - const entries: Array = []; let longName: string | undefined; let offset = 0; while (offset + 512 <= archive.length) { @@ -193,10 +164,9 @@ const readTar = (archive: Uint8Array): ReadonlyArray => { const prefix = field(257, 6) === "ustar" ? field(345, 155) : ""; const name = longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)); longName = undefined; - entries.push({ name, type, mode: Number.parseInt(field(100, 8).trim() || "644", 8), data }); + yield { name, type, mode: Number.parseInt(field(100, 8).trim() || "644", 8), data }; } - return entries; -}; +} const brotliDecompress = NodeUtil.promisify(NodeZlib.brotliDecompress); @@ -298,14 +268,12 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - /** Stages and atomically publishes `/tools//` if not already complete. */ const installOnce = Effect.fn("ServerBrowserToolchain.installOnce")(function* ( tool: string, version: string, populate: (stagingDir: string) => Effect.Effect, ) { - const fail = (step: string) => (cause: unknown) => - new ServerBrowserInstallError({ tool, step, cause }); + const fail = (step: string) => installFailure(tool, step); const parentDir = path.join(toolsDir, tool); const installDir = path.join(parentDir, version); if (yield* isInstalled(installDir, version)) return; @@ -347,9 +315,7 @@ const make = Effect.gen(function* () { Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.arrayBuffer), Effect.map((buffer) => new Uint8Array(buffer)), - Effect.mapError( - (cause) => new ServerBrowserInstallError({ tool, step: `downloading ${asset.url}`, cause }), - ), + Effect.mapError(installFailure(tool, `downloading ${asset.url}`)), ); if (NodeCrypto.createHash("sha256").update(bytes).digest("hex") !== asset.sha256) { return yield* new ServerBrowserInstallError({ tool, step: `verifying ${asset.url}` }); @@ -366,10 +332,12 @@ const make = Effect.gen(function* () { const populateBrowser = (pack: PinnedAsset) => Effect.fn("ServerBrowserToolchain.populateBrowser")(function* (stagingDir: string) { - const fail = (step: string) => (cause: unknown) => - new ServerBrowserInstallError({ tool: BROWSER_TOOL, step, cause }); + const fail = (step: string) => installFailure(BROWSER_TOOL, step); const members = new Map( - readTar(yield* download(BROWSER_TOOL, pack)).map((entry) => [entry.name, entry.data]), + Array.from( + readTar(yield* download(BROWSER_TOOL, pack)), + ({ name, data }) => [name, data] as const, + ), ); const member = (name: string) => { const data = members.get(name); @@ -377,7 +345,7 @@ const make = Effect.gen(function* () { ? Effect.fail( new ServerBrowserInstallError({ tool: BROWSER_TOOL, step: `finding ${name}` }), ) - : Effect.succeed(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); + : Effect.succeed(data); }; // Stream the ~200 MB executable to disk instead of inflating it in memory. @@ -389,8 +357,6 @@ const make = Effect.gen(function* () { Effect.mapError(fail("unpacking chromium.br")), ); - // SwiftShader sits beside the executable, NSS/NSPR/expat land in lib/, - // and fonts.tar adds Open Sans for hosts with no fonts at all. for (const name of ["swiftshader.tar.br", "al2023.tar.br", "fonts.tar.br"]) { const compressed = yield* member(name); const archive = yield* Effect.tryPromise({ @@ -438,42 +404,25 @@ const make = Effect.gen(function* () { } }); - const populateFonts = Effect.fn("ServerBrowserToolchain.populateFonts")(function* ( - stagingDir: string, - ) { - yield* Effect.forEach( + const populateFonts = (stagingDir: string) => + Effect.forEach( FONTS, - (font) => - download(FONTS_TOOL, font).pipe( - Effect.flatMap((bytes) => - fs.writeFile(path.join(stagingDir, font.file), bytes).pipe( - Effect.mapError( - (cause) => - new ServerBrowserInstallError({ - tool: FONTS_TOOL, - step: `saving ${font.file}`, - cause, - }), - ), - ), - ), - ), + Effect.fn(function* (font) { + const bytes = yield* download(FONTS_TOOL, font); + yield* fs + .writeFile(path.join(stagingDir, font.file), bytes) + .pipe(Effect.mapError(installFailure(FONTS_TOOL, `saving ${font.file}`))); + }), { concurrency: "unbounded", discard: true }, ); - }); - // Fonts are optional: one attempt per server process that never fails the - // browser. It runs detached so a cancelled or failed resolve cannot - // interrupt it, which would cache the interruption for every later call. + // Detach optional fonts so cancellation cannot poison the cached install attempt. const fontsFiber = yield* Effect.cached( Effect.forkDetach( fontsInstallLock.withPermit(installOnce(FONTS_TOOL, FONTS_VERSION, populateFonts)).pipe( Effect.timeoutOrElse({ duration: FONTS_INSTALL_TIMEOUT, - orElse: () => - Effect.fail( - new ServerBrowserInstallError({ tool: FONTS_TOOL, step: "downloading (timed out)" }), - ), + orElse: () => Effect.fail(installFailure(FONTS_TOOL, "downloading (timed out)")()), }), Effect.catch((cause) => Effect.logWarning("Preview browser fonts unavailable; emoji and CJK may not render", { @@ -483,18 +432,10 @@ const make = Effect.gen(function* () { ), ), ); - // The first launch waits briefly for fonts, then goes without; later - // launches pick them up once the background install lands. const ensureFonts = Effect.flatMap(fontsFiber, (fiber) => Fiber.join(fiber).pipe(Effect.timeoutOption(FONTS_LAUNCH_WAIT), Effect.asVoid), ); - const bundledLaunch: ServerBrowserLaunch = { - executablePath: path.join(browserDir, "chromium"), - env: launchEnv(browserDir), - args: [], - source: "bundled", - }; const bundled = (pack: PinnedAsset) => Effect.all( [ @@ -503,21 +444,16 @@ const make = Effect.gen(function* () { .pipe( Effect.timeoutOrElse({ duration: BROWSER_INSTALL_TIMEOUT, - orElse: () => - Effect.fail( - new ServerBrowserInstallError({ - tool: BROWSER_TOOL, - step: "installing (timed out)", - }), - ), + orElse: () => Effect.fail(installFailure(BROWSER_TOOL, "installing (timed out)")()), }), ), ensureFonts, ], { concurrency: "unbounded", discard: true }, - ).pipe(Effect.as(bundledLaunch)); + ).pipe( + Effect.as({ executablePath: path.join(browserDir, "chromium"), env: launchEnv(browserDir) }), + ); - /** Resolves bare names through PATH and checks absolute paths are executable. */ const executable = (candidate: string) => resolveCommandPath(candidate).pipe( Effect.option, @@ -540,8 +476,6 @@ const make = Effect.gen(function* () { return { executablePath: system, env: {}, - args: [], - source: "system", } satisfies ServerBrowserLaunch; } const playwrightRoot = yield* Effect.try(() => @@ -572,8 +506,6 @@ const make = Effect.gen(function* () { return { executablePath: cached, env: {}, - args: [], - source: "playwright-cache", } satisfies ServerBrowserLaunch; } } @@ -591,8 +523,6 @@ const make = Effect.gen(function* () { return { executablePath: resolved.value, env: libraryPath ? { LD_LIBRARY_PATH: libraryPath } : {}, - args: [], - source: "override", } satisfies ServerBrowserLaunch; } if (browserPack === undefined) return yield* discover; diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 183f777bd5e8..daa25d70f5c5 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -11,8 +11,6 @@ import { } from "@t3tools/client-runtime/preview/server-browser-stream"; import type { EnvironmentId } from "@t3tools/contracts"; import { - type ClipboardEvent, - type CompositionEvent, type FormEvent, type KeyboardEvent, type PointerEvent, @@ -34,7 +32,6 @@ export interface ServerBrowserHandle { readonly navigate: (url: string) => void; readonly history: (delta: -1 | 1) => void; readonly reload: () => void; - /** The canvas showing the latest frame. */ readonly canvas: () => HTMLCanvasElement | null; } @@ -91,13 +88,6 @@ const buttonOf = (button: number): PreviewStreamMouseButton => const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; -/** - * A server-hosted preview tab: JPEG frames from the environment's headless - * Chromium drawn into a canvas, with pointer, wheel, and keyboard input sent - * back in page coordinates. Touch taps click and touch drags scroll; the soft - * keyboard opens only for taps the server reports as landing on a text field. - * `visible=false` closes the stream so a hidden panel decodes nothing. - */ export function ServerBrowserSurface(props: { readonly environmentId: EnvironmentId; readonly threadId: string; @@ -122,7 +112,6 @@ export function ServerBrowserSurface(props: { ref, } = props; const access = usePreviewStreamAccess(environmentId); - const containerRef = useRef
+ This tab is open in the T3 Code desktop app. +
+ Browser connection was refused. +