Skip to content

Commit 710fd0e

Browse files
authored
feat(desktop): add favicons to the Browser panel (#5644)
1 parent e15f655 commit 710fd0e

28 files changed

Lines changed: 4189 additions & 71 deletions

apps/desktop/src/preview/FaviconCapture.test.ts

Lines changed: 999 additions & 0 deletions
Large diffs are not rendered by default.

apps/desktop/src/preview/FaviconCapture.ts

Lines changed: 679 additions & 0 deletions
Large diffs are not rendered by default.

apps/desktop/src/preview/Manager.test.ts

Lines changed: 604 additions & 0 deletions
Large diffs are not rendered by default.

apps/desktop/src/preview/Manager.ts

Lines changed: 204 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type {
99
DesktopPreviewAnnotationTheme,
1010
DesktopPreviewColorScheme,
11+
DesktopPreviewFavicon,
1112
DesktopPreviewPointerEvent,
1213
PreviewAnnotationPayload,
1314
PreviewAnnotationRect,
@@ -62,6 +63,7 @@ import {
6263
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
6364
import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
6465
import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
66+
import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
6567

6668
export 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

348351
interface ManagedListeners {
352+
readonly attachmentId: symbol;
353+
readonly cancelFaviconCapture: () => void;
349354
readonly scope: Scope.Closeable;
355+
readonly webContents: Electron.WebContents;
350356
}
351357

352358
type 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

Comments
 (0)