Skip to content
Merged
999 changes: 999 additions & 0 deletions apps/desktop/src/preview/FaviconCapture.test.ts

Large diffs are not rendered by default.

679 changes: 679 additions & 0 deletions apps/desktop/src/preview/FaviconCapture.ts

Large diffs are not rendered by default.

604 changes: 604 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts

Large diffs are not rendered by default.

225 changes: 204 additions & 21 deletions apps/desktop/src/preview/Manager.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type {
DesktopPreviewAnnotationTheme,
DesktopPreviewColorScheme,
DesktopPreviewFavicon,
DesktopPreviewPointerEvent,
PreviewAnnotationPayload,
PreviewAnnotationRect,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";

export type PreviewNavStatus =
| { kind: "Idle" }
Expand All @@ -85,6 +87,7 @@ export interface PreviewTabState {
pictureInPicture: boolean;
colorScheme: DesktopPreviewColorScheme;
controller: "human" | "agent" | "none";
favicon?: DesktopPreviewFavicon;
updatedAt: string;
}

Expand Down Expand Up @@ -346,7 +349,10 @@ type PreviewInputSignal =
| { readonly kind: "key"; readonly key: string; readonly code: string };

interface ManagedListeners {
readonly attachmentId: symbol;
readonly cancelFaviconCapture: () => void;
readonly scope: Scope.Closeable;
readonly webContents: Electron.WebContents;
}

type FrameCaptureConsumer = "picture-in-picture" | "recording";
Expand Down Expand Up @@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* (
tabId: string,
state: PreviewTabState,
) {
if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) {
yield* emit(tabId, state);
}
});

const update = Effect.fn("PreviewManager.update")(function* (
tabId: string,
patch: Partial<PreviewTabState>,
Expand Down Expand Up @@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
copy.delete(webContentsId);
}),
]);
if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
if (managed) {
managed.cancelFaviconCapture();
yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
}
});

