diff --git a/electron/countdownOverlay.test.ts b/electron/countdownOverlay.test.ts new file mode 100644 index 0000000000..940c0e9748 --- /dev/null +++ b/electron/countdownOverlay.test.ts @@ -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); + }); +}); diff --git a/electron/countdownOverlay.ts b/electron/countdownOverlay.ts new file mode 100644 index 0000000000..6ddf160645 --- /dev/null +++ b/electron/countdownOverlay.ts @@ -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 { + // 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 { + return new Promise((resolve) => { + let timer: ReturnType | 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); + }); +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 7a524d219b..fc6b5f9118 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -520,7 +520,11 @@ interface Window { showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; - 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) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ebee7416f0..3a179dbcdb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"; @@ -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; @@ -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 () => { diff --git a/electron/preload.ts b/electron/preload.ts index d704fd75f3..23160bcaa8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) => { const listener = async () => { try { diff --git a/src/components/launch/CountdownOverlay.test.tsx b/src/components/launch/CountdownOverlay.test.tsx new file mode 100644 index 0000000000..621bab7bb9 --- /dev/null +++ b/src/components/launch/CountdownOverlay.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CLEARED_AFTER_FRAMES, CountdownOverlay } from "./CountdownOverlay"; + +type ValueListener = (value: number | null, runId: number) => void; + +describe("CountdownOverlay", () => { + let pushValue: ValueListener = () => undefined; + let frames: FrameRequestCallback[] = []; + let textAtFrameRequest: string[] = []; + let notifyCleared: ReturnType; + let container: HTMLElement; + + beforeEach(() => { + frames = []; + textAtFrameRequest = []; + notifyCleared = vi.fn(); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + textAtFrameRequest.push(container?.textContent ?? ""); + frames.push(callback); + return frames.length; + }); + window.electronAPI = { + ...window.electronAPI, + onCountdownOverlayValue: (listener: ValueListener) => { + pushValue = listener; + return () => undefined; + }, + notifyCountdownOverlayCleared: notifyCleared, + } as typeof window.electronAPI; + ({ container } = render()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** Runs the display frame the renderer is waiting on. */ + const nextFrame = () => { + const callback = frames.shift(); + if (!callback) { + throw new Error("no animation frame was requested"); + } + act(() => callback(performance.now())); + }; + + it("reports the clear only after the empty state has been painted for a few frames", () => { + act(() => pushValue(1, 9)); + expect(container.textContent).toBe("1"); + + act(() => pushValue(null, 9)); + + // The digit is gone before the first frame is even requested, so every frame counted + // is a frame without it — a batched update would have counted one that still had it. + expect(textAtFrameRequest[0]).toBe(""); + for (let frame = 1; frame < CLEARED_AFTER_FRAMES; frame += 1) { + nextFrame(); + expect(notifyCleared).not.toHaveBeenCalled(); + } + nextFrame(); + expect(notifyCleared).toHaveBeenCalledExactlyOnceWith(9); + expect(frames).toHaveLength(0); + }); + + it("reports nothing while it is showing a digit", () => { + act(() => pushValue(3, 2)); + act(() => pushValue(2, 2)); + + expect(frames).toHaveLength(0); + expect(notifyCleared).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/launch/CountdownOverlay.tsx b/src/components/launch/CountdownOverlay.tsx index 71d12c50a6..6b5071b722 100644 --- a/src/components/launch/CountdownOverlay.tsx +++ b/src/components/launch/CountdownOverlay.tsx @@ -1,11 +1,34 @@ import { useEffect, useState } from "react"; +import { flushSync } from "react-dom"; + +/** + * Display frames to let pass after the digit is cleared before saying so: one for Chromium to + * produce the empty frame, one for it to reach the compositor, and one of margin. The hide + * waits on this on Linux, where the capture would otherwise record the last painted digit + * (`electron/countdownOverlay.ts`). + */ +export const CLEARED_AFTER_FRAMES = 3; + +function afterAnimationFrames(count: number, callback: () => void) { + if (count <= 0) { + callback(); + return; + } + requestAnimationFrame(() => afterAnimationFrames(count - 1, callback)); +} export function CountdownOverlay() { const [value, setValue] = useState(null); useEffect(() => { - const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => { - setValue(nextValue); + const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue, runId) => { + // Committed now rather than batched, so every frame counted below is already empty. + flushSync(() => setValue(nextValue)); + if (nextValue === null) { + afterAnimationFrames(CLEARED_AFTER_FRAMES, () => + window.electronAPI.notifyCountdownOverlayCleared(runId), + ); + } }); return () => unsubscribe();