88import type {
99 DesktopPreviewAnnotationTheme ,
1010 DesktopPreviewColorScheme ,
11+ DesktopPreviewFavicon ,
1112 DesktopPreviewPointerEvent ,
1213 PreviewAnnotationPayload ,
1314 PreviewAnnotationRect ,
@@ -62,6 +63,7 @@ import {
6263import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts" ;
6364import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts" ;
6465import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts" ;
66+ import { captureFavicon , safeHttpOrigin , selectFaviconCandidates } from "./FaviconCapture.ts" ;
6567
6668export type PreviewNavStatus =
6769 | { kind : "Idle" }
@@ -85,6 +87,7 @@ export interface PreviewTabState {
8587 pictureInPicture : boolean ;
8688 colorScheme : DesktopPreviewColorScheme ;
8789 controller : "human" | "agent" | "none" ;
90+ favicon ?: DesktopPreviewFavicon ;
8891 updatedAt : string ;
8992}
9093
@@ -346,7 +349,10 @@ type PreviewInputSignal =
346349 | { readonly kind : "key" ; readonly key : string ; readonly code : string } ;
347350
348351interface ManagedListeners {
352+ readonly attachmentId : symbol ;
353+ readonly cancelFaviconCapture : ( ) => void ;
349354 readonly scope : Scope . Closeable ;
355+ readonly webContents : Electron . WebContents ;
350356}
351357
352358type FrameCaptureConsumer = "picture-in-picture" | "recording" ;
@@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
613619 ) ;
614620 } ) ;
615621
622+ const emitIfCurrent = Effect . fn ( "PreviewManager.emitIfCurrent" ) ( function * (
623+ tabId : string ,
624+ state : PreviewTabState ,
625+ ) {
626+ if ( ( yield * SynchronizedRef . get ( tabsRef ) ) . get ( tabId ) === state ) {
627+ yield * emit ( tabId , state ) ;
628+ }
629+ } ) ;
630+
616631 const update = Effect . fn ( "PreviewManager.update" ) ( function * (
617632 tabId : string ,
618633 patch : Partial < PreviewTabState > ,
@@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12041219 copy . delete ( webContentsId ) ;
12051220 } ) ,
12061221 ] ) ;
1207- if ( managed ) yield * Scope . close ( managed . scope , Exit . void ) . pipe ( Effect . ignore ) ;
1222+ if ( managed ) {
1223+ managed . cancelFaviconCapture ( ) ;
1224+ yield * Scope . close ( managed . scope , Exit . void ) . pipe ( Effect . ignore ) ;
1225+ }
12081226 } ) ;
12091227
12101228 const isAppShortcut = ( input : Electron . Input ) : boolean =>
@@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12681286 wc : Electron . WebContents ,
12691287 ) {
12701288 const scope = yield * Scope . fork ( parentScope , "sequential" ) ;
1289+ const attachmentId = Symbol ( ) ;
1290+ let documentId = 0 ;
1291+ let nextRequestId = 0 ;
1292+ let activeCapture : {
1293+ readonly controller : AbortController ;
1294+ readonly documentId : number ;
1295+ readonly eventKey : string ;
1296+ readonly requestId : number ;
1297+ } | null = null ;
1298+ const cancelFaviconCapture = ( ) => {
1299+ documentId += 1 ;
1300+ activeCapture ?. controller . abort ( ) ;
1301+ activeCapture = null ;
1302+ } ;
12711303 const syncState = Effect . fn ( "PreviewManager.syncWebContentsState" ) ( function * (
12721304 preserveLoadFailure : boolean ,
1305+ confirmedNavigation = false ,
12731306 ) {
12741307 if ( wc . isDestroyed ( ) ) return ;
12751308 const zoomFactor = yield * attempt (
@@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12821315 const updatedAt = yield * currentIso ;
12831316 const next = yield * SynchronizedRef . modify ( tabsRef , ( tabs ) => {
12841317 const current = tabs . get ( tabId ) ;
1285- if ( ! current ) return [ Option . none < PreviewTabState > ( ) , tabs ] as const ;
1318+ if ( ! current || current . webContentsId !== wc . id || webContents . fromId ( wc . id ) !== wc ) {
1319+ return [ Option . none < PreviewTabState > ( ) , tabs ] as const ;
1320+ }
12861321 // Electron emits did-stop-loading after did-fail-load. At that point the
12871322 // failed guest is no longer "loading", but it has not successfully
12881323 // navigated anywhere. Keep the failure until a new load actually starts.
@@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12921327 computedNavStatus . kind === "Success"
12931328 ? current . navStatus
12941329 : computedNavStatus ;
1330+ const clearFavicon =
1331+ confirmedNavigation &&
1332+ current . favicon !== undefined &&
1333+ safeHttpOrigin ( current . favicon . pageUrl ) !==
1334+ safeHttpOrigin ( navStatus . kind === "Idle" ? wc . getURL ( ) : navStatus . url ) ;
1335+ const { favicon : _favicon , ...currentWithoutFavicon } = current ;
12951336 const state : PreviewTabState = {
1296- ...current ,
1337+ ...( clearFavicon ? currentWithoutFavicon : current ) ,
12971338 navStatus,
12981339 canGoBack,
12991340 canGoForward,
@@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13071348 } ) ,
13081349 ] as const ;
13091350 } ) ;
1310- if ( Option . isSome ( next ) ) yield * emit ( tabId , next . value ) ;
1351+ if ( Option . isSome ( next ) ) yield * emitIfCurrent ( tabId , next . value ) ;
13111352 } ) ;
13121353 const sync = ( ) => runFork ( syncState ( true ) ) ;
1313- const syncNavigation = ( ) => runFork ( syncState ( false ) ) ;
1354+ const syncNavigation = ( ) => runFork ( syncState ( false , true ) ) ;
1355+ const syncInPageNavigation = ( ) => runFork ( syncState ( false ) ) ;
1356+ const navigationStarted = (
1357+ event : Electron . Event < Electron . WebContentsDidStartNavigationEventParams > ,
1358+ ) => {
1359+ if ( event . isMainFrame && ! event . isSameDocument ) cancelFaviconCapture ( ) ;
1360+ } ;
1361+ const publishFavicon = Effect . fn ( "PreviewManager.publishFavicon" ) ( function * ( input : {
1362+ readonly captureDocumentId : number ;
1363+ readonly dataUrl : string ;
1364+ readonly pageUrl : string ;
1365+ readonly requestId : number ;
1366+ } ) {
1367+ const pageOrigin = safeHttpOrigin ( input . pageUrl ) ;
1368+ const managed = ( yield * Ref . get ( attachedRef ) ) . get ( wc . id ) ;
1369+ if (
1370+ ! pageOrigin ||
1371+ wc . isDestroyed ( ) ||
1372+ webContents . fromId ( wc . id ) !== wc ||
1373+ managed ?. attachmentId !== attachmentId ||
1374+ activeCapture ?. documentId !== input . captureDocumentId ||
1375+ activeCapture . requestId !== input . requestId ||
1376+ safeHttpOrigin ( wc . getURL ( ) ) !== pageOrigin
1377+ ) {
1378+ return ;
1379+ }
1380+ const capturedAt = yield * currentMillis ;
1381+ const updatedAt = yield * currentIso ;
1382+ const next = yield * SynchronizedRef . modify ( tabsRef , ( tabs ) => {
1383+ const current = tabs . get ( tabId ) ;
1384+ if (
1385+ ! current ||
1386+ current . webContentsId !== wc . id ||
1387+ webContents . fromId ( wc . id ) !== wc ||
1388+ activeCapture ?. documentId !== input . captureDocumentId ||
1389+ activeCapture . requestId !== input . requestId
1390+ ) {
1391+ return [ Option . none < PreviewTabState > ( ) , tabs ] as const ;
1392+ }
1393+ const state : PreviewTabState = {
1394+ ...current ,
1395+ favicon : { dataUrl : input . dataUrl , pageUrl : pageOrigin , capturedAt } ,
1396+ updatedAt,
1397+ } ;
1398+ return [
1399+ Option . some ( state ) ,
1400+ replaceMap ( tabs , ( copy ) => {
1401+ copy . set ( tabId , state ) ;
1402+ } ) ,
1403+ ] as const ;
1404+ } ) ;
1405+ if ( Option . isSome ( next ) ) yield * emitIfCurrent ( tabId , next . value ) ;
1406+ } ) ;
1407+ const faviconUpdated = ( _event : Event , rawCandidates : ReadonlyArray < string > ) : void => {
1408+ const pageUrl = wc . getURL ( ) ;
1409+ if ( ! safeHttpOrigin ( pageUrl ) ) return ;
1410+ const candidates = selectFaviconCandidates ( rawCandidates ) ;
1411+ if ( candidates . length === 0 ) return ;
1412+ const eventKey = JSON . stringify ( [ pageUrl , ...candidates ] ) ;
1413+ if ( activeCapture ?. eventKey === eventKey ) return ;
1414+ activeCapture ?. controller . abort ( ) ;
1415+ const captureDocumentId = documentId ;
1416+ const requestId = ++ nextRequestId ;
1417+ const controller = new AbortController ( ) ;
1418+ activeCapture = { controller, documentId : captureDocumentId , eventKey, requestId } ;
1419+ runFork (
1420+ Effect . tryPromise ( {
1421+ try : ( ) =>
1422+ captureFavicon ( { webContents : wc , pageUrl, candidates, signal : controller . signal } ) ,
1423+ catch : ( cause ) =>
1424+ new PreviewOperationError ( {
1425+ operation : "captureFavicon" ,
1426+ tabId,
1427+ webContentsId : wc . id ,
1428+ cause,
1429+ } ) ,
1430+ } ) . pipe (
1431+ Effect . flatMap ( ( result ) =>
1432+ result . kind === "captured"
1433+ ? publishFavicon ( {
1434+ captureDocumentId,
1435+ dataUrl : result . dataUrl ,
1436+ pageUrl,
1437+ requestId,
1438+ } )
1439+ : Effect . void ,
1440+ ) ,
1441+ Effect . catch ( ( error ) =>
1442+ controller . signal . aborted
1443+ ? Effect . void
1444+ : Effect . logDebug ( "Favicon capture failed." , { error, tabId, webContentsId : wc . id } ) ,
1445+ ) ,
1446+ Effect . ensuring (
1447+ Effect . sync ( ( ) => {
1448+ if ( activeCapture ?. requestId === requestId ) activeCapture = null ;
1449+ } ) ,
1450+ ) ,
1451+ ) ,
1452+ ) ;
1453+ } ;
13141454 const failed = (
13151455 _event : Event ,
13161456 code : number ,
@@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13871527 yield * Scope . addFinalizer (
13881528 scope ,
13891529 attempt ( { operation : "detachListeners" , tabId, webContentsId : wc . id } , ( ) => {
1530+ cancelFaviconCapture ( ) ;
1531+ wc . off ( "did-start-navigation" , navigationStarted ) ;
13901532 wc . off ( "did-navigate" , syncNavigation ) ;
1391- wc . off ( "did-navigate-in-page" , syncNavigation ) ;
1533+ wc . off ( "did-navigate-in-page" , syncInPageNavigation ) ;
13921534 wc . off ( "page-title-updated" , sync ) ;
1535+ wc . off ( "page-favicon-updated" , faviconUpdated as never ) ;
13931536 wc . off ( "did-start-loading" , sync ) ;
13941537 wc . off ( "did-stop-loading" , sync ) ;
13951538 wc . off ( "did-fail-load" , failed as never ) ;
@@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13991542 ) ;
14001543 const install = Effect . fn ( "PreviewManager.installWebContentsListeners" ) ( function * ( ) {
14011544 yield * attempt ( { operation : "attachListeners" , tabId, webContentsId : wc . id } , ( ) => {
1545+ wc . on ( "did-start-navigation" , navigationStarted ) ;
14021546 wc . on ( "did-navigate" , syncNavigation ) ;
1403- wc . on ( "did-navigate-in-page" , syncNavigation ) ;
1547+ wc . on ( "did-navigate-in-page" , syncInPageNavigation ) ;
14041548 wc . on ( "page-title-updated" , sync ) ;
1549+ wc . on ( "page-favicon-updated" , faviconUpdated as never ) ;
14051550 wc . on ( "did-start-loading" , sync ) ;
14061551 wc . on ( "did-stop-loading" , sync ) ;
14071552 wc . on ( "did-fail-load" , failed as never ) ;
@@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14181563 } ) ;
14191564 yield * Ref . update ( attachedRef , ( attached ) =>
14201565 replaceMap ( attached , ( copy ) => {
1421- copy . set ( wc . id , { scope } ) ;
1566+ copy . set ( wc . id , { attachmentId , cancelFaviconCapture , scope, webContents : wc } ) ;
14221567 } ) ,
14231568 ) ;
14241569 } ) ;
@@ -1561,14 +1706,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15611706 const mainWindow = yield * Ref . get ( mainWindowRef ) ;
15621707 if (
15631708 ! wc ||
1709+ wc . isDestroyed ( ) ||
15641710 wc . getType ( ) !== "webview" ||
15651711 ( Option . isSome ( mainWindow ) && wc . hostWebContents !== mainWindow . value . webContents )
15661712 ) {
15671713 return yield * new PreviewWebContentsNotFoundError ( { tabId, webContentsId } ) ;
15681714 }
15691715 const attached = yield * Ref . get ( attachedRef ) ;
15701716 const annotationTheme = yield * Ref . get ( annotationThemeRef ) ;
1571- if ( tab . webContentsId === webContentsId && attached . has ( webContentsId ) ) {
1717+ const currentAttachment = attached . get ( webContentsId ) ;
1718+ if ( tab . webContentsId === webContentsId && currentAttachment ?. webContents === wc ) {
15721719 const zoomFactor = yield * attempt (
15731720 { operation : "registerWebview.getZoomFactor" , tabId, webContentsId } ,
15741721 ( ) => wc . getZoomFactor ( ) ,
@@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15801727 return ;
15811728 }
15821729 const replacedWebContentsId =
1583- tab . webContentsId != null && tab . webContentsId !== webContentsId ? tab . webContentsId : null ;
1730+ tab . webContentsId != null &&
1731+ ( tab . webContentsId !== webContentsId || currentAttachment ?. webContents !== wc )
1732+ ? tab . webContentsId
1733+ : null ;
15841734 if ( replacedWebContentsId !== null ) {
15851735 yield * Effect . all (
15861736 [
@@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16271777 ] as const ;
16281778 }
16291779 const pendingUrl = current . navStatus . kind === "Loading" ? current . navStatus . url : null ;
1780+ const { favicon : _favicon , ...currentWithoutFavicon } = current ;
16301781 const next : PreviewTabState = {
1631- ...current ,
1782+ ...currentWithoutFavicon ,
16321783 webContentsId,
16331784 navStatus : pendingUrl === null ? computeNavStatus ( wc ) : current . navStatus ,
16341785 canGoBack : wc . navigationHistory . canGoBack ( ) ,
@@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17071858 pictureInPicture : current ?. pictureInPicture ?? false ,
17081859 colorScheme : current ?. colorScheme ?? "system" ,
17091860 controller : current ?. controller ?? "none" ,
1861+ ...( current ?. favicon ? { favicon : current . favicon } : { } ) ,
17101862 updatedAt,
17111863 } ;
17121864 return [
@@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17181870 } ) ;
17191871 yield * emit ( tabId , pending ) ;
17201872 if ( pending . webContentsId == null ) return ;
1721- const wc = webContents . fromId ( pending . webContentsId ) ;
1722- if ( ! wc ) {
1723- const detached = { ...pending , webContentsId : null } ;
1724- yield * SynchronizedRef . update ( tabsRef , ( tabs ) =>
1725- tabs . get ( tabId ) ?. webContentsId !== pending . webContentsId
1726- ? tabs
1727- : replaceMap ( tabs , ( copy ) => {
1728- copy . set ( tabId , detached ) ;
1729- } ) ,
1873+ const webContentsId = pending . webContentsId ;
1874+ const wc = webContents . fromId ( webContentsId ) ;
1875+ if ( ! wc || wc . isDestroyed ( ) ) {
1876+ const expectedAttachment = ( yield * Ref . get ( attachedRef ) ) . get ( webContentsId ) ;
1877+ yield * withTabLifecycleLock (
1878+ tabId ,
1879+ Effect . gen ( function * ( ) {
1880+ const currentTab = ( yield * SynchronizedRef . get ( tabsRef ) ) . get ( tabId ) ;
1881+ const currentAttachment = ( yield * Ref . get ( attachedRef ) ) . get ( webContentsId ) ;
1882+ const currentWebContents = webContents . fromId ( webContentsId ) ;
1883+ if (
1884+ currentTab ?. webContentsId !== webContentsId ||
1885+ currentAttachment !== expectedAttachment ||
1886+ ( currentWebContents && ! currentWebContents . isDestroyed ( ) )
1887+ ) {
1888+ return ;
1889+ }
1890+ yield * Effect . all (
1891+ [
1892+ detachControlSession ( webContentsId ) ,
1893+ detachListeners ( webContentsId ) ,
1894+ cancelPickElement ( tabId ) ,
1895+ ] ,
1896+ { concurrency : 3 , discard : true } ,
1897+ ) ;
1898+ const detached = yield * SynchronizedRef . modify ( tabsRef , ( tabs ) => {
1899+ const current = tabs . get ( tabId ) ;
1900+ if ( current ?. webContentsId !== webContentsId ) {
1901+ return [ Option . none < PreviewTabState > ( ) , tabs ] as const ;
1902+ }
1903+ const { favicon : _favicon , ...currentWithoutFavicon } = current ;
1904+ const next : PreviewTabState = { ...currentWithoutFavicon , webContentsId : null } ;
1905+ return [
1906+ Option . some ( next ) ,
1907+ replaceMap ( tabs , ( copy ) => {
1908+ copy . set ( tabId , next ) ;
1909+ } ) ,
1910+ ] as const ;
1911+ } ) ;
1912+ if ( Option . isSome ( detached ) ) yield * emitIfCurrent ( tabId , detached . value ) ;
1913+ } ) ,
17301914 ) ;
1731- yield * emit ( tabId , detached ) ;
17321915 return ;
17331916 }
17341917 if ( wc . getURL ( ) === url ) {
0 commit comments