Resolve countdown cancellation and enable webcam preview from New

This commit is contained in:
webadderall
2026-09-22 21:14:19 +10:00
parent 413793fb0d
commit 9c7e4b3a6a
8 changed files with 281 additions and 86 deletions
+86
View File
@@ -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);
});
+83
View File
@@ -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<Result> {
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<typeof setInterval> | 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 };
},
};
}
+13
View File
@@ -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);
});
+8
View File
@@ -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;
}
+11 -78
View File
@@ -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<void>((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 {
+4
View File
@@ -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()) {
+17 -2
View File
@@ -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<BrowserWindow>();
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", () => {
+59 -6
View File
@@ -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<boolean>).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);
});