Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DesktopBrowserCommand,
DesktopBrowserEvent,
type DesktopBrowserEvent as DesktopBrowserEventType,
type PreviewAutomationLoadError,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -93,6 +94,7 @@ export class DesktopBrowserHost extends Context.Service<
readonly handleCommandLine: (line: string) => Effect.Effect<void>;
readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
readonly detach: (key: DesktopBrowserTabKey) => void;
readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void;
readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void;
readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
Expand Down Expand Up @@ -523,6 +525,10 @@ export const make = Effect.gen(function* () {
if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown });
};

const loadFailed: DesktopBrowserHost["Service"]["loadFailed"] = (key, failure) => {
if (tabs.has(keyOf(key))) emit({ type: "loadFailed", ...key, ...failure });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};

const handleCommandLine = (line: string) =>
Effect.sync(() => {
const command = decodeCommand(line);
Expand Down Expand Up @@ -588,6 +594,7 @@ export const make = Effect.gen(function* () {
handleCommandLine,
attach,
detach,
loadFailed,
setShown,
placeDownload,
humanStartedDownload,
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ describe("PreviewManager", () => {
attach: () => {},
detach: () => {},
setShown: () => {},
loadFailed: () => {},
humanStartedDownload: () => true,
placeDownload: () => true,
}),
Expand Down Expand Up @@ -1986,6 +1987,97 @@ describe("PreviewManager", () => {
}).pipe(Effect.provide(managerLayer()), Effect.scoped),
);

effectIt.effect("reports early and subsequent load failures for the current guest", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.DesktopBrowserHost;
const serverTab = { threadId: "thread-1", tabId: "server-tab-1" };
const lines = yield* Queue.unbounded<string>();
yield* host.events.pipe(
Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))),
Effect.forkScoped({ startImmediately: true }),
);
const makeGuest = (id: number) => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const wc = Object.assign(
makeTestPreviewWebContents(
async () => ({
toJPEG: () => Buffer.from("jpeg"),
getSize: () => ({ width: 100, height: 80 }),
}),
id,
),
{
isDevToolsOpened: () => false,
getUserAgent: () => "Electron",
once: vi.fn(),
on: (event: string, listener: (...args: unknown[]) => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
},
);
const emit = (event: string, ...args: unknown[]) => {
for (const listener of listeners.get(event) ?? []) listener(...args);
};
return {
wc,
emit,
fail: (url: string) =>
emit("did-fail-load", {}, -202, "ERR_CERT_AUTHORITY_INVALID", url, true),
};
};
const initial = makeGuest(42);
const replacement = makeGuest(43);
const manager = yield* PreviewManager.PreviewManager;
const statuses: PreviewManager.PreviewNavStatus[] = [];
yield* manager.subscribeStateChanges((_tabId, state) =>
Effect.sync(() => {
statuses.push(state.navStatus);
}),
);
yield* manager.createTab("tab_1", { serverTab });
yield* manager.prepareWebview(initial.wc);
yield* manager.prepareWebview(replacement.wc);
initial.fail("https://first.local/");
initial.emit("did-start-navigation", { isMainFrame: false, isSameDocument: false });
initial.emit("did-start-loading");
replacement.fail("https://second.local/");
const failure = (url: string) => ({
type: "loadFailed",
...serverTab,
url,
code: -202,
description: "ERR_CERT_AUTHORITY_INVALID",
});

fromId.mockReturnValue(initial.wc);
yield* manager.registerWebview("tab_1", 42);
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab });
expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://first.local/"));

fromId.mockReturnValue(replacement.wc);
yield* manager.registerWebview("tab_1", 43);
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab });
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab });
expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://second.local/"));

// A callback queued by the old guest must not report a failure for its replacement.
initial.fail("https://stale.local/");
replacement.fail("https://current.local/");
expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://current.local/"));
expect(statuses).not.toContainEqual(expect.objectContaining({ url: "https://stale.local/" }));

const retried = makeGuest(44);
yield* manager.prepareWebview(retried.wc);
retried.fail("https://retry.local/");
retried.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
fromId.mockReturnValue(retried.wc);
yield* manager.registerWebview("tab_1", 44);
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab });
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab });
expect(statuses.at(-1)?.kind).toBe("Success");
}).pipe(Effect.provide(managerLayer()), Effect.scoped),
);

