@@ -39,11 +39,12 @@ import * as Context from "effect/Context";
3939import * as Effect from "effect/Effect" ;
4040import { constVoid } from "effect/Function" ;
4141import * as Layer from "effect/Layer" ;
42+ import * as Option from "effect/Option" ;
4243import * as Queue from "effect/Queue" ;
4344import * as Schema from "effect/Schema" ;
4445import type * as Scope from "effect/Scope" ;
4546import * 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
4849import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts" ;
4950import * 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
145152export 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+
233260const tabKey = ( threadId : string , tabId : string ) => `${ threadId } \u0000${ tabId } ` ;
234261
235262const 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
16721765export const layer = Layer . effect ( ServerBrowser , make ) ;
0 commit comments