Skip to content

Commit cbd62b3

Browse files
feat(preview): hand server-browser downloads to the viewer
A file a server tab downloaded was discarded. It is now saved under the environment's state directory until the tab closes (20 per tab, 1 GiB each) and offered to the viewer in control: web shows a Save toast linking to an authenticated /api/preview-stream/download route, mobile offers the share sheet. preview_status lists the tab's downloads with their environment paths so agents can read them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 16d6eb8 commit cbd62b3

11 files changed

Lines changed: 372 additions & 9 deletions

File tree

‎apps/mobile/src/features/browser/PreviewStreamWebView.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import previewStreamScript from "@t3tools/mobile-preview-stream";
22
import {
33
previewStreamControlLabel,
44
type PreviewStreamControl,
5+
type PreviewStreamDownload,
56
type PreviewStreamInput,
67
} from "@t3tools/client-runtime/preview/server-browser-stream";
78
import type { EnvironmentId } from "@t3tools/contracts";
@@ -15,11 +16,12 @@ import {
1516
useState,
1617
type Ref,
1718
} from "react";
18-
import { ActivityIndicator, Platform, Pressable, TextInput, View } from "react-native";
19+
import { ActivityIndicator, Alert, Platform, Pressable, TextInput, View } from "react-native";
1920
import { WebView } from "react-native-webview";
2021
import * as Clipboard from "expo-clipboard";
2122

2223
import { AppText } from "../../components/AppText";
24+
import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
2325
import { usePreviewStreamAccess } from "../../state/preview";
2426

2527
import {
@@ -54,6 +56,27 @@ type NativeStreamBridge = {
5456
readonly compact?: boolean;
5557
};
5658

59+
/** The file is on the environment; saving it here goes through the share sheet. */
60+
function offerDownload(download: PreviewStreamDownload) {
61+
Alert.alert(`Downloaded ${download.fileName}`, undefined, [
62+
{ text: "Not now", style: "cancel" },
63+
{
64+
text: "Save or share",
65+
onPress: () =>
66+
void downloadAndShareAttachment({
67+
url: download.url,
68+
attachment: { name: download.fileName, mimeType: "application/octet-stream" },
69+
signal: new AbortController().signal,
70+
}).catch((cause: unknown) =>
71+
Alert.alert(
72+
"Could not save the download",
73+
cause instanceof Error ? cause.message : undefined,
74+
),
75+
),
76+
},
77+
]);
78+
}
79+
5780
// Consecutive refused tickets before the view stops and offers Reconnect, e.g. a
5881
// session without operate scope.
5982
const MAX_REFUSALS = 3;
@@ -323,6 +346,9 @@ function PreviewStreamDocumentView({
323346
case "clipboard":
324347
void Clipboard.setStringAsync(message.text).catch(() => undefined);
325348
return;
349+
case "download":
350+
offerDownload(message);
351+
return;
326352
case "pictureInPicture":
327353
onPictureInPicture?.(message, message.detail);
328354
return;

‎apps/mobile/src/features/browser/preview-stream-document.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
2-
import type { PreviewStreamControl } from "@t3tools/client-runtime/preview/server-browser-stream";
2+
import type {
3+
PreviewStreamControl,
4+
PreviewStreamDownload,
5+
} from "@t3tools/client-runtime/preview/server-browser-stream";
36

47
export interface PreviewStreamConfiguration {
58
readonly access: DeviceHubAccess;
@@ -22,6 +25,7 @@ export type PreviewStreamMessage =
2225
| { readonly type: "gone" }
2326
| { readonly type: "viewport"; readonly width: number; readonly height: number }
2427
| { readonly type: "clipboard"; readonly text: string }
28+
| ({ readonly type: "download" } & PreviewStreamDownload)
2529
| {
2630
readonly type: "pictureInPicture";
2731
readonly supported: boolean;

‎apps/mobile/src/features/browser/preview-stream.browser.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export function start(configuration: PreviewStreamConfiguration) {
171171
{
172172
onFrame: (jpeg) => painter.paint(jpeg),
173173
onClipboard: (text) => post({ type: "clipboard", text }),
174+
onDownload: (download) => post({ type: "download", ...download }),
174175
onViewport: (page) => {
175176
if (viewport?.width === page.width && viewport.height === page.height) return;
176177
viewport = page;

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
} from "@t3tools/contracts";
1313
import * as Effect from "effect/Effect";
1414
import * as Fiber from "effect/Fiber";
15+
import * as FileSystem from "effect/FileSystem";
1516
import * as Layer from "effect/Layer";
17+
import * as Option from "effect/Option";
1618
import * as Queue from "effect/Queue";
1719
import type { BrowserContext, Page } from "playwright-core";
1820
import { afterEach, beforeEach, expect, vi } from "vite-plus/test";
@@ -671,3 +673,46 @@ it.live("page copies reach only the controlling viewer right after its input", (
671673
}),
672674
).pipe(Effect.provide(layer)),
673675
);
676+
677+
it.live("a page download is saved, offered to the controller, and listed for the agent", () =>
678+
Effect.scoped(
679+
Effect.gen(function* () {
680+
const { browser, broker, tabId } = yield* ready;
681+
const page = contexts[0]!.page;
682+
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
683+
yield* viewer.input({ type: "takeControl" });
684+
yield* Queue.clear(viewer.output);
685+
const fs = yield* FileSystem.FileSystem;
686+
const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());
687+
page.emit("download", {
688+
failure: async () => null,
689+
saveAs: (path: string) => runPromise(fs.writeFileString(path, "a,b")),
690+
suggestedFilename: () => "report.csv",
691+
url: () => "blob:http://localhost:5173/1",
692+
});
693+
let offered = yield* Queue.take(viewer.output);
694+
while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
695+
expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 });
696+
const file = yield* browser.openDownload({
697+
threadId: scope.threadId,
698+
tabId,
699+
downloadId: offered.id,
700+
});
701+
expect(Option.isSome(file) && file.value.fileName).toBe("report.csv");
702+
const status = yield* broker.invoke<PreviewAutomationStatus>({
703+
scope,
704+
tabId,
705+
operation: "status",
706+
input: {},
707+
});
708+
expect(status.downloads).toEqual([
709+
expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }),
710+
]);
711+
expect(
712+
Option.isNone(
713+
yield* browser.openDownload({ threadId: scope.threadId, tabId, downloadId: "guess" }),
714+
),
715+
).toBe(true);
716+
}),
717+
).pipe(Effect.provide(layer)),
718+
);

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ import * as Context from "effect/Context";
3939
import * as Effect from "effect/Effect";
4040
import { constVoid } from "effect/Function";
4141
import * as Layer from "effect/Layer";
42+
import * as Option from "effect/Option";
4243
import * as Queue from "effect/Queue";
4344
import * as Schema from "effect/Schema";
4445
import type * as Scope from "effect/Scope";
4546
import * as Stream from "effect/Stream";
46-
import type { BrowserContext, CDPSession, Dialog, Page } from "playwright-core";
47+
import type { BrowserContext, CDPSession, Dialog, Download, Page } from "playwright-core";
4748