effectIt.effect("discards a screenshot that resolves after its guest is replaced", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
75 changes: 64 additions & 11 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
DesktopPreviewScreenshotArtifact,
DesktopPreviewTabDefaults,
PreviewForwardedShortcut,
PreviewAutomationLoadError,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
Expand Down Expand Up @@ -584,6 +585,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
{ readonly semaphore: Semaphore.Semaphore; users: number }
>();
const tabLifecycleGenerations = new Map<string, number>();
// A guest can fail its first load before registerWebview installs its tab listeners.
const guestLoadFailures = new Map<number, PreviewAutomationLoadError>();

const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
Effect.try({
Expand Down Expand Up @@ -1378,16 +1381,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
validatedUrl: string,
isMainFrame: boolean,
): void => {
if (code === -3 || !isMainFrame) return;
if (code === -3 || !isMainFrame || wc.isDestroyed()) return;
const url = validatedUrl || wc.getURL();
runFork(
update(tabId, {
navStatus: {
kind: "LoadFailed",
url: validatedUrl || wc.getURL(),
title: wc.getTitle(),
code,
description,
},
Effect.gen(function* () {
const updatedAt = yield* currentIso;
const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
const current = tabs.get(tabId);
if (
!current ||
current.webContentsId !== wc.id ||
wc.isDestroyed() ||
webContents.fromId(wc.id) !== wc
) {
return [Option.none<PreviewTabState>(), tabs] as const;
}
const state: PreviewTabState = {
...current,
navStatus: { kind: "LoadFailed", url, title: wc.getTitle(), code, description },
updatedAt,
};
return [
Option.some(state),
replaceMap(tabs, (copy) => copy.set(tabId, state)),
] as const;
});
if (Option.isNone(next)) return;
const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
if (current?.webContentsId !== wc.id || current.navStatus !== next.value.navStatus)
return;
if (current.serverTab) {
browserHost.loadFailed(current.serverTab, { url, code, description });
}
yield* emitIfCurrent(tabId, next.value);
}),
);
};
Expand Down Expand Up @@ -1838,11 +1864,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
] as const;
}
const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
const failure = guestLoadFailures.get(webContentsId);
const { favicon: _favicon, ...currentWithoutFavicon } = current;
const next: PreviewTabState = {
...currentWithoutFavicon,
webContentsId,
navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
navStatus:
failure && (pendingUrl === null || pendingUrl === failure.url)
? { kind: "LoadFailed", title: wc.getTitle(), ...failure }
: pendingUrl === null
? computeNavStatus(wc)
: current.navStatus,
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
audible: attachedAudible,
Expand Down Expand Up @@ -1910,7 +1942,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
yield* rendererHistory.register(wc, { surface: "preview" });
const webContentsId = wc.id;
wc.on("did-start-navigation", (event) => {
if (event.isMainFrame && !event.isSameDocument) guestLoadFailures.delete(webContentsId);
});
wc.on("did-fail-load", (_event, code, description, validatedUrl, isMainFrame) => {
if (code === -3 || !isMainFrame) return;
guestLoadFailures.set(webContentsId, {
url: validatedUrl || wc.getURL(),
code,
description,
});
});
wc.once("destroyed", () => {
guestLoadFailures.delete(webContentsId);
runFork(hideDestroyedGuest(webContentsId));
runFork(detachControlSession(webContentsId));
});
Expand Down Expand Up @@ -2356,14 +2400,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
if (beforeAttach?.webContentsId !== wc.id) return;
const control = yield* ensureControlSession(wc);
if (beforeAttach.serverTab) yield* listenToBrowserHost;
const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
if (afterAttach?.webContentsId !== wc.id) {
yield* detachControlSession(wc.id);
return;
}
if (afterAttach.serverTab) {
yield* listenToBrowserHost;
browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
// CDP cannot recover a failure that happened before it attached.
const { navStatus } = afterAttach;
if (navStatus.kind === "LoadFailed") {
browserHost.loadFailed(afterAttach.serverTab, {
url: navStatus.url,
code: navStatus.code,
description: navStatus.description,
});
}
}
if (afterAttach.colorScheme !== "system") {
yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/preview/DesktopBrowserChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DesktopBrowserCommand,
DesktopBrowserEvent,
type DesktopBrowserCommand as DesktopBrowserCommandType,
type PreviewAutomationLoadError,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
Expand Down Expand Up @@ -46,6 +47,8 @@ export class DesktopBrowserChannel extends Context.Service<
readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
/** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */
readonly isShown: (key: DesktopTabKey) => boolean;
/** A load failure the desktop saw, possibly before the server connected to the tab. */
readonly loadFailure: (key: DesktopTabKey) => PreviewAutomationLoadError | undefined;
readonly endpoint: (key: DesktopTabKey) => Effect.Effect<string, never, Scope.Scope>;
readonly pointer: (
key: DesktopTabKey,
Expand All @@ -64,6 +67,7 @@ const make = Effect.gen(function* () {
const attachedTabs = new Set<string>();
// Missing reports protect desktop tabs until the renderer explicitly marks them hidden.
const shownTabs = new Map<string, boolean>();
const loadFailures = new Map<string, PreviewAutomationLoadError>();
const inbound = new Map<string, Queue.Queue<string>>();
const writeLock = yield* Semaphore.make(1);

Expand All @@ -88,6 +92,7 @@ const make = Effect.gen(function* () {
return Effect.void;
}),
isShown: () => false,
loadFailure: () => undefined,
});
}

Expand Down Expand Up @@ -126,9 +131,17 @@ const make = Effect.gen(function* () {
case "attached":
attachedTabs.add(id);
return PubSub.publish(changes, { key, attached: true });
case "loadFailed":
loadFailures.set(id, {
url: event.url.slice(0, 2048),
code: event.code,
description: event.description,
});
return Effect.void;
case "detached": {
attachedTabs.delete(id);
shownTabs.delete(id);
loadFailures.delete(id);
const queue = inbound.get(id);
return (queue ? Queue.shutdown(queue) : Effect.void).pipe(
Effect.andThen(PubSub.publish(changes, { key, attached: false })),
Expand All @@ -140,7 +153,12 @@ const make = Effect.gen(function* () {
}
}),
Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })),
Effect.ensuring(Effect.sync(() => shownTabs.clear())),
Effect.ensuring(
Effect.sync(() => {
shownTabs.clear();
loadFailures.clear();
}),
),
Effect.forkScoped,
);

Expand Down Expand Up @@ -232,6 +250,7 @@ const make = Effect.gen(function* () {
return Effect.sync(() => attachedTabs.has(keyOf(key)));
}),
isShown: (key) => shownTabs.get(keyOf(key)) ?? true,
loadFailure: (key) => loadFailures.get(keyOf(key)),
endpoint,
turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) {
return command({ type: "turn", threadId, active });
Expand Down
Loading
Loading