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
157 changes: 157 additions & 0 deletions electron/countdownOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
COUNTDOWN_OVERLAY_CLEAR_TIMEOUT_MS,
COUNTDOWN_OVERLAY_CLEARED_CHANNEL,
hideCountdownOverlay,
} from "./countdownOverlay";

/** Records every call on the overlay in order, which is what this suite is about. */
function fakeOverlay({ visible = true } = {}) {
const calls: string[] = [];
const ipc = new EventEmitter();
let destroyed = false;
const overlay = {
isDestroyed: () => destroyed,
isVisible: () => visible,
hide: vi.fn(() => {
calls.push("hide");
}),
webContents: {
send: vi.fn((channel: string, value: unknown, runId: unknown) => {
calls.push(`send ${channel} ${String(value)} ${String(runId)}`);
}),
ipc,
},
};
return {
overlay,
calls,
cleared: (runId: number) => ipc.emit(COUNTDOWN_OVERLAY_CLEARED_CHANNEL, {}, runId),
destroy: () => {
destroyed = true;
},
};
}

/** Lets the hide's pending awaits run without advancing any timer. */
const settle = () => vi.advanceTimersByTimeAsync(0);

describe("hideCountdownOverlay", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => undefined);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("on Linux, where only ordering keeps the overlay out of the capture", () => {
it("clears the digit and hides only once the renderer has painted the cleared frame", async () => {
const { overlay, calls, cleared } = fakeOverlay();
let done = false;
const hiding = hideCountdownOverlay(overlay, 7, { platform: "linux" }).then(() => {
done = true;
});

await settle();
expect(calls).toEqual(["send countdown-overlay-value null 7"]);
expect(overlay.hide).not.toHaveBeenCalled();
// The caller starts the capture when this resolves, so it must still be pending.
expect(done).toBe(false);

cleared(7);
await hiding;
expect(calls).toEqual(["send countdown-overlay-value null 7", "hide"]);
expect(overlay.webContents.ipc.listenerCount(COUNTDOWN_OVERLAY_CLEARED_CHANNEL)).toBe(0);
});

it("listens for the report before asking for the clear, so a fast renderer is not missed", async () => {
const { overlay, calls } = fakeOverlay();
overlay.webContents.send.mockImplementation((channel, value, runId) => {
calls.push(`send ${channel} ${String(value)} ${String(runId)}`);
overlay.webContents.ipc.emit(COUNTDOWN_OVERLAY_CLEARED_CHANNEL, {}, runId);
});

await hideCountdownOverlay(overlay, 3, { platform: "linux" });

expect(calls).toEqual(["send countdown-overlay-value null 3", "hide"]);
});

it("ignores a cleared report from another countdown run", async () => {
const { overlay, cleared } = fakeOverlay();
const hiding = hideCountdownOverlay(overlay, 8, { platform: "linux" });

cleared(7);
await settle();
expect(overlay.hide).not.toHaveBeenCalled();

cleared(8);
await hiding;
expect(overlay.hide).toHaveBeenCalledTimes(1);
});

it("hides anyway when the renderer never reports, so the take still starts", async () => {
const { overlay } = fakeOverlay();
const hiding = hideCountdownOverlay(overlay, 1, { platform: "linux" });

await vi.advanceTimersByTimeAsync(COUNTDOWN_OVERLAY_CLEAR_TIMEOUT_MS - 1);
expect(overlay.hide).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(1);
await hiding;
expect(overlay.hide).toHaveBeenCalledTimes(1);
expect(overlay.webContents.ipc.listenerCount(COUNTDOWN_OVERLAY_CLEARED_CHANNEL)).toBe(0);
});

it("leaves the overlay up when a newer countdown showed it during the wait", async () => {
const { overlay, cleared } = fakeOverlay();
let superseded = false;
const hiding = hideCountdownOverlay(overlay, 4, {
platform: "linux",
isSuperseded: () => superseded,
});

superseded = true;
cleared(4);
await hiding;

expect(overlay.hide).not.toHaveBeenCalled();
});

it("does not touch a window destroyed during the wait", async () => {
const { overlay, cleared, destroy } = fakeOverlay();
const hiding = hideCountdownOverlay(overlay, 5, { platform: "linux" });

destroy();
cleared(5);
await hiding;

expect(overlay.hide).not.toHaveBeenCalled();
});

it("hides at once when the overlay is not on screen, since nothing is left to paint", async () => {
const { overlay, calls } = fakeOverlay({ visible: false });

await hideCountdownOverlay(overlay, 2, { platform: "linux" });

expect(calls).toEqual(["send countdown-overlay-value null 2", "hide"]);
expect(overlay.webContents.ipc.listenerCount(COUNTDOWN_OVERLAY_CLEARED_CHANNEL)).toBe(0);
});
});

