From 9c7e4b3a6aa4fafd0a2e84afaa593b415583e466 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:14:19 +1000 Subject: [PATCH] Resolve countdown cancellation and enable webcam preview from New --- electron/countdownController.test.ts | 86 +++++++++++++++++++++++++++ electron/countdownController.ts | 83 ++++++++++++++++++++++++++ electron/hudEditorMode.test.ts | 13 ++++ electron/hudEditorMode.ts | 8 +++ electron/ipc/register/settings.ts | 89 ++++------------------------ electron/ipc/register/sources.ts | 4 ++ electron/windows.ts | 19 +++++- tests/ui/hud-layout.spec.ts | 65 ++++++++++++++++++-- 8 files changed, 281 insertions(+), 86 deletions(-) create mode 100644 electron/countdownController.test.ts create mode 100644 electron/countdownController.ts create mode 100644 electron/hudEditorMode.test.ts create mode 100644 electron/hudEditorMode.ts diff --git a/electron/countdownController.test.ts b/electron/countdownController.test.ts new file mode 100644 index 00000000..eca328a5 --- /dev/null +++ b/electron/countdownController.test.ts @@ -0,0 +1,86 @@ +import { EventEmitter } from "node:events"; +import type { BrowserWindow } from "electron"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createCountdownController } from "./countdownController"; +function fixture(loading = false) { + const contents = Object.assign(new EventEmitter(), { + isLoadingMainFrame: () => loading, + isDestroyed: () => false, + send: vi.fn(), + }); + const win = Object.assign(new EventEmitter(), { + webContents: contents, + isDestroyed: () => false, + close: vi.fn(), + }); + const state = vi.fn(); + const controller = createCountdownController(() => win as unknown as BrowserWindow, state); + return { controller, win, contents, state }; +} +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); +it("cancels an active countdown immediately and allows another start", async () => { + const { controller, state } = fixture(); + const pending = controller.start(3); + controller.cancel(); + await expect(pending).resolves.toEqual({ success: false, cancelled: true }); + expect(state).toHaveBeenLastCalledWith(null); + expect(vi.getTimerCount()).toBe(0); + const next = controller.start(1); + await vi.advanceTimersByTimeAsync(1000); + await expect(next).resolves.toEqual({ success: true }); +}); +it("settles cancellation before the countdown renderer finishes loading", async () => { + const { controller, contents } = fixture(true); + const pending = controller.start(3); + controller.cancel(); + await expect(pending).resolves.toEqual({ success: false, cancelled: true }); + contents.emit("did-finish-load"); + expect(contents.send).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); +it.each([ + "closed", + "render-process-gone", +])("settles when the window exits through %s", async (event) => { + const { controller, win, contents } = fixture(true); + const pending = controller.start(3); + (event === "closed" ? win : contents).emit(event); + await expect(pending).resolves.toEqual({ success: false, cancelled: true }); +}); +it("settles loading failures and releases the countdown lock", async () => { + const { controller, contents } = fixture(true); + const pending = controller.start(3); + contents.emit("did-fail-load"); + await expect(pending).resolves.toMatchObject({ success: false, error: expect.any(String) }); + const next = controller.start(1); + contents.emit("did-finish-load"); + await vi.advanceTimersByTimeAsync(1000); + await expect(next).resolves.toEqual({ success: true }); +}); +it("rejects concurrent starts and emits ticks until normal completion", async () => { + const { controller, contents } = fixture(); + const pending = controller.start(2); + await expect(controller.start(3)).resolves.toMatchObject({ + success: false, + error: expect.any(String), + }); + await vi.advanceTimersByTimeAsync(2000); + await expect(pending).resolves.toEqual({ success: true }); + expect(contents.send.mock.calls).toEqual([ + ["countdown-tick", 2], + ["countdown-tick", 1], + ]); + expect(vi.getTimerCount()).toBe(0); +}); +it("resets state if creating the countdown window fails", async () => { + const state = vi.fn(); + const controller = createCountdownController(() => { + throw Error("window unavailable"); + }, state); + await expect(controller.start(3)).resolves.toMatchObject({ + success: false, + error: expect.any(String), + }); + expect(state).toHaveBeenLastCalledWith(null); +}); diff --git a/electron/countdownController.ts b/electron/countdownController.ts new file mode 100644 index 00000000..3a3ae309 --- /dev/null +++ b/electron/countdownController.ts @@ -0,0 +1,83 @@ +import type { BrowserWindow } from "electron"; + +type Result = { success: boolean; cancelled?: boolean; error?: string }; +/** Every countdown exit settles the IPC request, including cancellation during window load. */ +export function createCountdownController( + createWindow: () => BrowserWindow, + onRemaining: (seconds: number | null) => void, +) { + let finishActive: ((result: Result) => void) | null = null; + return { + start(seconds: number): Promise { + if (finishActive) + return Promise.resolve({ success: false, error: "Countdown already in progress" }); + if (!Number.isFinite(seconds) || seconds < 0) + return Promise.resolve({ success: false, error: "Invalid countdown delay" }); + return new Promise((resolve) => { + let win: BrowserWindow | undefined; + let timer: ReturnType | undefined; + let settled = false; + let started = false; + let remaining = Math.ceil(seconds); + const finish = (result: Result) => { + if (settled) return; + settled = true; + if (timer) clearInterval(timer); + finishActive = null; + onRemaining(null); + if (win) { + win.removeListener("closed", cancel); + win.webContents.removeListener("did-finish-load", begin); + win.webContents.removeListener("did-fail-load", fail); + win.webContents.removeListener("render-process-gone", cancel); + } + resolve(result); + if (win && !win.isDestroyed()) win.close(); + }; + const cancel = () => finish({ success: false, cancelled: true }); + const fail = () => + finish({ success: false, error: "Countdown window failed to load" }); + const tick = () => { + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) { + cancel(); + return; + } + onRemaining(remaining); + win.webContents.send("countdown-tick", remaining); + }; + const begin = () => { + if (settled || started) return; + started = true; + if (remaining === 0) { + finish({ success: true }); + return; + } + tick(); + if (settled) return; + timer = setInterval(() => { + remaining--; + if (remaining <= 0) finish({ success: true }); + else tick(); + }, 1000); + }; + finishActive = finish; + onRemaining(remaining); + try { + win = createWindow(); + win.once("closed", cancel); + win.webContents.once("did-fail-load", fail); + win.webContents.once("render-process-gone", cancel); + if (win.webContents.isLoadingMainFrame()) + win.webContents.once("did-finish-load", begin); + else begin(); + } catch (error) { + finish({ success: false, error: String(error) }); + } + }); + }, + cancel() { + finishActive?.({ success: false, cancelled: true }); + return { success: true }; + }, + }; +} diff --git a/electron/hudEditorMode.test.ts b/electron/hudEditorMode.test.ts new file mode 100644 index 00000000..b8d88684 --- /dev/null +++ b/electron/hudEditorMode.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { isHudInEditorMode } from "./hudEditorMode"; +it("allows the webcam when New prepares a recording despite an existing editor", () => { + expect(isHudInEditorMode(1, false, false)).toBe(true); + expect(isHudInEditorMode(1, true, false)).toBe(false); + expect(isHudInEditorMode(1, true, true)).toBe(false); + // Cancelling leaves the HUD ready to retry, including its webcam preview. + expect(isHudInEditorMode(1, true, false)).toBe(false); + // Returning Home stops an idle camera, but does not stop an active capture preview. + expect(isHudInEditorMode(1, false, false)).toBe(true); + expect(isHudInEditorMode(1, false, true)).toBe(false); + expect(isHudInEditorMode(0, false, false)).toBe(false); +}); diff --git a/electron/hudEditorMode.ts b/electron/hudEditorMode.ts new file mode 100644 index 00000000..f87ddcd6 --- /dev/null +++ b/electron/hudEditorMode.ts @@ -0,0 +1,8 @@ +/** An editor may stay open behind the HUD while a new capture is prepared. */ +export function isHudInEditorMode( + editorCount: number, + preparingRecording: boolean, + recording: boolean, +) { + return editorCount > 0 && !preparingRecording && !recording; +} diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index 91de5ea3..065ed707 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,22 +1,19 @@ +import { createCountdownController } from "../../countdownController"; import fs from "node:fs/promises"; import { app, BrowserWindow, ipcMain } from "electron"; import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore"; import { hideCursor } from "../../cursorHider"; -import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; +import { createCountdownWindow } from "../../windows"; import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; import { createRecordingPreferencesStore, type RecordingPreferencesPatch, } from "../settings/recordingPreferencesStore"; import { - countdownCancelled, countdownInProgress, countdownRemaining, - countdownTimer, - setCountdownCancelled, setCountdownInProgress, setCountdownRemaining, - setCountdownTimer, } from "../state"; import { parseJsonWithByteOrderMark } from "../utils"; @@ -43,7 +40,10 @@ function getBrowserMicrophoneProfileFromEnv() { } export function registerSettingsHandlers() { - ipcMain.handle("get-window-fullscreen", (event) => BrowserWindow.fromWebContents(event.sender)?.isFullScreen() ?? false); + ipcMain.handle( + "get-window-fullscreen", + (event) => BrowserWindow.fromWebContents(event.sender)?.isFullScreen() ?? false, + ); ipcMain.handle("app:getVersion", () => { return app.getVersion(); }); @@ -197,79 +197,12 @@ export function registerSettingsHandlers() { } }); - ipcMain.handle("start-countdown", async (_, seconds: number) => { - if (countdownInProgress) { - return { success: false, error: "Countdown already in progress" }; - } - - setCountdownInProgress(true); - setCountdownCancelled(false); - setCountdownRemaining(seconds); - - const countdownWin = createCountdownWindow(); - - if (countdownWin.webContents.isLoadingMainFrame()) { - await new Promise((resolve) => { - countdownWin.webContents.once("did-finish-load", () => { - resolve(); - }); - }); - } - - return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { - let remaining = seconds; - setCountdownRemaining(remaining); - - countdownWin.webContents.send("countdown-tick", remaining); - - setCountdownTimer( - setInterval(() => { - if (countdownCancelled) { - if (countdownTimer) { - clearInterval(countdownTimer); - setCountdownTimer(null); - } - closeCountdownWindow(); - setCountdownInProgress(false); - setCountdownRemaining(null); - resolve({ success: false, cancelled: true }); - return; - } - - remaining--; - setCountdownRemaining(remaining); - - if (remaining <= 0) { - if (countdownTimer) { - clearInterval(countdownTimer); - setCountdownTimer(null); - } - closeCountdownWindow(); - setCountdownInProgress(false); - setCountdownRemaining(null); - resolve({ success: true }); - } else { - const win = getCountdownWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send("countdown-tick", remaining); - } - } - }, 1000), - ); - }); - }); - - ipcMain.handle("cancel-countdown", () => { - setCountdownCancelled(true); - setCountdownInProgress(false); - setCountdownRemaining(null); - if (countdownTimer) { - clearInterval(countdownTimer); - setCountdownTimer(null); - } - closeCountdownWindow(); - return { success: true }; + const countdown = createCountdownController(createCountdownWindow, (remaining) => { + setCountdownRemaining(remaining); + setCountdownInProgress(remaining !== null); }); + ipcMain.handle("start-countdown", (_, seconds: number) => countdown.start(seconds)); + ipcMain.handle("cancel-countdown", () => countdown.cancel()); ipcMain.handle("get-active-countdown", () => { return { diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index f470b865..e5b7cd0b 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -3,6 +3,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { app, BrowserWindow, desktopCapturer, ipcMain, systemPreferences } from "electron"; import { + setHudRecordingPreparationActive, createHudOverlayWindow, getHudOverlayWindow, reassertHudOverlayMousePassthrough, @@ -596,6 +597,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} createSourceSelectorWindow(); }); ipcMain.handle("show-recording-hud", (event) => { + setHudRecordingPreparationActive(true); recordingNavigation.setReturnWindow(BrowserWindow.fromWebContents(event.sender)); const hud = getHudOverlayWindow(); if (hud && !hud.isDestroyed()) { @@ -606,9 +608,11 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } }); ipcMain.handle("show-project-dashboard", () => { + setHudRecordingPreparationActive(false); recordingNavigation.open(false); }); ipcMain.handle("switch-to-editor", () => { + setHudRecordingPreparationActive(false); console.log("[switch-to-editor] Opening editor window"); const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { diff --git a/electron/windows.ts b/electron/windows.ts index fd8eb3f2..719e39aa 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,3 +1,4 @@ +import { isHudInEditorMode } from "./hudEditorMode"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -448,12 +449,24 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) }); const editorWindows = new Set(); +let recordingPreparationActive = false; +function getHudEditorMode() { + return isHudInEditorMode( + editorWindows.size, + recordingPreparationActive, + hudOverlayRecordingActive, + ); +} +export function setHudRecordingPreparationActive(active: boolean) { + recordingPreparationActive = active; + notifyEditorMode(); +} function notifyEditorMode() { if (hudOverlayWindow && !hudOverlayWindow.webContents.isDestroyed()) { - hudOverlayWindow.webContents.send("editor-mode-changed", editorWindows.size > 0); + hudOverlayWindow.webContents.send("editor-mode-changed", getHudEditorMode()); } } -ipcMain.handle("get-editor-mode", () => editorWindows.size > 0); +ipcMain.handle("get-editor-mode", getHudEditorMode); export function createHudOverlayWindow(): BrowserWindow { const perfStart = Date.now(); @@ -691,6 +704,7 @@ export function reassertHudOverlayMousePassthrough(): void { export function setHudOverlayRecordingActive(recording: boolean): void { hudOverlayRecordingActive = Boolean(recording); + notifyEditorMode(); hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); reassertHudOverlayCaptureProtection(); @@ -947,6 +961,7 @@ export function createEditorWindow(): BrowserWindow { }, }); + recordingPreparationActive = false; editorWindows.add(win); notifyEditorMode(); win.once("closed", () => { diff --git a/tests/ui/hud-layout.spec.ts b/tests/ui/hud-layout.spec.ts index 8cf8b07d..11dfd9cc 100644 --- a/tests/ui/hud-layout.spec.ts +++ b/tests/ui/hud-layout.spec.ts @@ -17,12 +17,10 @@ test("HUD dividers are vertically centered", async ({ page }) => { const home = page.getByRole("button", { name: "Home", exact: true }); await expect(home.locator("svg")).toHaveAttribute("data-icon-style", "bold"); await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0); - const icon = await home - .locator("svg") - .evaluate((element) => ({ - width: element.getBoundingClientRect().width, - height: element.getBoundingClientRect().height, - })); + const icon = await home.locator("svg").evaluate((element) => ({ + width: element.getBoundingClientRect().width, + height: element.getBoundingClientRect().height, + })); expect(icon).toEqual({ width: 20, height: 20 }); await page.screenshot({ path: "test-results/hud-idle.png", animations: "disabled" }); await home.click(); @@ -55,3 +53,58 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page } for (const size of sizes) expect(size).toEqual([36, 36]); await page.screenshot({ path: "test-results/hud-recording.png" }); }); + +test("New recording mode starts the webcam with an editor still open and releases it on Home", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.getEditorMode = async () => true; + window.electronAPI.onEditorModeChanged = (callback) => { + const listener = (event: Event) => callback((event as CustomEvent).detail); + window.addEventListener("test-editor-mode", listener); + return () => window.removeEventListener("test-editor-mode", listener); + }; + window.electronAPI.getRecordingPreferences = async () => ({ + success: true, + microphoneEnabled: false, + webcamEnabled: true, + systemAudioEnabled: false, + }); + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 320; + const context = canvas.getContext("2d")!; + context.fillStyle = "#2874ff"; + context.fillRect(0, 0, 320, 320); + const stream = canvas.captureStream(24); + setInterval(() => context.fillRect(0, 0, 320, 320), 100); + return stream; + }; + navigator.mediaDevices.enumerateDevices = async () => []; + }); + await page.goto("/?windowType=hud-overlay"); + const preview = page.locator("video").first(); + await expect(preview).toBeVisible(); + await expect + .poll(() => preview.evaluate((video: HTMLVideoElement) => video.srcObject === null)) + .toBe(true); + await page.evaluate(() => + window.dispatchEvent(new CustomEvent("test-editor-mode", { detail: false })), + ); + await expect + .poll(() => + preview.evaluate( + (video: HTMLVideoElement) => + !!video.srcObject && !video.paused && video.videoWidth === 320, + ), + ) + .toBe(true); + await page.evaluate(() => + window.dispatchEvent(new CustomEvent("test-editor-mode", { detail: true })), + ); + await expect + .poll(() => preview.evaluate((video: HTMLVideoElement) => video.srcObject === null)) + .toBe(true); +});