From 4a46206ea1db43294f850be3e2273294d82d20f4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 15 Oct 2026 17:50:31 +0200 Subject: [PATCH] fix(recording): keep the countdown's last digit out of frame 0 on Linux On Linux nothing excludes the countdown overlay from a capture, so the only protection is ordering. The hide sent the overlay its cleared value and hid the window in the same tick, so the renderer never painted the empty state: the compositor's last buffer still showed the "1", GNOME Shell faded that buffer out over ~150 ms, and the capture armed right after the hide recorded the fade in its first frames. On Linux the hide now clears the digit, waits for the overlay renderer to report that empty frames have been painted (bounded by a 500 ms fallback), and only then hides the window, so whatever the compositor animates afterwards is transparent. A countdown that re-shows the overlay during that wait keeps it up. Windows and macOS keep the immediate hide. --- electron/countdownOverlay.test.ts | 157 ++++++++++++++++++ electron/countdownOverlay.ts | 108 ++++++++++++ electron/electron-env.d.ts | 6 +- electron/ipc/handlers.ts | 16 +- electron/preload.ts | 8 +- .../launch/CountdownOverlay.test.tsx | 73 ++++++++ src/components/launch/CountdownOverlay.tsx | 27 ++- 7 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 electron/countdownOverlay.test.ts create mode 100644 electron/countdownOverlay.ts create mode 100644 src/components/launch/CountdownOverlay.test.tsx diff --git a/electron/countdownOverlay.test.ts b/electron/countdownOverlay.test.ts new file mode 100644 index 000000000..940c0e974 --- /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 000000000..6ddf16064 --- /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 7a524d219..fc6b5f911 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 ebee7416f..3a179dbcd 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 d704fd75f..23160bcaa 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 000000000..621bab7bb --- /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 71d12c50a..6b5071b72 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();