4849
import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts";
4950
import * as ServerConfig from "../config.ts";
@@ -140,6 +141,12 @@ export type ServerBrowserViewerOutput =
140141
readonly editable: boolean;
141142
}
142143
| { readonly _tag: "clipboard"; readonly text: string }
144+
| {
145+
readonly _tag: "download";
146+
readonly id: string;
147+
readonly fileName: string;
148+
readonly sizeBytes: number;
149+
}
143150
| { readonly _tag: "gone" };
144151

145152
export interface ServerBrowserViewer {
@@ -163,6 +170,12 @@ export class ServerBrowser extends Context.Service<
163170
ServerBrowserTabNotFoundError | ServerBrowserLaunchError,
164171
Scope.Scope
165172
>;
173+
/** A finished download of a live tab, for the authenticated download route. */
174+
readonly openDownload: (input: {
175+
readonly threadId: string;
176+
readonly tabId: string;
177+
readonly downloadId: string;
178+
}) => Effect.Effect<Option.Option<{ readonly path: string; readonly fileName: string }>>;
166179
/** Deletes a human profile's server-side storage, closing its open tabs first. */
167180
readonly clearProfile: (profileId: string) => Effect.Effect<void, PreviewClearProfileError>;
168181
}
@@ -216,6 +229,8 @@ interface ServerTab {
216229
readonly profileId: string | undefined;
217230
/** Set when a page in another tab opened this one with `window.open` or a link. */
218231
readonly openerTabId: string | undefined;
232+
/** Finished downloads, newest last; files live until the tab closes. */
233+
readonly downloads: Array<ServerDownload>;
219234
dialog: Dialog | null;
220235
setting: PreviewViewportSetting;
221236
loading: boolean;
@@ -230,6 +245,18 @@ interface ServerTab {
230245
capturing: number;
231246
}
232247

248+
interface ServerDownload {
249+
readonly id: string;
250+
readonly fileName: string;
251+
readonly path: string;
252+
readonly sizeBytes: number;
253+
readonly url: string;
254+
readonly completedAt: string;
255+
}
256+
257+
const DOWNLOAD_LIMIT = 20;
258+
const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
259+
233260
const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;
234261

235262
const pushBounded = <A>(
@@ -468,6 +495,7 @@ const make = Effect.gen(function* () {
468495
void tab.page.close().catch(constVoid);
469496
if (tab.isolatedContext) void tab.page.context().close().catch(constVoid);
470497
void tab.recording?.encoder.close().catch(constVoid);
498+
void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid);
471499
reportLiveTabs();
472500
if (closeSession) {
473501
runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore));
@@ -510,6 +538,7 @@ const make = Effect.gen(function* () {
510538
isolatedContext,
511539
profileId: snapshot.profileId,
512540
openerTabId: adopted?.openerTabId,
541+
downloads: [],
513542
dialog: null,
514543
setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
515544
loading: false,
@@ -577,6 +606,7 @@ const make = Effect.gen(function* () {
577606
tab.dialog = dialog;
578607
broadcastControl(tab);
579608
});
609+
page.on("download", (download) => void saveDownload(tab, download));
580610
// Popups become tabs and keep `window.opener`, so sign-in popups can report back.
581611
page.on("popup", (popup) => void adoptPopup(tab, popup));
582612
// Playwright cannot reload a crashed page, so it must leave the tab list.
@@ -602,6 +632,62 @@ const make = Effect.gen(function* () {
602632
return tab;
603633
};
604634

635+
const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads");
636+
// Tab ids are opaque server strings; hashing keeps them out of the path.
637+
const downloadDir = (tab: ServerTab) =>
638+
NodePath.join(
639+
downloadsRoot,
640+
NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),
641+
);
642+
643+
const saveDownload = async (tab: ServerTab, download: Download) => {
644+
const id = NodeCrypto.randomUUID();
645+
const path = NodePath.join(downloadDir(tab), id);
646+
try {
647+
if (await download.failure()) return;
648+
await NodeFSP.mkdir(downloadDir(tab), { recursive: true });
649+
await download.saveAs(path);
650+
const { size } = await NodeFSP.stat(path);
651+
if (tab.closing || size > DOWNLOAD_MAX_BYTES) {
652+
await NodeFSP.rm(path, { force: true });
653+
return;
654+
}
655+
const saved: ServerDownload = {
656+
id,
657+
fileName: download.suggestedFilename().slice(0, 255) || "download",
658+
path,
659+
sizeBytes: size,
660+
url: download.url().slice(0, 2048),
661+
completedAt: new Date().toISOString(),
662+
};
663+
tab.downloads.push(saved);
664+
for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {
665+
await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);
666+
}
667+
// Only the person driving the page gets the file offered; agents read it from status.
668+
const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
669+
controller?.push({
670+
_tag: "download",
671+
id,
672+
fileName: saved.fileName,
673+
sizeBytes: saved.sizeBytes,
674+
});
675+
} catch (cause) {
676+
await NodeFSP.rm(path, { force: true }).catch(constVoid);
677+
runFork(Effect.logWarning("server preview download failed", { cause }));
678+
}
679+
};
680+
681+
const openDownload: ServerBrowser["Service"]["openDownload"] = (input) =>
682+
Effect.sync(() => {
683+
const download = tabs
684+
.get(tabKey(input.threadId, input.tabId))
685+
?.downloads.find((candidate) => candidate.id === input.downloadId);
686+
return download === undefined
687+
? Option.none()
688+
: Option.some({ path: download.path, fileName: download.fileName });
689+
});
690+
605691
const preparedContexts = new WeakSet<BrowserContext>();
606692
const prepareContext = async (context: BrowserContext) => {
607693
if (preparedContexts.has(context)) return;
@@ -731,6 +817,13 @@ const make = Effect.gen(function* () {
731817
url: candidate.page.url() === "about:blank" ? null : candidate.page.url(),
732818
...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }),
733819
})),
820+
downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({
821+
fileName,
822+
path,
823+
sizeBytes,
824+
url,
825+
completedAt,
826+
})),
734827
};
735828
if (status.url === null || tab.dialog) return status;
736829
return { ...status, title: (await tab.page.title().catch(() => "")) || null };
@@ -1666,7 +1759,7 @@ const make = Effect.gen(function* () {
16661759
catch: (cause) => new PreviewClearProfileError({ profileId, cause }),
16671760
});
16681761

1669-
return ServerBrowser.of({ enabled, attachViewer, clearProfile });
1762+
return ServerBrowser.of({ enabled, attachViewer, clearProfile, openDownload });
16701763
});
16711764

16721765
export const layer = Layer.effect(ServerBrowser, make);

0 commit comments

Comments
 (0)