it.each([
"win32",
"darwin",
] as const)("keeps the immediate, synchronous hide on %s", (platform) => {
const { overlay, calls } = fakeOverlay();

// Not awaited: the hide must already have happened synchronously.
void hideCountdownOverlay(overlay, 6, { platform });

expect(calls).toEqual(["send countdown-overlay-value null 6", "hide"]);
expect(overlay.webContents.ipc.listenerCount(COUNTDOWN_OVERLAY_CLEARED_CHANNEL)).toBe(0);
});
});
108 changes: 108 additions & 0 deletions electron/countdownOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Hiding the countdown overlay before a take starts.
//
// On Linux nothing keeps the overlay out of a capture: `setContentProtection` does nothing
// there, so the one protection is ordering — the overlay has to be gone from the compositor's
// output before the first frame is taken. `hide()` alone does not get there. The renderer was
// told to clear the digit in the same tick the window was hidden, so it never painted the empty
// state, and the last buffer the compositor held still showed the "1". GNOME Shell then animates
// the closing window out from that buffer — a fade of ~150 ms — and the capture armed right
// after `hide()` returned recorded it: the first frames of a full-screen take carried a "1"
// fading out (GNOME 46, Wayland).
//
// So on Linux the overlay is cleared first, and hidden only once its renderer reports that a
// frame without the digit has been painted. Whatever the compositor animates after that is
// transparent, for however long its animation runs. Windows and macOS keep the immediate hide.

/** Sent by the overlay's renderer once a frame without the digit has been painted. */
export const COUNTDOWN_OVERLAY_CLEARED_CHANNEL = "countdown-overlay-cleared";

/**
* How long to wait for that report before hiding anyway. A cleared frame normally takes a few
* display frames; this only bounds a renderer that never answers, which would otherwise keep
* the take from starting at all.
*/
export const COUNTDOWN_OVERLAY_CLEAR_TIMEOUT_MS = 500;

type ClearedListener = (event: unknown, runId: unknown) => void;

/** The slice of `BrowserWindow` this needs, so the ordering can be tested without Electron. */
export type CountdownOverlayWindow = {
isDestroyed(): boolean;
isVisible(): boolean;
hide(): void;
webContents: {
send(channel: string, ...args: unknown[]): void;
/** `webContents.ipc`: only this window's messages, so no sender check is needed. */
ipc: {
on(channel: string, listener: ClearedListener): unknown;
removeListener(channel: string, listener: ClearedListener): unknown;
};
};
};

export type HideCountdownOverlayOptions = {
platform: NodeJS.Platform;
/**
* True once a newer countdown has shown the overlay while this hide waited for the clear.
* Hiding then would take the new countdown off the screen.
*/
isSuperseded?: () => boolean;
timeoutMs?: number;
};

export async function hideCountdownOverlay(
overlay: CountdownOverlayWindow,
runId: number,
{
platform,
isSuperseded = () => false,
timeoutMs = COUNTDOWN_OVERLAY_CLEAR_TIMEOUT_MS,
}: HideCountdownOverlayOptions,
): Promise<void> {
// Listening before the clear is sent, so a fast renderer cannot answer into nothing.
const cleared =
platform === "linux" && overlay.isVisible()
? waitForCleared(overlay.webContents.ipc, runId, timeoutMs)
: null;

overlay.webContents.send("countdown-overlay-value", null, runId);

if (cleared) {
await cleared;
if (overlay.isDestroyed() || isSuperseded()) {
return;
}
}

overlay.hide();
}