const isAppShortcut = (input: Electron.Input): boolean =>
Expand Down Expand Up @@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc: Electron.WebContents,
) {
const scope = yield* Scope.fork(parentScope, "sequential");
const attachmentId = Symbol();
let documentId = 0;
let nextRequestId = 0;
let activeCapture: {
readonly controller: AbortController;
readonly documentId: number;
readonly eventKey: string;
readonly requestId: number;
} | null = null;
const cancelFaviconCapture = () => {
documentId += 1;
activeCapture?.controller.abort();
activeCapture = null;
};
const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
preserveLoadFailure: boolean,
confirmedNavigation = false,
) {
if (wc.isDestroyed()) return;
const zoomFactor = yield* attempt(
Expand All @@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const updatedAt = yield* currentIso;
const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
const current = tabs.get(tabId);
if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) {
return [Option.none<PreviewTabState>(), tabs] as const;
}
// Electron emits did-stop-loading after did-fail-load. At that point the
// failed guest is no longer "loading", but it has not successfully
// navigated anywhere. Keep the failure until a new load actually starts.
Expand All @@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
computedNavStatus.kind === "Success"
? current.navStatus
: computedNavStatus;
const clearFavicon =
confirmedNavigation &&
current.favicon !== undefined &&
safeHttpOrigin(current.favicon.pageUrl) !==
safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url);
const { favicon: _favicon, ...currentWithoutFavicon } = current;
const state: PreviewTabState = {
...current,
...(clearFavicon ? currentWithoutFavicon : current),
navStatus,
canGoBack,
canGoForward,
Expand All @@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
}),
] as const;
});
if (Option.isSome(next)) yield* emit(tabId, next.value);
if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
});
const sync = () => runFork(syncState(true));
const syncNavigation = () => runFork(syncState(false));
const syncNavigation = () => runFork(syncState(false, true));
const syncInPageNavigation = () => runFork(syncState(false));
const navigationStarted = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
) => {
if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
};
const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
readonly captureDocumentId: number;
readonly dataUrl: string;
readonly pageUrl: string;
readonly requestId: number;
}) {
const pageOrigin = safeHttpOrigin(input.pageUrl);
const managed = (yield* Ref.get(attachedRef)).get(wc.id);
if (
!pageOrigin ||
wc.isDestroyed() ||
webContents.fromId(wc.id) !== wc ||
managed?.attachmentId !== attachmentId ||
activeCapture?.documentId !== input.captureDocumentId ||
activeCapture.requestId !== input.requestId ||
safeHttpOrigin(wc.getURL()) !== pageOrigin
) {
return;
}
const capturedAt = yield* currentMillis;
const updatedAt = yield* currentIso;
const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
const current = tabs.get(tabId);
if (
!current ||
current.webContentsId !== wc.id ||
webContents.fromId(wc.id) !== wc ||
activeCapture?.documentId !== input.captureDocumentId ||
activeCapture.requestId !== input.requestId
) {
return [Option.none<PreviewTabState>(), tabs] as const;
}
const state: PreviewTabState = {
...current,
favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt },
updatedAt,
};
return [
Option.some(state),
replaceMap(tabs, (copy) => {
copy.set(tabId, state);
}),
] as const;
});
if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
});
const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray<string>): void => {
const pageUrl = wc.getURL();
if (!safeHttpOrigin(pageUrl)) return;
const candidates = selectFaviconCandidates(rawCandidates);
if (candidates.length === 0) return;
const eventKey = JSON.stringify([pageUrl, ...candidates]);
if (activeCapture?.eventKey === eventKey) return;
activeCapture?.controller.abort();
const captureDocumentId = documentId;
const requestId = ++nextRequestId;
const controller = new AbortController();
activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId };
runFork(
Effect.tryPromise({
try: () =>
captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }),
catch: (cause) =>
new PreviewOperationError({
operation: "captureFavicon",
tabId,
webContentsId: wc.id,
cause,
}),
}).pipe(
Effect.flatMap((result) =>
result.kind === "captured"
? publishFavicon({
captureDocumentId,
dataUrl: result.dataUrl,
pageUrl,
requestId,
})
: Effect.void,
),
Effect.catch((error) =>
controller.signal.aborted
? Effect.void
: Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }),
),
Effect.ensuring(
Effect.sync(() => {
if (activeCapture?.requestId === requestId) activeCapture = null;
}),
),
),
);
};
const failed = (
_event: Event,
code: number,
Expand Down Expand Up @@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
yield* Scope.addFinalizer(
scope,
attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
cancelFaviconCapture();
wc.off("did-start-navigation", navigationStarted);
wc.off("did-navigate", syncNavigation);
wc.off("did-navigate-in-page", syncNavigation);
wc.off("did-navigate-in-page", syncInPageNavigation);
wc.off("page-title-updated", sync);
wc.off("page-favicon-updated", faviconUpdated as never);
Comment thread
cursor[bot] marked this conversation as resolved.
wc.off("did-start-loading", sync);
wc.off("did-stop-loading", sync);
wc.off("did-fail-load", failed as never);
Expand All @@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
wc.on("did-start-navigation", navigationStarted);
wc.on("did-navigate", syncNavigation);
wc.on("did-navigate-in-page", syncNavigation);
wc.on("did-navigate-in-page", syncInPageNavigation);
wc.on("page-title-updated", sync);
wc.on("page-favicon-updated", faviconUpdated as never);
wc.on("did-start-loading", sync);
wc.on("did-stop-loading", sync);
wc.on("did-fail-load", failed as never);
Expand All @@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});
yield* Ref.update(attachedRef, (attached) =>
replaceMap(attached, (copy) => {
copy.set(wc.id, { scope });
copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc });
}),
);
});
Expand Down Expand Up @@ -1561,14 +1706,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const mainWindow = yield* Ref.get(mainWindowRef);
if (
!wc ||
wc.isDestroyed() ||
wc.getType() !== "webview" ||
(Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
) {
return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });
}
const attached = yield* Ref.get(attachedRef);
const annotationTheme = yield* Ref.get(annotationThemeRef);
if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
const currentAttachment = attached.get(webContentsId);
if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
const zoomFactor = yield* attempt(
{ operation: "registerWebview.getZoomFactor", tabId, webContentsId },
() => wc.getZoomFactor(),
Expand All @@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return;
}
const replacedWebContentsId =
tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null;
tab.webContentsId != null &&
(tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc)
? tab.webContentsId
: null;
if (replacedWebContentsId !== null) {
yield* Effect.all(
[
Expand Down Expand Up @@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
] as const;
}
const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
const { favicon: _favicon, ...currentWithoutFavicon } = current;
const next: PreviewTabState = {
...current,
...currentWithoutFavicon,
webContentsId,
navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
canGoBack: wc.navigationHistory.canGoBack(),
Expand Down Expand Up @@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
pictureInPicture: current?.pictureInPicture ?? false,
colorScheme: current?.colorScheme ?? "system",
controller: current?.controller ?? "none",
...(current?.favicon ? { favicon: current.favicon } : {}),
updatedAt,
};
return [
Expand All @@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});
yield* emit(tabId, pending);
if (pending.webContentsId == null) return;
const wc = webContents.fromId(pending.webContentsId);
if (!wc) {
const detached = { ...pending, webContentsId: null };
yield* SynchronizedRef.update(tabsRef, (tabs) =>
tabs.get(tabId)?.webContentsId !== pending.webContentsId
? tabs
: replaceMap(tabs, (copy) => {
copy.set(tabId, detached);
}),
const webContentsId = pending.webContentsId;
const wc = webContents.fromId(webContentsId);
if (!wc || wc.isDestroyed()) {
const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
yield* withTabLifecycleLock(
tabId,
Effect.gen(function* () {
const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
const currentWebContents = webContents.fromId(webContentsId);
if (
currentTab?.webContentsId !== webContentsId ||
currentAttachment !== expectedAttachment ||
(currentWebContents && !currentWebContents.isDestroyed())
) {
return;
}
yield* Effect.all(
[
detachControlSession(webContentsId),
detachListeners(webContentsId),
cancelPickElement(tabId),
],
{ concurrency: 3, discard: true },
);
const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
const current = tabs.get(tabId);
if (current?.webContentsId !== webContentsId) {
return [Option.none<PreviewTabState>(), tabs] as const;
}
const { favicon: _favicon, ...currentWithoutFavicon } = current;
const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null };
return [
Option.some(next),
replaceMap(tabs, (copy) => {
copy.set(tabId, next);
}),
] as const;
});
if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value);
}),
);
yield* emit(tabId, detached);
return;
}
if (wc.getURL() === url) {
Expand Down
Loading
Loading