44 DesktopBrowserCommand ,
55 DesktopBrowserEvent ,
66 type DesktopBrowserEvent as DesktopBrowserEventType ,
7+ type PreviewAutomationLoadError ,
78} from "@t3tools/contracts" ;
89import * as Context from "effect/Context" ;
910import * as NodePath from "node:path" ;
@@ -48,7 +49,7 @@ interface AttachedTab {
4849 readonly debuggee : DesktopBrowserTabDebugger ;
4950 relay : CdpRelayConnection | null ;
5051 screenshotTurn : object | null ;
51- /** Each holds focus and painting while `held` and whoever queued it still waits on it . */
52+ /** Each keeps the guest painting while whoever queued it still waits, and focused while `held` . */
5253 readonly waitingScreenshots : Array < {
5354 readonly start : ( ) => void ;
5455 readonly live : ( ) => boolean ;
@@ -61,10 +62,9 @@ interface AttachedTab {
6162 focusChange : Promise < void > | null ;
6263 focusUses : number ;
6364 /** Each pending command, by the extra session waiting on it; the main connection's have none. */
64- readonly focusPending : Map <
65- object ,
66- { readonly owner : string | undefined ; readonly paints : boolean }
67- > ;
65+ readonly focusPending : Map < object , string | undefined > ;
66+ /** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */
67+ readonly capturing : Set < object > ;
6868 /** Whether the client keeps the guest painting, as published on `painting`. */
6969 painting : boolean ;
7070 /**
@@ -93,6 +93,7 @@ export class DesktopBrowserHost extends Context.Service<
9393 readonly handleCommandLine : ( line : string ) => Effect . Effect < void > ;
9494 readonly attach : ( key : DesktopBrowserTabKey , debuggee : DesktopBrowserTabDebugger ) => void ;
9595 readonly detach : ( key : DesktopBrowserTabKey ) => void ;
96+ readonly loadFailed : ( key : DesktopBrowserTabKey , failure : PreviewAutomationLoadError ) => void ;
9697 readonly setShown : ( key : DesktopBrowserTabKey , shown : boolean | undefined ) => void ;
9798 readonly humanStartedDownload : ( source : Electron . WebContents ) => boolean ;
9899 readonly placeDownload : ( source : Electron . WebContents , item : Electron . DownloadItem ) => boolean ;
@@ -148,7 +149,7 @@ export const make = Effect.gen(function* () {
148149 tab : AttachedTab ,
149150 relay : CdpRelayConnection ,
150151 ownerLive : ( ) => boolean ,
151- /** Sends only while `current` holds; holds focus and painting only while `held`. */
152+ /** Sends only while `current` holds; holds focus only while `held`. */
152153 capture : ( current : ( ) => boolean , held : boolean ) => Promise < unknown > ,
153154 ) =>
154155 new Promise < unknown > ( ( resolve , reject ) => {
@@ -236,38 +237,42 @@ export const make = Effect.gen(function* () {
236237 const active =
237238 tabs . get ( keyOf ( tab . key ) ) === tab &&
238239 ( tab . screencasts . size > 0 ||
239- waitingHolds ( tab ) ||
240- [ ... tab . focusPending . values ( ) ] . some ( ( hold ) => hold . paints ) ) ;
240+ tab . capturing . size > 0 ||
241+ tab . waitingScreenshots . some ( ( waiting ) => waiting . live ( ) ) ) ;
241242 if ( tab . painting === active ) return ;
242243 tab . painting = active ;
243244 runFork ( PubSub . publish ( painting , { key : tab . key , active } ) ) ;
244245 } ;
245246
246247 /** A command may wait on the page's next frame, so the guest stays focused until it settles. */
247- const holdFocus = < A > (
248- tab : AttachedTab ,
249- method : string ,
250- owner : string | undefined ,
251- work : ( ) => Promise < A > ,
252- ) => {
248+ const holdFocus = < A > ( tab : AttachedTab , owner : string | undefined , work : ( ) => Promise < A > ) => {
253249 const pending = { } ;
254- tab . focusPending . set ( pending , { owner, paints : method === "Page.captureScreenshot" } ) ;
250+ tab . focusPending . set ( pending , owner ) ;
255251 tab . focusUses += 1 ;
256252 setFocusEmulation ( tab , true ) ;
257253 releaseFocusWhenIdle ( tab ) ;
258- syncPainting ( tab ) ;
259254 return work ( ) . finally ( ( ) => {
260255 if ( ! tab . focusPending . delete ( pending ) ) return ;
261256 tab . focusUses += 1 ;
262257 releaseFocusWhenIdle ( tab ) ;
258+ } ) ;
259+ } ;
260+
261+ /** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */
262+ const paintUntilAnswered = < A > ( tab : AttachedTab , send : ( ) => Promise < A > ) => {
263+ const sent = { } ;
264+ tab . capturing . add ( sent ) ;
265+ syncPainting ( tab ) ;
266+ return send ( ) . finally ( ( ) => {
267+ tab . capturing . delete ( sent ) ;
263268 syncPainting ( tab ) ;
264269 } ) ;
265270 } ;
266271
267272 /**
268273 * Chromium can leave a command unanswered, so the screenshot guard's release points
269- * also end every hold: a released relay has no one waiting, and a new document answers
270- * nothing pending from the old one. A released relay's screencast has no viewer to stop it .
274+ * also end every focus hold: a released relay has no one waiting, and a new document
275+ * answers nothing pending from the old one. A released relay's screencast has no viewer.
271276 */
272277 const dropFocusHolds = ( tab : AttachedTab ) => {
273278 tab . focusPending . clear ( ) ;
@@ -285,24 +290,24 @@ export const make = Effect.gen(function* () {
285290 /** A detached session, such as a closed viewer, waits on nothing it sent. */
286291 const dropSessionHolds = ( tab : AttachedTab , owner : string ) => {
287292 tab . screencasts . delete ( owner ) ;
288- for ( const [ pending , hold ] of tab . focusPending ) {
289- if ( hold . owner === owner ) tab . focusPending . delete ( pending ) ;
293+ for ( const [ pending , holder ] of tab . focusPending ) {
294+ if ( holder === owner ) tab . focusPending . delete ( pending ) ;
290295 }
291296 releaseFocusWhenIdle ( tab ) ;
292297 syncPainting ( tab ) ;
293298 } ;
294299
295300 /**
296301 * A command the server stopped waiting on, such as a timed-out screenshot or evaluation,
297- * may never settle; the turn's end ends every hold. The agent also sends on its own extra
302+ * may never settle; the turn's end ends every focus hold. The agent also sends on its own extra
298303 * session, so this cannot spare other sessions; a viewer's screencast still holds.
299304 */
300305 const endAgentTurn = ( threadId : string ) => {
301306 agentTurns . delete ( threadId ) ;
302307 for ( const tab of tabs . values ( ) ) {
303308 if ( tab . key . threadId !== threadId ) continue ;
304309 tab . agentUsed = false ;
305- // Still sent in turn, so the queue keeps moving, but no longer kept painting .
310+ // Still sent in turn, so the queue keeps moving, but no longer kept focused .
306311 for ( const waiting of tab . waitingScreenshots ) waiting . held = false ;
307312 dropFocusHolds ( tab ) ;
308313 }
@@ -323,8 +328,10 @@ export const make = Effect.gen(function* () {
323328 if ( sessionId === undefined && method === "Page.startScreencast" ) {
324329 tab . screencasts . add ( owner ) ;
325330 if ( owner !== undefined ) streamingOwners . add ( owner ) ;
331+ syncPainting ( tab ) ;
326332 } else if ( sessionId === undefined && method === "Page.stopScreencast" ) {
327333 tab . screencasts . delete ( owner ) ;
334+ syncPainting ( tab ) ;
328335 }
329336 // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's.
330337 if (
@@ -337,9 +344,11 @@ export const make = Effect.gen(function* () {
337344 const forward = ( current ?: ( ) => boolean ) => {
338345 const change = tab . focusChange ;
339346 const run = ( ) =>
340- tab . relay === relay && ( current === undefined || current ( ) )
341- ? send ( method , params , sessionId )
342- : Promise . reject ( new Error ( "The page this screenshot was for has moved on." ) ) ;
347+ tab . relay !== relay || ( current !== undefined && ! current ( ) )
348+ ? Promise . reject ( new Error ( "The page this screenshot was for has moved on." ) )
349+ : current === undefined
350+ ? send ( method , params , sessionId )
351+ : paintUntilAnswered ( tab , ( ) => send ( method , params , sessionId ) ) ;
343352 return change === null ? run ( ) : change . then ( run ) ;
344353 } ;
345354 return method === "Page.captureScreenshot"
@@ -348,17 +357,17 @@ export const make = Effect.gen(function* () {
348357 relay ,
349358 ( ) => owner === undefined || ! detachedOwners . has ( owner ) ,
350359 ( current , held ) =>
351- held ? holdFocus ( tab , method , owner , ( ) => forward ( current ) ) : forward ( current ) ,
360+ held ? holdFocus ( tab , owner , ( ) => forward ( current ) ) : forward ( current ) ,
352361 )
353362 : method === "Page.startScreencast"
354- ? holdFocus ( tab , method , owner , forward ) . catch ( ( cause : unknown ) => {
363+ ? holdFocus ( tab , owner , forward ) . catch ( ( cause : unknown ) => {
355364 // A viewer can outlive a refused stream; nothing is streaming for it.
356365 if ( sessionId === undefined && tab . relay === relay ) tab . screencasts . delete ( owner ) ;
357366 releaseFocusWhenIdle ( tab ) ;
358367 syncPainting ( tab ) ;
359368 throw cause ;
360369 } )
361- : holdFocus ( tab , method , owner , forward ) ;
370+ : holdFocus ( tab , owner , forward ) ;
362371 } ,
363372 sessionDetached : ( owner ) => {
364373 detachedOwners . add ( owner ) ;
@@ -445,6 +454,7 @@ export const make = Effect.gen(function* () {
445454 focusChange : null ,
446455 focusUses : 0 ,
447456 focusPending : new Map ( ) ,
457+ capturing : new Set ( ) ,
448458 painting : false ,
449459 agentUsed : false ,
450460 focusReleasing : false ,
@@ -459,7 +469,8 @@ export const make = Effect.gen(function* () {
459469 // A screenshot pending when the page loads a new document, a reload included, can
460470 // stay unanswered. A crashed page lands here once it reloads, well after its renderer died.
461471 onNewDocument : ( ) => {
462- // Before the next screenshot starts, so its own hold survives.
472+ // Before the next screenshot starts, so its own holds survive.
473+ tab . capturing . clear ( ) ;
463474 dropFocusHolds ( tab ) ;
464475 if ( tab . screenshotTurn !== null ) nextScreenshot ( tab ) ;
465476 } ,
@@ -523,6 +534,10 @@ export const make = Effect.gen(function* () {
523534 if ( tabs . get ( id ) ?. stopWaiting === null ) emit ( { type : "shown" , ...key , shown } ) ;
524535 } ;
525536
537+ const loadFailed : DesktopBrowserHost [ "Service" ] [ "loadFailed" ] = ( key , failure ) => {
538+ if ( tabs . has ( keyOf ( key ) ) ) emit ( { type : "loadFailed" , ...key , ...failure } ) ;
539+ } ;
540+
526541 const handleCommandLine = ( line : string ) =>
527542 Effect . sync ( ( ) => {
528543 const command = decodeCommand ( line ) ;
@@ -588,6 +603,7 @@ export const make = Effect.gen(function* () {
588603 handleCommandLine,
589604 attach,
590605 detach,
606+ loadFailed,
591607 setShown,
592608 placeDownload,
593609 humanStartedDownload,
0 commit comments