function waitForCleared(
ipc: CountdownOverlayWindow["webContents"]["ipc"],
runId: number,
timeoutMs: number,
): Promise<void> {
return new Promise<void>((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (timer) {
clearTimeout(timer);
}
ipc.removeListener(COUNTDOWN_OVERLAY_CLEARED_CHANNEL, onCleared);
resolve();
};
// Another run's report says nothing about the frame this hide is waiting for.
const onCleared: ClearedListener = (_event, clearedRunId) => {
if (clearedRunId === runId) {
finish();
}
};
ipc.on(COUNTDOWN_OVERLAY_CLEARED_CHANNEL, onCleared);
timer = setTimeout(() => {
console.warn(
`[countdown-overlay] no cleared frame within ${timeoutMs} ms; hiding the overlay anyway`,
);
finish();
}, timeoutMs);
});
}
6 changes: 5 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,11 @@ interface Window {
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
onCountdownOverlayValue: (
callback: (value: number | null, runId: number) => void,
) => () => void;
/** The overlay painted a frame without the digit; a Linux hide waits for this. */
notifyCountdownOverlayCleared: (runId: number) => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
Expand Down
16 changes: 13 additions & 3 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { LlmConfigStore } from "../ai-edition/llm-config-store";
import { StylePresetService } from "../ai-edition/style-preset-service";
import { AppSettingsStore } from "../app-settings";
import { createRunnerHost } from "../cli/runnerHost";
import { hideCountdownOverlay } from "../countdownOverlay";
import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer";
import { mainT } from "../i18n";
import { getInstallChannel } from "../install-channel";
Expand Down Expand Up @@ -2536,7 +2537,11 @@ export function registerIpcHandlers(
return { success: true };
});

// Counts the countdowns that showed the overlay, so a Linux hide still waiting on its cleared
// frame can tell that a newer countdown has taken the overlay over (`countdownOverlay.ts`).
let countdownOverlayShows = 0;
ipcMain.handle("countdown-overlay-show", async (_, value: number, runId: number) => {
countdownOverlayShows += 1;
const overlayWindow = getCountdownOverlayWindow?.() ?? createCountdownOverlayWindow();
if (overlayWindow.isDestroyed()) {
return;
Expand Down Expand Up @@ -2566,14 +2571,19 @@ export function registerIpcHandlers(
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
});

ipcMain.handle("countdown-overlay-hide", (_, runId: number) => {
// Resolves once the overlay is hidden, and the renderer starts the take only then: on Linux
// that is after a frame without the digit has been painted, so the capture cannot record it.
ipcMain.handle("countdown-overlay-hide", async (_, runId: number) => {
const overlayWindow = getCountdownOverlayWindow?.();
if (!overlayWindow || overlayWindow.isDestroyed()) {
return;
}

overlayWindow.webContents.send("countdown-overlay-value", null, runId);
overlayWindow.hide();
const showsAtHide = countdownOverlayShows;
await hideCountdownOverlay(overlayWindow, runId, {
platform: process.platform,
isSuperseded: () => countdownOverlayShows !== showsAtHide,
});
});

ipcMain.handle("is-native-windows-capture-available", async () => {
Expand Down
8 changes: 6 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,11 +550,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
hideCountdownOverlay: (runId: number) => {
return ipcRenderer.invoke("countdown-overlay-hide", runId);
},
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
const listener = (_event: unknown, value: number | null) => callback(value);
onCountdownOverlayValue: (callback: (value: number | null, runId: number) => void) => {
const listener = (_event: unknown, value: number | null, runId: number) =>
callback(value, runId);
ipcRenderer.on("countdown-overlay-value", listener);
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
},
notifyCountdownOverlayCleared: (runId: number) => {
ipcRenderer.send("countdown-overlay-cleared", runId);
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
try {
Expand Down
Loading
Loading