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
117 changes: 117 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,123 @@ describe("PreviewManager", () => {
),
);

const makeAttachingGuest = (id: number) => {
const listeners = new Map<string, () => void>();
const attach = vi.fn();
const detach = vi.fn();
const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>(
async () => undefined,
);
let destroyed = false;
const wc = {
id,
isDestroyed: () => destroyed,
isDevToolsOpened: () => {
// Electron throws from native methods once a WebContents is destroyed.
if (destroyed) throw new TypeError("Object has been destroyed");
return false;
},
getType: () => "webview",
getURL: () => "http://localhost:5173/README.md",
getTitle: () => "README.md",
isLoading: () => true,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
once: (event: string, listener: () => void) => {
listeners.set(event, listener);
},
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setIgnoreMenuShortcuts: vi.fn(),
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => attach.mock.calls.length > detach.mock.calls.length,
attach,
detach,
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
};
return {
wc: wc as unknown as Electron.WebContents,
attach,
detach,
sendCommand,
destroy: () => {
destroyed = true;
listeners.get("destroyed")?.();
},
};
};

effectIt.effect("sets the opaque base as soon as a guest attaches", () =>
withManager((manager) =>
Effect.gen(function* () {
// The tab's first document can paint before the renderer registers the
// guest, so the opaque base has to be the first command on attach.
const claimed = makeAttachingGuest(44);
fromId.mockReturnValue(claimed.wc);
yield* manager.prepareWebview(claimed.wc);
expect(claimed.attach).toHaveBeenCalledTimes(1);
expect(claimed.sendCommand.mock.calls[0]).toEqual([
"Emulation.setDefaultBackgroundColorOverride",
{ color: { r: 255, g: 255, b: 255, a: 1 } },
]);

yield* manager.createTab("tab_early");
yield* manager.registerWebview("tab_early", 44);
yield* manager.setColorScheme("tab_early", "dark");
expect(claimed.attach).toHaveBeenCalledTimes(1);
expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-color-scheme", value: "dark" }],
});

// A guest destroyed before any tab claims it releases its session.
const unclaimed = makeAttachingGuest(45);
yield* manager.prepareWebview(unclaimed.wc);
expect(unclaimed.attach).toHaveBeenCalledTimes(1);
unclaimed.destroy();
yield* Effect.yieldNow;
expect(unclaimed.detach).toHaveBeenCalledTimes(1);
expect(claimed.detach).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("skips a guest destroyed while another guest's session opens", () =>
withManager((manager) =>
Effect.gen(function* () {
const slow = makeAttachingGuest(46);
let releaseSlow = () => {};
const slowCommand = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
slow.sendCommand.mockImplementation(() => slowCommand);
const queued = makeAttachingGuest(47);

const slowFiber = yield* manager
.prepareWebview(slow.wc)
.pipe(Effect.forkChild({ startImmediately: true }));
const queuedFiber = yield* manager
.prepareWebview(queued.wc)
.pipe(Effect.forkChild({ startImmediately: true }));
queued.destroy();
releaseSlow();

expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true);
expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true);
expect(slow.attach).toHaveBeenCalledTimes(1);
expect(queued.attach).not.toHaveBeenCalled();
}),
),
);

const makeAudioWebContents = (id: number) => {
const listeners = new Map<string, (...args: never[]) => void>();
const setAudioMuted = vi.fn();
Expand Down
58 changes: 49 additions & 9 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
> => {
const existing = sessions.get(wc.id);
if (existing) return Effect.succeed([existing, sessions] as const);
// A guest can be destroyed while it waits for this lock, and its native
// methods throw once it is.
if (wc.isDestroyed()) {
return Effect.fail(
new PreviewOperationError({
operation: "ensureControlSession",
webContentsId: wc.id,
cause: new Error("WebContents was destroyed"),
}),
);
}
if (wc.isDevToolsOpened()) {
return Effect.fail(
new PreviewAutomationDevToolsOpenError({
Expand Down Expand Up @@ -1409,28 +1420,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wcDebugger.on("message", onMessage);
wcDebugger.attach("1.3");
});
yield* Effect.forEach(
["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
(method) =>
attemptPromise(
{ operation: `initializeDebugger.${method}`, webContentsId: wc.id },
() => wcDebugger.sendCommand(method),
),
{ concurrency: "unbounded", discard: true },
);
// Electron gives `<webview>` guests a transparent base background, and
// Chromium only paints a dark canvas for dark color-scheme pages over an
// opaque base. Without this, dark-scheme pages with no background of
// their own (text/plain, e.g. .md files) render white text on white.
// White matches the webview's existing white backing, so light pages look
// the same; Chromium still swaps in its dark canvas for dark-scheme pages.
// Sent first because a document that paints before it arrives keeps the
// transparent base until its next load.
yield* attemptPromise(
{ operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
() =>
wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
color: { r: 255, g: 255, b: 255, a: 1 },
}),
);
yield* Effect.forEach(
["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
(method) =>
attemptPromise(
{ operation: `initializeDebugger.${method}`, webContentsId: wc.id },
() => wcDebugger.sendCommand(method),
),
{ concurrency: "unbounded", discard: true },
);
return [
control,
replaceMap(sessions, (copy) => {
Expand Down Expand Up @@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

// Called when a guest attaches to the window, before its first document
// paints. A tab opened straight to a URL often paints before the renderer
// gets to registerWebview, which would leave that page on the transparent
// base. registerWebview reuses the session opened here.
const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (
wc: Electron.WebContents,
) {
const webContentsId = wc.id;
// A guest destroyed before any tab claims it has no other cleanup path.
wc.once("destroyed", () => {
runFork(detachControlSession(webContentsId));
});
// Runs detached from the attach event, so nothing may escape. registerWebview
// opens the session again if this one did not.
yield* ensureControlSession(wc).pipe(
Effect.catchCause((cause) =>
Effect.logDebug("Preview webview control session was not opened on attach.", {
webContentsId,
cause,
}),
),
);
});

const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>
normalizePreviewUrl(rawUrl),
Expand Down Expand Up @@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
openPictureInPicture,
openDevTools,
pickElement,
prepareWebview,
reapplyZoom,
refresh,
registerWebview,
Expand Down Expand Up @@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service<
tabId: string,
webContentsId: number,
) => Effect.Effect<void, PreviewManagerError>;
readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect<void>;
readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>;
readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
Expand Down Expand Up @@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
createTab: operations.createTab,
closeTab: operations.closeTab,
registerWebview: operations.registerWebview,
prepareWebview: operations.prepareWebview,
navigate: operations.navigate,
goBack: operations.goBack,
goForward: operations.goForward,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ function makeTestLayer(input: {
Layer.mock(PreviewManager.PreviewManager)({
getBrowserSession: () => Effect.succeed({} as Electron.Session),
setMainWindow: () => Effect.void,
prepareWebview: () => Effect.void,
isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
reapplyZoom: () =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ export const make = Effect.gen(function* () {
installContextMenu(window, window.webContents);
window.webContents.on("did-attach-webview", (_event, contents) => {
installContextMenu(window, contents);
void runPromise(previewManager.prepareWebview(contents));
});

window.webContents.setWindowOpenHandler(({ url }) => {
Expand Down
Loading