From d8470846fb27a05fa9f60de3502c81e99528b9c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:38:50 +0000 Subject: [PATCH 01/39] chore(release): bump to 1.9.5-rc.1 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59efaa90..98c0ff6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.2", + "version": "1.9.5-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.2", + "version": "1.9.5-rc.1", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 47a386c2..c7869e5d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.2", + "version": "1.9.5-rc.1", "type": "module", "packageManager": "npm@10.9.4", "engines": { From a44206c469e70040455656ce58d35d52f2fee87b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 22:46:37 +0200 Subject: [PATCH 02/39] fix(recording): open the recording a failed stop left playable A failed stop stopped meaning a lost take the moment the Windows helper began writing fragmented MP4 (a6795d23), and nothing on the Electron side was told. The stop handler still tears the recording down and answers "The recording could not be saved" -- which is now false. The bytes are there, indexed, and play. Measured on installed 1.9.5-rc.1: kill wgc-capture.exe mid-recording, which is what the shutdown watchdog does via TerminateProcess in #252 / #292 / #327, and the file left behind holds 41 moof+mdat fragments with mvex present and no mfra. ffprobe reads 41.0s / 2460 packets at 1920x1080, and `ffmpeg -i f -f null -` decodes it end to end, exit 0, zero errors. Truncating the pre-fMP4 container at the same fraction leaves 59.5 MB no demuxer will touch; the fragmented one at 60% still plays 29s. The app threw the good one away anyway. So the failed-stop branch now asks whether the file is worth keeping instead of assuming it is not, and falls through into the ordinary save path when it is -- same manifest, same cursor telemetry, same media links, same editor. No new UI: from the user's side the recording simply opens, minus at most the last incomplete fragment. The question is answered by the `container` field the helper has been reporting since a6795d23 and nobody read. That is the only thing that can answer it: the fragmented sink degrades to the plain one rather than failing a recording, so the flavour is a per-run outcome, and a plain MP4 killed before Finalize() really is unreadable. Absent, as from any older helper, is not fragmented. Gated on the helper actually being dead. `exited: false` means it survived even the forced kill, and such a process still holds the MP4 open and may still be appending; handing that to the editor would trade an honest failure for a sharing violation on a moving file. The predicate lives in nativeWindowsCaptureStop.ts, next to the rest of the stop logic and for the same reason: handlers.ts calls app.getPath() at import time, so nothing in it can be reached from a test. It shares its size floor with the cleanup that deletes stubs, so the two agree by construction rather than by comment -- nothing is recovered that the tidy-up would have deleted, and nothing deleted that this would keep. Windows only. macOS fragments too and needs the same treatment, but it also has no already-exited fast path and an unguarded stdin write, so it is its own change. Linux writes a plain container on purpose and has nothing to salvage. --- electron/ipc/handlers.ts | 124 +++++++++++++----- .../nativeWindowsCaptureStop.test.ts | 41 ++++++ .../recording/nativeWindowsCaptureStop.ts | 37 ++++++ src/hooks/useScreenRecorder.ts | 6 +- 4 files changed, 172 insertions(+), 36 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 14262b11..d4c09486 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -73,6 +73,8 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { + isSalvageableFragmentedCapture, + NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, terminateNativeWindowsCapture, waitForNativeWindowsCaptureStop, } from "../recording/nativeWindowsCaptureStop"; @@ -538,6 +540,12 @@ let nativeWindowsCursorRecordingStartMs = 0; let nativeWindowsPauseStartedAtMs: number | null = null; let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeWindowsIsPaused = false; +/** + * The MP4 flavour the helper reported for THIS run, or null if it never said. + * Read at stop, not for reporting: it is what decides whether a capture that + * failed to finalize still left a playable file behind. + */ +let nativeWindowsCaptureContainer: string | null = null; /** Cuts a surviving helper's output loose so it cannot pollute the next recording. */ let nativeWindowsCaptureDrainCleanup: (() => void) | null = null; @@ -558,14 +566,17 @@ function resetNativeWindowsCaptureState() { nativeWindowsPauseStartedAtMs = null; nativeWindowsPauseRanges = []; nativeWindowsIsPaused = false; + nativeWindowsCaptureContainer = null; } -/** - * An MP4 the helper never indexed is a few bytes of header at most. Anything - * larger might be a real recording, and deleting one of those to tidy up after - * a failed stop is a far worse outcome than leaving a stray file behind. - */ -const NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES = 64 * 1024; +/** Reads the file, then defers the judgement to the tested predicate. */ +async function salvageNativeWindowsFragmentedCapture(screenVideoPath: string | null) { + if (!screenVideoPath) { + return false; + } + const stats = await fs.stat(screenVideoPath).catch(() => null); + return isSalvageableFragmentedCapture(nativeWindowsCaptureContainer, stats?.size ?? null); +} /** * Best-effort removal of the files a failed or discarded native Windows capture @@ -1344,6 +1355,13 @@ function readNativeWindowsEncoderSelection(output: string) { try { return JSON.parse(lastLine) as { video?: string; + // Which MP4 flavour the helper actually wrote, `fragmented-mp4` or + // `mp4`. It reports this because the fragmented sink degrades to the + // plain one rather than failing a recording, so the flavour is a + // per-run outcome and not a property of the version. This is the only + // thing that can answer "was this file supposed to survive a kill?", + // which is what `salvageNativeWindowsFragmentedCapture` asks. + container?: string; preferSoftwareEncoder?: boolean; }; } catch { @@ -2433,6 +2451,9 @@ export function registerIpcHandlers( : 0; const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput); const encoderSelection = readNativeWindowsEncoderSelection(nativeWindowsCaptureOutput); + // Captured now because stop may have no helper left to ask. A helper + // killed mid-recording is exactly the case where this matters most. + nativeWindowsCaptureContainer = encoderSelection?.container ?? null; console.info("[native-wgc] capture started", { captureStartedAtMs, cursorOffsetMs: nativeWindowsCursorOffsetMs, @@ -2742,6 +2763,11 @@ export function registerIpcHandlers( } } + // Set when the helper failed its stop handshake but left a playable + // fragmented file. Reported so a bug report can tell a clean stop from a + // recovered one; the user-facing path is deliberately identical. + let recovered = false; + try { completeNativeWindowsCursorPauseRange(); const stopPromise = waitForNativeWindowsCaptureStop({ @@ -2763,35 +2789,62 @@ export function registerIpcHandlers( if (!stopResult.exited) { detachNativeWindowsCaptureOutputDrain(); } - await stopCursorRecording(); - // Same as the discard path. `startCursorRecording` clears this on - // the next recording anyway, so this is not what keeps the samples - // from being written next to someone else's video -- it just stops - // a lost take's telemetry from sitting in memory until then. - pendingCursorRecordingData = null; - // The helper never announced a finalized file, so what is on disk - // is almost certainly an unindexed stub, and leaving those behind - // just accumulates unplayable recordings the user cannot explain. - // Almost: size-gate it, because throwing away a recording to tidy - // up after a failed stop is the worse mistake of the two. - await removeNativeWindowsCaptureOutputs(preferredPath, preferredWebcamPath, { - onlyIfUnusable: true, - }); - // The helper log goes to console/diagnostics above, not into this - // string: it ends up in a toast, and pasting an entire capture log - // into the HUD tells the user nothing they can act on. - return { - success: false, - reason: stopResult.reason, - error: - stopResult.reason === "stop-timeout" - ? "Timed out waiting for native Windows capture to stop. The recording could not be saved." - : stopResult.message.split(/\r?\n/).filter(Boolean).at(-1) || - "Native Windows capture failed.", - }; + + // A failed stop stopped meaning a lost take when the helper started + // writing fragmented MP4. The file on disk is already playable, so + // the only thing standing between the user and their recording is + // this function deciding to throw it away and say so. Fall through + // into the normal save path instead: same manifest, same media + // links, same editor. From the user's side it simply worked, minus + // at most the last incomplete fragment. + // + // Only once the helper is actually dead. `exited: false` means it + // survived even the forced kill -- stuck somewhere `TerminateProcess` + // could not reach -- and on Windows such a process still holds the + // MP4 open and may still be appending to it. Handing that file to + // the editor trades an honest failure for a sharing violation on a + // file that is still moving, so a wedged helper keeps the old answer. + if (stopResult.exited && (await salvageNativeWindowsFragmentedCapture(preferredPath))) { + console.warn("[native-wgc] stop failed but the fragmented output is playable", { + reason: stopResult.reason, + path: preferredPath, + }); + recovered = true; + } else { + await stopCursorRecording(); + // Same as the discard path. `startCursorRecording` clears this on + // the next recording anyway, so this is not what keeps the samples + // from being written next to someone else's video -- it just stops + // a lost take's telemetry from sitting in memory until then. + pendingCursorRecordingData = null; + // Reaching here means the container was the plain one, whose only + // index is written by the `Finalize()` this stop never reached, so + // what is on disk really is an unindexed stub and leaving those + // behind just accumulates unplayable recordings the user cannot + // explain. Size-gate it anyway: throwing away a recording to tidy + // up after a failed stop is the worse mistake of the two, and the + // gate is the same one the salvage check above uses. + await removeNativeWindowsCaptureOutputs(preferredPath, preferredWebcamPath, { + onlyIfUnusable: true, + }); + // The helper log goes to console/diagnostics above, not into this + // string: it ends up in a toast, and pasting an entire capture log + // into the HUD tells the user nothing they can act on. + return { + success: false, + reason: stopResult.reason, + error: + stopResult.reason === "stop-timeout" + ? "Timed out waiting for native Windows capture to stop. The recording could not be saved." + : stopResult.message.split(/\r?\n/).filter(Boolean).at(-1) || + "Native Windows capture failed.", + }; + } } - const screenVideoPath = stopResult.screenVideoPath || preferredPath; + // Only a successful stop names the file; the salvage path above falls + // through with `ok: false` and nothing but the path we asked for. + const screenVideoPath = (stopResult.ok ? stopResult.screenVideoPath : null) || preferredPath; if (!screenVideoPath) { throw new Error("Native Windows capture did not return an output path."); } @@ -2833,7 +2886,10 @@ export function registerIpcHandlers( success: true, path: screenVideoPath, session, - message: "Native Windows recording session stored successfully", + recovered, + message: recovered + ? "Native Windows recording recovered from a failed stop" + : "Native Windows recording session stored successfully", }; } catch (error) { console.error("Failed to stop native Windows recording:", error); diff --git a/electron/recording/nativeWindowsCaptureStop.test.ts b/electron/recording/nativeWindowsCaptureStop.test.ts index 7ba34317..900df9e1 100644 --- a/electron/recording/nativeWindowsCaptureStop.test.ts +++ b/electron/recording/nativeWindowsCaptureStop.test.ts @@ -3,6 +3,8 @@ import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + isSalvageableFragmentedCapture, + NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, readStoppedPath, terminateNativeWindowsCapture, waitForNativeWindowsCaptureStop, @@ -77,6 +79,45 @@ describe("readStoppedPath", () => { }); }); +describe("isSalvageableFragmentedCapture", () => { + const big = NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES * 8; + + // The whole point of the fragmented container, and the case that used to be + // deleted-or-disowned while the file on disk played perfectly (#252). + it("keeps a fragmented capture whose stop never finalized", () => { + expect(isSalvageableFragmentedCapture("fragmented-mp4", big)).toBe(true); + }); + + // The ablation. Same size, same failed stop, no index anywhere in the file: + // this one really is lost, and saying otherwise would open an empty editor. + it("does not pretend a plain MP4 survived the same failure", () => { + expect(isSalvageableFragmentedCapture("mp4", big)).toBe(false); + }); + + it("rejects a fragmented file too small to hold a complete fragment", () => { + expect( + isSalvageableFragmentedCapture("fragmented-mp4", NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES - 1), + ).toBe(false); + }); + + it("takes the floor itself as salvageable", () => { + expect( + isSalvageableFragmentedCapture("fragmented-mp4", NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES), + ).toBe(true); + }); + + // A helper predating the fragmented sink reports no container at all. Absent + // is not fragmented -- guessing here would resurrect the total loss. + it("refuses to guess when the helper never reported a container", () => { + expect(isSalvageableFragmentedCapture(null, big)).toBe(false); + expect(isSalvageableFragmentedCapture(undefined, big)).toBe(false); + }); + + it("rejects a file that is not there at all", () => { + expect(isSalvageableFragmentedCapture("fragmented-mp4", null)).toBe(false); + }); +}); + describe("waitForNativeWindowsCaptureStop", () => { it("resolves with the path the helper reported", async () => { let output = "Recording started\n"; diff --git a/electron/recording/nativeWindowsCaptureStop.ts b/electron/recording/nativeWindowsCaptureStop.ts index 95da2c19..badec52d 100644 --- a/electron/recording/nativeWindowsCaptureStop.ts +++ b/electron/recording/nativeWindowsCaptureStop.ts @@ -33,6 +33,43 @@ export const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 60_000; /** How long a killed helper gets to actually die before we escalate. */ const NATIVE_WINDOWS_CAPTURE_KILL_GRACE_MS = 2_000; +/** What `mf_encoder.h`'s `kContainerFormatFragmentedMp4` puts on the wire. */ +export const NATIVE_WINDOWS_FRAGMENTED_CONTAINER = "fragmented-mp4"; + +/** + * An MP4 the helper never indexed is a few bytes of header at most. Anything + * larger might be a real recording, and deleting one of those to tidy up after + * a failed stop is a far worse outcome than leaving a stray file behind. + */ +export const NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES = 64 * 1024; + +/** + * Did a stop that failed its handshake still leave a recording worth opening? + * + * Only the fragmented container can. A plain MP4 writes its one index in + * `Finalize()`, so a helper that never reached it leaves bytes no demuxer can + * read — the total loss issues #252 / #292 / #327 reported. A fragmented one + * writes `moov` up front and a self-describing `moof`+`mdat` pair about every + * second, so the same file plays up to the last complete fragment with nothing + * else needed. Which one a run used is not a property of the version: the + * fragmented sink degrades to the plain one rather than failing a recording, + * which is exactly why the helper reports the flavour it settled on. + * + * The size floor is shared with the cleanup that deletes unusable leftovers, so + * the two agree by construction: nothing is recovered that the tidy-up would + * have judged a stub, and nothing is deleted that this would have called a + * recording. + */ +export function isSalvageableFragmentedCapture( + container: string | null | undefined, + sizeBytes: number | null, +): boolean { + if (container !== NATIVE_WINDOWS_FRAGMENTED_CONTAINER) { + return false; + } + return sizeBytes !== null && sizeBytes >= NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES; +} + const RECORDING_STOPPED_PATTERN = /Recording stopped\. Output path: (.+)/; const STOP_TIMEOUT_EVENT_PATTERN = /"event":"stop-timeout"[^\n]*"step":"([^"]+)"/; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9724c27e..a6da24d7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -591,8 +591,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // disagreeing about whether anything was recording: the HUD kept // showing a stop button, and pressing it sent a second stop that // came back "Native Windows capture is not running." (issue #252). - // The recording is already lost either way -- what the user needs - // is to be able to start a new one. + // Reaching here now means the take really is unreadable -- a failed + // stop that left a playable fragmented file comes back `success` + // with a session and takes the editor path below, so this branch no + // longer decides the fate of a recoverable recording. clearNativeRecordingState(); return true; } From db101b915dc2ad2ed7769f1388d497bc960fd0a4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:33:50 +0200 Subject: [PATCH 03/39] fix(editor): import a recording once, so reopening keeps the project you saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD parks a finished recording in one main-process slot and opens the editor, which imports it into a fresh project on mount. Nothing ever emptied that slot, and opening the editor destroys and recreates its window — so the second open imported the same file again: a new project at the default padding, roundness and wallpaper, with everything the user had set and saved stranded in the project that was no longer on screen. Consume the hand-off once the recording lives in a project. A later mount then takes the existing 'reopen the most recent project' path, which lands on that same project. Two projects on this machine point at one recording file, created two minutes apart, both with an empty settings envelope. --- src/components/ai-edition/NewEditorShell.tsx | 78 +++++++--------- .../ai-edition/recordingImport.test.ts | 91 +++++++++++++++++++ src/components/ai-edition/recordingImport.ts | 55 +++++++++++ 3 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 src/components/ai-edition/recordingImport.test.ts create mode 100644 src/components/ai-edition/recordingImport.ts diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 208942e2..4a69effe 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -41,6 +41,7 @@ import { } from "./Modals"; import { Preview } from "./Preview"; import type { TrimTarget } from "./RightPanes"; +import { importPendingRecording } from "./recordingImport"; import v4 from "./v4/EditorShellV4.module.css"; import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar"; import { type Facet, FloatingInspector } from "./v4/FloatingInspector"; @@ -91,7 +92,6 @@ export function NewEditorShell() { const projectId = useProjectStore((s) => s.projectId); const dirty = useProjectStore((s) => s.dirty); const createProject = useProjectStore((s) => s.createProject); - const addAsset = useProjectStore((s) => s.addAsset); const setCurrentTime = useProjectStore((s) => s.setCurrentTime); const setSourceDuration = useProjectStore((s) => s.setSourceDuration); const loadProject = useProjectStore((s) => s.loadProject); @@ -222,59 +222,45 @@ export function NewEditorShell() { void (async () => { if (!window.electronAPI) return; try { - const result = await window.electronAPI.getCurrentRecordingSession(); - if (!result.success || !result.session?.screenVideoPath) { - // ponytail: no active recording — try to restore the user's - // most recent project. The browser-shim's listProjects - // returns the seeded `browser-shim-projects` entries, so - // e2e tests can land directly in a populated editor; for - // real Electron users this is the expected "open last - // project on launch" UX. - try { - const projects = await nativeBridgeClient.aiEdition.listProjects(); - console.info("[editor] listProjects returned", projects); - if (projects.length > 0) { - console.info("[editor] auto-loading project", projects[0].id); - await loadProject(projects[0].id); - const state = useProjectStore.getState(); - console.info( - "[editor] post-loadProject status=", - state.status, - "error=", - JSON.stringify(state.error), - "doc=", - state.document ? "loaded" : "null", - ); - } - } catch (e) { - console.warn("[editor] auto-load failed", e); - } + if (await importPendingRecording()) { + toast.success("Recording added to a new project"); return; } - const screenPath = result.session.screenVideoPath; - const label = screenPath.split(/[\\/]/).pop() || "Recording"; - await createProject(`Recording ${new Date().toLocaleString()}`); - await addAsset(screenPath, label); - // ponytail: MediaRecorder WebMs ship with duration = NaN until - // fix-webm-duration patches the EBML header; until that flows - // through the asset, drop a default 60s clip into the timeline - // so the editor isn't stuck on "No clips yet" the moment the - // user lands in the project. Real duration overwrites this - // when handleLoadedMetadata fires with a finite value. - const doc = useProjectStore.getState().document; - if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { - await useProjectStore - .getState() - .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); - } - toast.success("Recording added to a new project"); } catch (err) { toast.error("Could not auto-create project from recording", { description: err instanceof Error ? err.message : String(err), }); + return; + } + // ponytail: no recording waiting — restore the user's most recent + // project. The browser-shim's listProjects returns the seeded + // `browser-shim-projects` entries, so e2e tests can land directly in a + // populated editor; for real Electron users this is the expected "open + // last project on launch" UX — and, now that the recording hand-off is + // consumed on import, it is also what reopening the editor after a + // recording lands on: the project that recording went into, settings and + // all, instead of a second project on the same file. + try { + const projects = await nativeBridgeClient.aiEdition.listProjects(); + console.info("[editor] listProjects returned", projects); + if (projects.length > 0) { + console.info("[editor] auto-loading project", projects[0].id); + await loadProject(projects[0].id); + const state = useProjectStore.getState(); + console.info( + "[editor] post-loadProject status=", + state.status, + "error=", + JSON.stringify(state.error), + "doc=", + state.document ? "loaded" : "null", + ); + } + } catch (e) { + console.warn("[editor] auto-load failed", e); } })(); - }, [addAsset, createProject, loadProject]); + }, [loadProject]); // Warn on close when dirty useEffect(() => { diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts new file mode 100644 index 00000000..2c9728d4 --- /dev/null +++ b/src/components/ai-edition/recordingImport.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { importPendingRecording } from "./recordingImport"; + +// The store's own bridge calls are never reached — every action the import uses +// is stubbed below — but importing the store pulls the client in, so stub it. +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); + +const createProject = vi.fn(async () => undefined); +const addAsset = vi.fn(async () => null); +const replaceTimeline = vi.fn(async () => undefined); + +/** Stands in for the main-process recording slot: one value, set and read. */ +function stubElectronApi(screenVideoPath: string | null) { + let session = screenVideoPath ? { screenVideoPath, createdAt: 0 } : null; + const api = { + getCurrentRecordingSession: vi.fn(async () => + session ? { success: true, session } : { success: false }, + ), + setCurrentRecordingSession: vi.fn(async (next: typeof session) => { + session = next; + return { success: true }; + }), + }; + // biome-ignore lint/suspicious/noExplicitAny: test-only stub of the contextBridge surface + (window as any).electronAPI = api; + return api; +} + +describe("importPendingRecording", () => { + beforeEach(() => { + vi.clearAllMocks(); + useProjectStore.setState({ + document: null, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + createProject: createProject as any, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + addAsset: addAsset as any, + replaceTimeline, + }); + }); + + it("does nothing when no recording is waiting", async () => { + stubElectronApi(null); + await expect(importPendingRecording()).resolves.toBe(false); + expect(createProject).not.toHaveBeenCalled(); + }); + + it("imports the recording into a new project and consumes the hand-off", async () => { + const api = stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await expect(importPendingRecording()).resolves.toBe(true); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledWith("C:\\recordings\\recording-1.mp4", "recording-1.mp4"); + expect(api.setCurrentRecordingSession).toHaveBeenCalledWith(null); + }); + + // The regression: the editor window is destroyed and recreated on every open, + // so a session left in the slot was imported again — a second project on the + // same recording, at default settings, with the user's saved ones stranded in + // the first one. + it("imports one recording once, however often the editor mounts", async () => { + stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await importPendingRecording(); + await expect(importPendingRecording()).resolves.toBe(false); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledTimes(1); + }); + + it("seeds a placeholder clip when the imported asset has none", async () => { + stubElectronApi("/recordings/recording-1.webm"); + addAsset.mockImplementationOnce(async () => { + useProjectStore.setState({ + // biome-ignore lint/suspicious/noExplicitAny: only the two fields the seed reads + document: { assets: [{ id: "a1" }], timeline: { clips: [] } } as any, + }); + return null; + }); + + await importPendingRecording(); + + expect(replaceTimeline).toHaveBeenCalledWith( + [{ startSec: 0, endSec: 60 }], + "Auto-imported recording", + ); + }); +}); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts new file mode 100644 index 00000000..0b9b656b --- /dev/null +++ b/src/components/ai-edition/recordingImport.ts @@ -0,0 +1,55 @@ +// Hand-off from the recorder to the editor. +// +// The HUD parks the recording it just finished in ONE main-process slot +// (`set/getCurrentRecordingSession`) and opens the editor, which imports it into +// a fresh project on mount. The slot has to be emptied once that project owns +// the file, because opening the editor destroys and recreates its window +// (`createEditorWindowWrapper` in electron/main.ts) — so a session left in place +// is imported AGAIN on the next open: a second project on the same recording, +// back at the default padding / roundness / wallpaper, while everything the user +// set and saved stays behind in the first project, which is no longer the one on +// screen. That reads exactly like "the editor forgot my settings" (#364). +// +// `setCurrentRecordingSession(null)` is the existing clear (it also drops the +// derived `currentVideoPath`); the only renderer that still needs the session +// after this point is the CLI runner, which lives in its own process. + +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; + +/** + * Imports the recording the HUD handed over into a new project, and consumes the + * hand-off so it is imported exactly once. + * + * Returns false when there is nothing pending — the caller then falls back to + * reopening the most recent project. Throws if the import itself fails, leaving + * the session in place so a later mount can retry it. + */ +export async function importPendingRecording(): Promise { + const api = window.electronAPI; + if (!api) return false; + + const result = await api.getCurrentRecordingSession(); + const screenPath = result.success ? result.session?.screenVideoPath : undefined; + if (!screenPath) return false; + + const label = screenPath.split(/[\\/]/).pop() || "Recording"; + await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); + await useProjectStore.getState().addAsset(screenPath, label); + // Consumed: the recording now lives in a project. Cleared here rather than + // after the timeline seed below so a failure down there can't hand the same + // recording to the next editor window. + await api.setCurrentRecordingSession(null); + + // ponytail: MediaRecorder WebMs ship with duration = NaN until + // fix-webm-duration patches the EBML header; until that flows through the + // asset, drop a default 60s clip into the timeline so the editor isn't stuck + // on "No clips yet" the moment the user lands in the project. Real duration + // overwrites this when handleLoadedMetadata fires with a finite value. + const doc = useProjectStore.getState().document; + if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { + await useProjectStore + .getState() + .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); + } + return true; +} From 39d830f6080233c9fcffdccf3c466c6f92166959 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 20:55:20 +0200 Subject: [PATCH 04/39] docs(e2e): say what injected input can never prove about the HUD Four corrections to the computer-use E2E guidance, each one found by following the existing text and hitting the wall it does not mention. The HUD click-through note had only its positive half: move the real cursor and the control becomes clickable. The negative half is the one that costs an hour. On Windows `forward` is a global WH_MOUSE_LL hook, and only a real OS mouse move drives it; CDP-injected input arrives below the OS hit-test, fires the DOM handler, and looks like it worked while never exercising click-through at all. This repo has a green Playwright test clicking HUD testids, which reads as proof that Playwright can drive the HUD -- it proves renderer wiring and nothing else. The failure #266 actually shipped, a painted and permanently inert HUD, is invisible to injected input by construction and cannot be regression-tested there, so the spec now says so next to those clicks. `request_access` was documented as "grant electron.exe" with no timing. electron.exe is not an installed app, so the resolver only finds it once the process exists and owns a window; asking earlier fails, and one unresolvable name short-circuits the whole request. Granting Openscreen instead resolves to the installed exe and reports success while leaving the dev window masked. The worktree setup step said to copy the prebuilt native binaries without saying they are frozen. Nothing rebuilds them, so a helper older than the change under test runs silently: this pass recorded a healthy 1080p60 file whose encoder-selection event had no `container` field, because the helper predated the fragmented-MP4 commit by seventeen hours. Date the binary and grep it for a string the change introduced. --- AGENTS.md | 3 +++ tests/e2e/windows-native-checklist.spec.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d7b2ec8a..fc38e073 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,10 +86,12 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and grep it for a string the change introduced (`strings -a wgc-capture.exe | grep fragmented-mp4`). If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** - `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Openscreen` — grant **`electron.exe`** or the dev window stays masked in screenshots. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add. +- **Start the app before asking for it.** `electron.exe` is not an installed app, so the resolver only finds it once the process exists *and* owns a window; ask any earlier and the call fails with `doesn't match any installed or running application` — and one unresolvable name short-circuits the whole request, including the names that would have resolved. Granting `Openscreen` instead is not a workaround: it resolves to `…\programs\openscreen\openscreen.exe`, so the dev window stays masked while the grant reports success. **The HUD widget** (recording controller) @@ -97,6 +99,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. +- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD testids and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index 959b15f1..fcda17a2 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -326,6 +326,16 @@ test.describe("Windows native checklist smoke tests", () => { // input-transparent when the hook fails to install can never be clicked again, // which is what bricked the app in issue #266. Both halves matter — that nothing // asks during construction, and that the renderer still does after mount. + // + // Note what this test therefore cannot do, and what no test in this file can. + // Only a real OS cursor move drives a WH_MOUSE_LL hook; CDP-injected input + // arrives below the OS hit-test, so Playwright's own `.click()` on a HUD testid + // — above, and in the source-selector step of the checklist test — reaches the + // DOM handler whether or not click-through is installed, or even working. Those + // clicks assert renderer wiring and nothing else. The failure #266 actually shipped + // (a painted, permanently inert HUD) is invisible to injected input by construction, + // so it belongs on the manual computer-use checklist and cannot be regression-tested + // here. Do not read a green run as evidence that the HUD is clickable. test("the HUD asks for click-through instead of being born with it", async () => { const app = await launchApp(); From b146de5ee2a43e4ff9cf36d4bd53fb1f008a5686 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 21:08:06 +0200 Subject: [PATCH 05/39] docs(e2e): give a binary-string check that works on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advice I had just written recommended `strings -a … | grep`, and Git Bash has no `strings`: the pipeline returns nothing and every binary reads as missing the change. It produced five confident false negatives against the CI-built helper, which does contain the fix. Use `findstr /M /C:` (handles binaries, ships with Windows), and always search a control string the old binary also has, so a broken search cannot masquerade as a stale binary. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index fc38e073..a220da75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. -- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and grep it for a string the change introduced (`strings -a wgc-capture.exe | grep fragmented-mp4`). If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced: `findstr /M /C:"fragmented-mp4" wgc-capture.exe` — `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** From 7fe208948f6171d19d706b83c2e9f39c38911a74 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:16:01 +0200 Subject: [PATCH 06/39] docs(agents): name the thing that actually builds a capture helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review. The rebuild claim was wrong, and wrong in the direction that causes the trap the rest of this PR documents: electron-builder and `@electron/rebuild` do Node native-module ABI work, not the standalone Swift and C++ capture helpers. Those are separate executables built by `npm run build:native:` and only copied into the package as `extraResources` — `build:win` even passes `--config.npmRebuild=false`. A reader who believed the old sentence would expect a normal build to pick up a helper change. Nothing does. The staleness check quoted a bare filename, so it only worked from inside `electron/native/bin//`. Given from the repo root now, and it names the rebuild command instead of only offering the no-toolchain escape hatch. And `testids` is not a word. --- AGENTS.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a220da75..12165d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - Format: `npm run format` (Biome, tabs, double quotes, 100-col) - i18n check: `npm run i18n:check` (validates the 13 locale files) -**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference. The native Swift (macOS) and C++ (Windows) capture helpers are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild`, which resolve the tree through `package-lock.json`. Another package manager writes a different lockfile, so that rebuild breaks. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. +**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference. Node native modules are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild`, which resolve the tree through `package-lock.json`. Another package manager writes a different lockfile, so that rebuild breaks. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. Note what this does *not* cover: the standalone Swift (macOS) and C++ (Windows) capture helpers are separate executables, built by `npm run build:native:` and only *copied* into the package as `extraResources` — `build:win` even passes `--config.npmRebuild=false`. Nothing in a normal build compiles them. ## Development principles @@ -86,7 +86,13 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. -- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced: `findstr /M /C:"fragmented-mp4" wgc-capture.exe` — `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale and you have no toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: + + ``` + findstr /M /C:"fragmented-mp4" electron\native\bin\win32-x64\wgc-capture.exe + ``` + + `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** @@ -99,7 +105,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD testids and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. +- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From dbd0a2954a6daab1604e38dea1d3b76d236135f9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:24:35 +0200 Subject: [PATCH 07/39] docs(agents): the HUD click-through rule is macOS too, not Windows only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet opened with "On Windows", which reads as a scope and is one. `forward` is `@platform darwin,win32` in Electron's typings, and the renderer asks for click-through on both — `!enabled && !isLinuxHud`. Linux is the exception, and the only platform where a blind click on the HUD lands; LaunchWindow.tsx already said so thirty lines from where I wrote the opposite. That mattered: computer-use drives the macOS build too, and an agent reading "On Windows" concludes the caveat is somebody else's problem, then spends an hour on an injected click that fires the DOM handler and proves nothing. The mechanisms do differ — WH_MOUSE_LL on Windows, Electron's own forwarding on macOS — so the sentence now separates the implementation from the consequence, which is shared. Also notes that a macOS spec written like the Windows one would prove no more than it does, since there is no macOS e2e spec yet to say it in. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 12165d63..286e2855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD.** On Windows that `forward` option is a global `WH_MOUSE_LL` hook, and driving it is what lifts the input-transparency over a control. CDP-injected input never does: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. +- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** `forward` is `@platform darwin,win32` in Electron's own typings, and the renderer asks for click-through on both; **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. The implementations differ — Windows installs a global `WH_MOUSE_LL` hook, macOS forwards through its own event path — but the consequence is identical: moving the real cursor onto a control is what lifts the input-transparency. CDP-injected input never does that, on any platform: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From 7c4ce519d16733f43dff510120ba8832977ba443 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:27:38 +0200 Subject: [PATCH 08/39] docs(agents): point at the testing docs, which only linked one way manual-e2e-checklist.md sends the reader to AGENTS.md for the computer-use mechanics. AGENTS.md sent nobody back: its whole "Desktop E2E testing with computer-use" section, and the testing section above it, named no file under technical-documentation/testing/ at all. An agent starting from AGENTS.md -- which its own first line calls the canonical guide -- could read every mechanic for driving the app and never learn that a 410-line capture-to-export checklist exists, with per-platform sections and a results log meant to be appended to. The repo already solved this shape for releases: the Release flow section carries "Full operational guide ... read it before touching a release". Same treatment here, for writing-tests.md and the checklist, plus native-cursor-diagnostics.md for cursor work. Pointers only, no content moved -- the checklist stays the place that says what to run, this stays the place that says how. --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 286e2855..00932d9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,11 +76,14 @@ every edit is the main way an agent turns a 5-minute task into a 30-minute one, - E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). - Add a test for every new behavior in the same package as the code under test. - All tests must pass before opening a PR. CI runs `npm run test` on every PR. +- **Which kind of test to write, and where: [`technical-documentation/testing/writing-tests.md`](technical-documentation/testing/writing-tests.md).** ## Desktop E2E testing with computer-use Unit/browser tests can't exercise real capture (native screen recording, a physical webcam, the tray). To verify a recording/editor feature end to end, drive the actual Electron app with the **computer-use** MCP (screenshot + click/type on the desktop). This is the required "manual smoke test on real Windows/macOS" for native changes. +This section is the *mechanics*. **What to actually run is [`technical-documentation/testing/manual-e2e-checklist.md`](technical-documentation/testing/manual-e2e-checklist.md)** — the capture-to-export pass, per-platform sections, and a results log to append to. Run it before promoting a release candidate and after any change to native capture, preview or export. For cursor work specifically, [`native-cursor-diagnostics.md`](technical-documentation/testing/native-cursor-diagnostics.md) gets you sidecars and reports without a full record-edit-export cycle. The checklist links back here for the mechanics below; the pairing only works if you know both halves exist. + **Launch the app** - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). From 3ba1305ae8d50d5f3f9e0bcbf26fbd9d1942656a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:29:10 +0200 Subject: [PATCH 09/39] docs(testing): log the 1.9.5-rc.1 Windows pass in the results table The table has had one row since July and asks for the run to be recorded. This pass was run and not recorded, which is the same failure as not running it: the next person cannot tell what was covered. Records what the shipped artifact actually did (fragmented MP4 confirmed, 48 fragments over 47.6s), the defect found and where it was fixed, and the finding that matters most for anyone reaching for this checklist next -- a dev build cannot answer a native question, because the prebuilt worktree helper predated the change under test and ran the old path without a word. --- technical-documentation/testing/manual-e2e-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f1375ff7..d3a8fc99 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -406,5 +406,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | Date | Build / tag | Platform | Pass/fail | Notes | |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | +| 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | | | | | | | | | | | | From f26fc4577cde5b9c555bee686a4fcb7ef5f9bf6e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:33:48 +0200 Subject: [PATCH 10/39] docs(testing): the manual tester is an agent, so say what not to drive it with I had left this fact out of the checklist on the reasoning that a manual tester uses a real cursor by definition. That is only true of a human. "Manual" here means an agent holding the mouse, and an agent has a choice a human does not: it can drive the same real app through CDP. That choice is the failure. Injected input arrives below the OS hit-test, so on Windows and macOS -- where the HUD is input-transparent until a real cursor move lifts it -- a Playwright click fires the DOM handler and returns green while the path a user takes was never exercised. Injection is also the faster-looking option, which is what makes it worth an explicit prohibition rather than an implication. Step 1 named the tool and contrasted it with a browser shim; the shim was never the temptation. Two prerequisites promoted next to it, both of which silently void a run rather than failing it: the prebuilt helpers are frozen and a stale one exercises the old path, and the access resolver cannot see a dev build until it is running, while granting the installed name instead reports success and leaves the window masked. --- technical-documentation/testing/manual-e2e-checklist.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d3a8fc99..678b831f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -6,10 +6,12 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing ## How to run this -1. Drive the real Electron app with computer-use, not a browser shim. Start a dev build with `npm run dev`, or launch the packaged build under test. +1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. + + "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. Injection is faster, and it is the one thing that can make this entire checklist mean nothing. 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. -3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. -4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build. +3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. +4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. 6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. 7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. From 2ec6473137208656ab01de8d3833860647825a43 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:39:15 +0200 Subject: [PATCH 11/39] docs(testing): give the editor sections their own reason not to inject The prohibition I just added was argued entirely from the HUD being input-transparent. That is true, and it is also HUD-only: the HUD and the countdown overlay are the only click-through windows, the editor is `transparent: false` and never calls setIgnoreMouseEvents, and an injected click there really does reach the handler a user would. Which means an agent that reads the reason, clears the HUD sections and then thinks about the ~350 editor checks can conclude, correctly from what was written, that injection is fine for the rest. That guts the document. The editor's reason is different and is in this file's own first line: it covers what unit, browser and Playwright tests cannot reach. Driving it the way those tests already drive it re-runs coverage that exists and writes "passed" beside the parts nothing checked. --- technical-documentation/testing/manual-e2e-checklist.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 678b831f..40511ce9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -8,7 +8,9 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 1. Drive the real Electron app with computer-use — real OS mouse and keyboard events. Start a dev build with `npm run dev`, or launch the packaged build under test. - "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. Injection is faster, and it is the one thing that can make this entire checklist mean nothing. + "Manual" here usually means an agent holding the mouse, so the tempting shortcut is not a browser shim: it is driving the real app through CDP instead. **Do not.** Playwright's `.click()`, `javascript_tool`-dispatched pointer events and anything else synthesised into the renderer arrive *below* the OS hit-test. On Windows and macOS the HUD is input-transparent until a real cursor move lifts it, so an injected click fires the DOM handler and comes back green while the path a user actually takes was never exercised at all. + + That trap is specific to the HUD and the countdown overlay — they are the only click-through windows; the editor is an ordinary one, and an injected click there does reach the handler a user would reach. The reason not to inject in the editor either is the first line of this file: this checklist covers what unit, browser and **Playwright** tests cannot. Drive it the way those tests already drive it and you have re-run the coverage you had, then written "passed" beside the parts nothing checked. 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. From 6496934c0d0545338f01fd3c9a88d024e2bd588f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:46:08 +0200 Subject: [PATCH 12/39] docs(agents): orient an agent toward computer-use before it needs to know MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ordering and framing problems, all of which only bite someone reading this front to back and acting as they go. The content-protection flag was documented at line 109 and the launch step is at line 89. Environment variables are set at launch. By the time you reach the explanation you have already started the app without it, screenshotted, found no HUD, and started looking for a bug. It is a module-scope constant read once as the main process loads (`electron/windows.ts:20`), so there is no recovery short of relaunching — which is exactly why it belongs in the launch step, with the log line that confirms it took. The section opened by framing computer-use as the answer to real capture — screen recording, webcam, tray. That is what forces it, not what it covers: the checklist it points at runs the editor, timeline, regions, transcript, export, settings and persistence the same way. An agent reading the old sentence would use computer-use for the capture checks and reach for something faster afterwards. And "Testing instructions" listed Vitest and Playwright and stopped, with no path to the computer-use section below it. Whoever reads only that section concludes Playwright is where e2e ends. It now says what Playwright structurally cannot reach, and links onward. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 00932d9e..e0e10eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,19 +74,21 @@ every edit is the main way an agent turns a 5-minute task into a 30-minute one, every Windows and macOS machine — `electron/recording/webm-seek-index.test.ts` is the worked example. - E2E tests are in `tests/e2e/` (Playwright). Some specs are platform-specific (e.g. `windows-native-checklist.spec.ts`). +- **Playwright is not the end of the e2e story.** It drives the app through CDP, which cannot reach real capture, a real webcam, the tray, or the click-through HUD. Everything those miss is covered by a manual pass driven with computer-use — see [Desktop E2E testing with computer-use](#desktop-e2e-testing-with-computer-use) below, which is required for native changes and before promoting a release candidate. - Add a test for every new behavior in the same package as the code under test. - All tests must pass before opening a PR. CI runs `npm run test` on every PR. - **Which kind of test to write, and where: [`technical-documentation/testing/writing-tests.md`](technical-documentation/testing/writing-tests.md).** ## Desktop E2E testing with computer-use -Unit/browser tests can't exercise real capture (native screen recording, a physical webcam, the tray). To verify a recording/editor feature end to end, drive the actual Electron app with the **computer-use** MCP (screenshot + click/type on the desktop). This is the required "manual smoke test on real Windows/macOS" for native changes. +**Computer-use is how the manual end-to-end pass is driven — all of it, not only the native parts.** Real capture is what forces it (native screen recording, a physical webcam, the tray: no unit or browser test reaches those), but once the app is up you drive everything the same way — editor, timeline, regions, transcript, export, settings, persistence. Screenshot and click/type on the desktop, through the **computer-use** MCP, against the actual Electron app. This is the required "manual smoke test on real Windows/macOS" for native changes, and the only mode in which the checklist below means anything. This section is the *mechanics*. **What to actually run is [`technical-documentation/testing/manual-e2e-checklist.md`](technical-documentation/testing/manual-e2e-checklist.md)** — the capture-to-export pass, per-platform sections, and a results log to append to. Run it before promoting a release candidate and after any change to native capture, preview or export. For cursor work specifically, [`native-cursor-diagnostics.md`](technical-documentation/testing/native-cursor-diagnostics.md) gets you sidecars and reports without a full record-edit-export cycle. The checklist links back here for the mechanics below; the pairing only works if you know both halves exist. **Launch the app** - Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). +- **Set `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1` in the environment you launch from, or the HUD is invisible in every screenshot you take.** It is a module-scope constant (`electron/windows.ts:20`), read once as the main process loads, so it cannot be turned on afterwards — you relaunch or you work blind. The main process prints `[content-protection] OFF for the HUD window` when it took effect; if that line is missing, stop and relaunch rather than hunting a HUD you will never see. What it does and when to unset it: the HUD notes below. - The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. - **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: From b1b81de582e5b801a58b1b3d3fdd33b7f86239b9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 23:52:24 +0200 Subject: [PATCH 13/39] docs(agents): show the control search, not just require it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose demanded a control string and the example showed only the positive search, which makes the control read as optional advice. It is the load-bearing half: without it a broken search is indistinguishable from a stale binary, and that is not hypothetical — `strings … | grep` in Git Bash produced five confident false negatives earlier in this PR, including against the helper that does contain the change. Both commands now appear, with the repository-root path, and the fence is tagged. Outcomes measured against the two helpers the section is about rather than reasoned about: stale gives no match then HIT, current gives HIT twice. --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0e10eb2..e966eeeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,11 +93,14 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. - **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: - ``` + ```powershell + # the string the change introduced — absent from a stale helper findstr /M /C:"fragmented-mp4" electron\native\bin\win32-x64\wgc-capture.exe + # the control — present in every helper, stale or not + findstr /M /C:"encoder-selection" electron\native\bin\win32-x64\wgc-capture.exe ``` - `findstr` handles binaries and ships with Windows, whereas Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Always search a control string the old binary also has (`encoder-selection`) so a broken search cannot masquerade as a stale binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. + Run **both**. Only the second tells "the binary is stale" apart from "my search is broken", and that distinction is not hypothetical: `findstr` handles binaries and ships with Windows, but Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Measured against the two helpers this section is about — stale: no match, then HIT; current: HIT, HIT. A control that does not hit means you learned nothing about the binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. **Granting access** From dfff6e2b5ec644834f76d5d5d7c1ece5a904e36a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 11:10:47 +0200 Subject: [PATCH 14/39] docs(testing): log the rc.2 regression pass, and what nearly faked a bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist run this records covers the 65 commits since v1.9.2 rather than the rc.2 delta, which is what a release candidate actually needs. Four recordings; no defect found. The numbers that matter are in the row. The AGENTS.md addition is the one thing this pass got wrong about itself. The staleness warning I wrote yesterday said to date "the binary" — so I refreshed the capture helper and nothing else, and an export then died on `open_input: -22 (Invalid argument)` out of `compositor.exportMulti`. It reads exactly like a product bug, and I nearly filed it as one. The file was fine: `ffmpeg` opened it from the command line without complaint. The compositor addon was four days older than the av* DLLs it was built against. A full hash diff of the directory found sixteen files differing and two missing outright. So the unit is the directory, not the binary. Copy all of it and diff by hash, or a mismatched set will hand you a failure that looks like the thing you came to test. --- AGENTS.md | 1 + technical-documentation/testing/manual-e2e-checklist.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e966eeeb..cb065a87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta ``` Run **both**. Only the second tells "the binary is stale" apart from "my search is broken", and that distinction is not hypothetical: `findstr` handles binaries and ships with Windows, but Git Bash has **no `strings`**, so `strings … | grep` there returns nothing and reads as a confident *absent* for every binary you point it at. Measured against the two helpers this section is about — stale: no match, then HIT; current: HIT, HIT. A control that does not hit means you learned nothing about the binary. If it is stale, rebuild it with `npm run build:native:win` (or `:mac` / `:linux`) — that is the only thing that compiles a helper. Without the toolchain, test the CI-built artifact instead; a dev build cannot answer the question. +- **And it is the whole directory, not the one binary you came for.** `electron/native/bin//` also holds the compositor addon, the cursor sampler, the ffmpeg DLLs it dlopens, and the STT binaries — each frozen independently at whenever someone last ran a build. Refreshing only the helper leaves a mismatched set, and a mismatched set fails like a product bug: an export died on `open_input: -22 (Invalid argument)` from `compositor.exportMulti` purely because the addon was four days older than the av\* DLLs it was built against, while `ffmpeg` on the command line opened the very same file without complaint. If you are borrowing binaries from an installed build, copy the **entire** directory and diff it by hash afterwards — the last check turned up sixteen differing files and two missing outright. **Granting access** diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 40511ce9..f9e3940d 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -411,5 +411,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | +| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling (this machine is 100% — those bugs are structurally invisible here), webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | | | | | | | | | | | | | From d8b49e3e3c22939e275a8033bb1469fc1a2e8a1f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 11:54:39 +0200 Subject: [PATCH 15/39] docs(testing): "the machine is at 100%" is not a reason to skip DPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row justified skipping DPI coverage with "this machine is 100% — those bugs are structurally invisible here". The display scale is a setting. Changing it takes about two minutes and has been the documented procedure since #346, so the honest sentence was "not re-run in this pass", not "cannot be tested here". Left as not-covered, because it was already validated when 60bb6d7c and 71cc88d6 landed, but the reason now says that instead of dressing a choice up as a constraint — which is exactly how a gap outlives the release it was skipped for. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f9e3940d..7f5ea08d 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -411,6 +411,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a |------|-------------|----------|-----------|-------| | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | -| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling (this machine is 100% — those bugs are structurally invisible here), webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | +| 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | | | | | | | | | | | | | From e3c332cdaa499ee926debc8a98c00a96677f5ea6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 12:06:56 +0200 Subject: [PATCH 16/39] docs(testing): batch the computer-use grants so the operator can leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full capture-to-export pass is dozens of computer-use actions and, once the grants are in place, not one of them prompts again. Verified across the 2026-08-14 run: four dialogs, all at unpredictable moments, then forty-odd uninterrupted actions. So what pins a human to the keyboard is not the grant model, it is that the requests arrive scattered through the run. One batched call at the start and the operator answers once and walks away; discovering a fourth app you need an hour in and they cannot. Names the two easy-to-forget ones: the desktop shell, because the tray is the only reliable route back to the HUD and the save dialogs live there too, and the OS settings app, because changing display scaling is how DPI checks get run at all. Also records why batching is the whole mitigation rather than a preference — there is no config to pre-approve any of it (claude-code#46907, closed stale), and bypassPermissions does not cover it (#43172). --- technical-documentation/testing/manual-e2e-checklist.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 7f5ea08d..57eee2d9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -14,6 +14,13 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 2. The app is single-instance per `userData` path. If a leftover Electron/OpenScreen process still holds the lock, stop that process before relaunching; a second launch can exit successfully without opening a window. The lock is held by the OS and is released when the process dies, so there is nothing to delete on disk. 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. + + **Ask for everything in ONE call, here, before anything else.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt — a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, at the start, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. Beyond the app under test, ask for: + + - the desktop shell (`Explorateur de fichiers` / Finder) — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; + - the OS settings app (`systemsettings.exe` / System Settings) — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + + There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. 6. The recording HUD is protected from capture by default and is invisible in screenshots. For this session only, launch with `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; this is the environment variable checked before `setContentProtection(true)`. Unset it before making any recording whose HUD must not appear in the video. 7. A preview screenshot is downscaled. Settle every pixel-level question by exporting a frame and measuring the exported frame, not by judging fine edges, corners, shadows, or alignment from the preview screenshot. From 322fe947013574c83af06353912736a881906c75 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 12:57:29 +0200 Subject: [PATCH 17/39] docs(testing): fix the grant ordering, and name apps the way the resolver does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, one valid and one that would have broken the recipe. Valid: "here, before anything else" contradicted the launch-first rule stated three lines above it. Now "after the launches above, before the first check", with the reason attached so nobody moves it back. Not valid: the suggestion to use `explorer.exe` instead of the localized label. Tested it — `explorer.exe` returns notInstalled and suggests "Windows Software Development Kit", while `Explorateur de fichiers` resolves to c:\windows\explorer.exe. The resolver matches Start-menu display names, not executables, so that change would have short-circuited the whole batch: exactly the failure this step warns about. The concern underneath it was real though — a localized label is machine-specific and this doc is not. So the step now says the names are display names in the system's language, gives both spellings for the shell, and says to ask rather than guess. --- technical-documentation/testing/manual-e2e-checklist.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 57eee2d9..e8b3020e 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -15,10 +15,12 @@ Sections marked **v1.8.0** cover what this release changed: chat-driven editing 3. From a worktree, link or junction `node_modules` to the main checkout and provide the prebuilt native capture binaries for the platform before starting the dev build. **Date those binaries against the change you came to test.** Nothing rebuilds them, so an older helper runs the old code path in silence: the recording succeeds and the thing you were checking for is simply absent. See AGENTS.md for how to check one and how to rebuild it; when you cannot, test the CI-built artifact, because a dev build cannot answer a native question. 4. Grant computer-use access to the process name that actually owns the window: `electron.exe` or `Electron.app` for a dev build, and `Openscreen.exe` or `Openscreen.app` for a packaged build. Do not grant access only to the installed app name when testing a dev build — it resolves to the *installed* executable and reports success while the dev window stays masked. This is step 4 and not step 1 for a reason: a dev build is not an installed app, so the resolver cannot find it until it is running and owns a window, and one unresolvable name voids the whole request. - **Ask for everything in ONE call, here, before anything else.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt — a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, at the start, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. Beyond the app under test, ask for: + **Ask for everything in ONE call — after the launches above, before the first check.** `request_access` takes a list, and once a grant is in place the rest of the pass runs without a single further prompt: a full capture-to-export run is dozens of clicks and none of them ask again. So the only thing keeping a human at the keyboard is *how many* dialogs you raise and *when*. Raise one, before the first check, and the operator can walk away for the rest of the run; discover a fourth app you need an hour in and they cannot. That is also why this cannot move earlier — the resolver needs the app running, and one unresolvable name voids the batch. Beyond the app under test, ask for: - - the desktop shell (`Explorateur de fichiers` / Finder) — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; - - the OS settings app (`systemsettings.exe` / System Settings) — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + - the desktop shell — the tray icon and the native save dialogs live there, and the tray is the only reliable way back to the HUD; + - the OS settings app — needed to change display scaling, which is how DPI checks are run (see AGENTS.md; "the machine is at 100%" is not a reason to skip them). + + **Name them the way the Start menu does, in the system's own language.** The resolver matches installed-app display names, not executables: on a French Windows the shell is `Explorateur de fichiers` and `explorer.exe` fails outright — `notInstalled`, with a nonsense suggestion attached — which then voids every other name in the same call. On an English install it is `File Explorer`. When unsure, ask rather than guess; the tool lists the installed names it knows. There is no way to pre-approve any of this in config: the request has to be answered live. That is upstream ([claude-code#46907](https://github.com/anthropics/claude-code/issues/46907), closed stale), and `bypassPermissions` does not cover it either ([#43172](https://github.com/anthropics/claude-code/issues/43172)). Batching is the whole mitigation. 5. Read [AGENTS.md](../../AGENTS.md) for the computer-use mechanics, screenshot permissions, tray interaction, and cleanup procedure. Read one check, perform it, observe the result, then continue; close each modal or popover with `Esc` before the next check. From ba1d746f38981583ad1bf139f9aab39eb9c80ca5 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:00:39 +0200 Subject: [PATCH 18/39] docs(testing): log the macOS rc.1 pass, and why a clean stop proves nothing The macOS half of a6795d23 had never been tested. It is active -- but the check the plan prescribed cannot see it. AVAssetWriter collapses its fragments back into a normal movie in finishWriting(), so a cleanly stopped macOS file is `ftyp mdat moov` with zero moof and no mfra: byte-for-byte the shape the plan calls the headline failure, and the same shape a pre-a6795d23 recording has. Only a take whose writer died shows mvex and ~1 moof per second. On macOS the kill test is the assertion; the clean-stop box walk is a coin flip. It also found a blocker on the way. Every app-driven recording truncates -- media stops at 4.0s, 36.0s, 15.0s while the HUD counts to 02:02, 01:30, 01:04 -- and the app then discards a take it could have kept: writer-failed (AVFoundation -11800 / -16341), no sidecars, no editor, ~530 MB of decodable video dropped across three takes. That is the #363 gap firing with nothing killed at all. The cause is narrowed by building the helper twice from the rc.1 source, one line apart. With system audio, movieFragmentInterval present fails 2/2 inside two seconds; removed, it stops cleanly 3/3 at ~40s. The row records the one thing that does not fit -- video-only, the local build outlived the shipped binary 2/2 against 0/5 -- because a report that hides its loose end invites the next person to re-run the easy half and call it settled. --- technical-documentation/testing/manual-e2e-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index e8b3020e..c59b030f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,5 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor, no error dialog. 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From a3dd4a12619d58f77a268462e7980a05f3e61d1a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:37:16 +0200 Subject: [PATCH 19/39] docs(testing): fold in the by-hand macOS repro, and correct the 'no error dialog' claim Six takes made by hand on the same machine separate the trigger that my automated runs could not: system audio on, 3/3 die at ~1.0s and mint no project; audio off, 3/3 survive to 3.3s, 7.4s and 25.0s and each mint one. That matches the movieFragmentInterval A/B exactly. It also corrects the row. I wrote that stop produces no error dialog. It does -- a toast carrying the raw AVFoundation string. My screenshots simply landed after it auto-dismissed. The defect is unchanged; the claim about what the user sees was wrong, and a row that misdescribes the symptom sends the next person looking for a silent failure that is not silent. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c59b030f..5612a9f5 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor, no error dialog. 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** Audio is not required for the failure (my own app runs had it disabled and still died at 4.0/36.0/15.0 s) but it makes it near-immediate, matching the A/B below. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From a37dca9482d6e949dda168278f2ba26f66a9ee77 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 13:53:37 +0200 Subject: [PATCH 20/39] =?UTF-8?q?docs(testing):=20kill=20the=20last=20conf?= =?UTF-8?q?ound=20=E2=80=94=20audio=20off,=20no=20screenshots,=20same=20de?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row could say audio was not required for the failure, but only by pointing at runs that took screenshots mid-capture. That layer hides non-allowlisted windows at the window-server level, which is exactly the kind of thing that makes ScreenCaptureKit hiccup, so the claim rested on the one variable a reader would rightly challenge. Controlled run: audio off, not one screenshot taken while capturing. Same death -- 8.008s of video, flat for the next 76s with the helper still alive, zero sidecars, same -11800/-16341. That also reconciles the by-hand takes with mine, which looked contradictory: the writer dies after a variable delay, so a take stopped before it dies is genuinely clean. 3.3s and 7.4s survive, 8.0s does not. The row now says the thing that matters to anyone reaching for a workaround -- turning audio off buys time, it does not buy safety. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 5612a9f5..d6300c87 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** Audio is not required for the failure (my own app runs had it disabled and still died at 4.0/36.0/15.0 s) but it makes it near-immediate, matching the A/B below. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From 9535bc17e8621e75dc0759f7ac58cf2c94e7e813 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 14:21:00 +0200 Subject: [PATCH 21/39] docs(testing): point the macOS blocker at its fix The Results log is the durable artifact, and a row that records a blocker with no pointer to its resolution invites the next person to re-derive it. #375 root-causes this one to a version 0 trun carrying a negative composition offset, and the row now says so -- along with the part that still needs doing, which is re-running this section against a CI build that carries the fix. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d6300c87..144aacf2 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root-caused and fixed in #375 — the fragments carried a negative composition offset in a version 0 `trun`, where the field is unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the container change was for. Re-run this section against a CI build before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From ee2a1ee49b6dd0f01f5e361bdcadb7344f905ada Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 15:33:44 +0200 Subject: [PATCH 22/39] docs(testing): downgrade four claims the evidence does not carry Review pushed on four sentences, and rebuilding the broken arm while answering it turned one of them from overstated into wrong. "Not load-related" was drawn from two standalone reproductions at a lower resolution. Those show the failure is not confined to the app's 4K60 path, which is not the same thing: append rate demonstrably changes how reliably it bites, reliably at ~57 fps and intermittently at 30. "A/B isolates it" was a sample presented as a law. A later rebuild of the with-the-line arm survived 22.2s at settings that had killed it twice at 1-2s, so the counts narrow the with-audio path and no more. The case rests on the bytes, not the tally, and the row now says so. The same variable also dissolves the video-only local-versus-shipped gap this row called unexplained: 56.6 fps shipped against 29 fps locally, not the released artifact. "Duration exact" was followed in the same clause by the 7 ms it differed by. "Root-caused and fixed in #375" claimed for this run a validation it never did. The run reproduced the failure; the fix is verified at helper level in #375 and in the packaged app nowhere yet. Also attributes the mvex/moof observations to the samples they came from, including the one kill that carries mvex with zero moof because capture had already stalled twelve seconds before the kill landed. --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 144aacf2..a5720970 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -421,6 +421,6 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | | 2026-08-13 | installed `v1.9.5-rc.1` | Windows 11 26200, 1920×1080 @ 100% | Partial — 1 defect | Ran launch/HUD, source selection, recording, stop, editor open. Fragmented MP4 confirmed on the shipped artifact: 48 `moof`+`mdat` pairs over 47.6s, `mvex` present, `mfra` on clean stop. **Defect: a recording that survives a helper kill is thrown away by the app** — killing `wgc-capture.exe` mid-recording leaves a fully decodable 41s file (2460 packets, `ffmpeg -f null -` exit 0) with no `.session.json` and no `.cursor.json`, and stop answers "The recording could not be saved". Fixed in #363, re-verified end to end. Truncation ablation at 60%: plain MP4 unreadable, fragmented plays 29s. **A dev build cannot test any of this** — the prebuilt worktree helper predated the change and silently ran the old path. Editor/export/chat sections not run. | | 2026-08-14 | `release/v1.9.5` @ `b1b81de5` (rc.2 candidate: dev TS + the CI-built rc.1 native payload, which is byte-identical since no native source changed) | Windows 11 26200, 1920×1080 @ 100% | Pass — no defect | Regression net across the 65 commits since **v1.9.2**, not just the rc.2 delta. Four recordings. Every one a fragmented MP4 (`mvex` + ~1 `moof`/s, `mfra` only on a clean stop). GPU DXGI path still correctly opt-in (`videoInput: cpu-rgb32`) — the #336 regression has not crept back. No capture-pacing drift: HUD `00:59` → 60.067s at 60/1. Waveform correct in both directions: absent with no audio track, rendered with one. Audio muxes into the fragmented container (AAC 48k stereo) with 15 ms A/V drift, under one frame. Compositor renders and exports with no camera declared. **#366**: reopening returns to the saved project with its settings (Blur BG on, padding 9%) and mints no second project — 167→168 across a whole new recording. **#363**: helper killed mid-recording → editor opens on the recovered take (46 `moof`, no `mfra`), all three sidecars written, imported once. Export MP4 1080p60 **from that recovered take**: 46.0s / 2760 packets, decodes clean, duration matches the source exactly. Tray refocus works. NOT covered: DPI scaling — **not re-run here, already validated when `60bb6d7c` / `71cc88d6` landed**; note that the display scale is a setting, so "this machine is at 100%" is never a reason a DPI bug cannot be tested (flip it to 150%, ~2 min). Also not covered: webcam PiP and the export webcam fixes, microphone, GIF, macOS/Linux, AI sections, packaging. | -| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: every take whose writer died retains `mvex` + ~1 `moof` per second of media (35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s, 18 on a killed helper). No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root-caused and fixed in #375 — the fragments carried a negative composition offset in a version 0 `trun`, where the field is unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the container change was for. Re-run this section against a CI build before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not load-related. **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **A/B isolates it to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Not established**: video-only, the local build survived 45 s 2/2 while the shipped binary failed 5/5 — that gap is unexplained and needs more samples before blaming the released artifact. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration exact — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured (7 ms, under one frame). **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | +| 2026-08-14 | installed `v1.9.5-rc.1`, macOS Apple Silicon DMG (CI-built, Developer ID signed). **rc.2 is not published** — only rc.1 exists on Releases; no native source changed between `v1.9.5-rc.1` and `origin/release/v1.9.5`, so this artifact already carries the rc.2 native payload, but #366 (cross-platform TS) is absent from it | macOS 26.5 (25F71), M1, 1920×1080 @ 2× | **Fail — 1 blocker** | **The plan's assertion-1 criterion does not hold on macOS, in both directions.** On a clean stop `AVAssetWriter.finishWriting()` collapses the fragments into a normal movie: `ftyp mdat moov`, `mvex` ABSENT, 0 `moof`, no `mfra` (45 s / 44.4 MB run). That is exactly the shape the plan calls the headline failure — and the pre-`a6795d23` control recording (2026-08-10) has the *same* shape — so **a clean-stop box walk cannot distinguish fragmented from plain on macOS; only the kill test can.** Fragmenting *is* active: the takes whose writer died mid-fragment retain `mvex` + ~1 `moof` per second of media (shipped-build writer-failure samples: 35 `moof`/36.0 s, 14/15.0 s, 3/4.0 s; plus 18 on a surviving-helper kill). The one kill on the shipped build is the exception that proves the scope — capture had already stalled ~12 s before the kill, so it carries `mvex` but **0 `moof`** and only 1.0 s. No macOS file, clean or killed, ever carried `mfra`. **Blocker: every app-driven recording truncates, then the app discards it.** (Root cause and fix reported in #375 — the fragments carry a negative composition offset in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines the field as unsigned, because frame reordering was left on; `AVVideoAllowFrameReorderingKey: false` clears it and restores the crash-resilience the fragmenting was for. Verified at helper level there; **this rc.1 run only reproduced the failure and validated nothing about the fix**. Re-run this section against a CI build carrying #375 before rc.2 ships.) 3/3 takes stopped writing early while the HUD kept counting — media 4.0 s / 36.0 s / 15.0 s against HUD `02:02` / `01:30` / `01:04`. Helper emits `{"event":"error","code":"writer-failed"}`; main log `AVFoundationErrorDomain Code=-11800 … (-16341)`. Stop then hangs ~30 s on "Saving…" and drops the take: no `.session.json`, no `.cursor.json`, no editor. The app *does* surface the raw error in a toast (confirmed by hand on the same machine at 13:28–13:35 — my automated runs screenshotted after it auto-dismissed, so an earlier draft of this row wrongly said there was none). 44,561,966 / 328,337,979 / 139,631,607 / 17,187,009 bytes decodable and thrown away (147 GB free — not disk). Reproduced standalone with the shipped helper at 1080p30/8 Mbps, 2/2 (~9 s, ~5 s), so it is not confined to the app's 4K60 path — but do not read that as load-independent: append rate demonstrably modulates how reliably it bites (#375 measures it reliable at ~57 fps and intermittent at 30 fps). **Reproduced by hand, no automation involved**, on six takes recording a YouTube page — and those six separate the trigger cleanly: **system audio ON → 3/3 died at ~1.0 s and minted 0 projects; system audio OFF → 3/3 survived (3.3 s, 7.4 s, 25.0 s) and minted 1 project each.** **Audio is not the condition, only an accelerant** — a controlled run with system audio off *and not one screenshot taken during the capture* (the screenshot layer hides non-allowlisted windows, so it was the last confound worth eliminating) died the same way: 8.008 s of video, 79,004,330 bytes then flat for 76 s with the helper still alive, 7 `moof`, 0 sidecars, 0 projects, same `-11800`/`-16341`. What audio changes is the window: with a track it is ~1 s, without one ~4–40 s. That reconciles the by-hand takes with mine — a take short enough to stop before the writer dies is clean, which is why 3.3 s and 7.4 s survived and 8.0 s did not, and why the 25.0 s one minted a project while still carrying `mvex` (never cleanly finalised). **Turning audio off is therefore not a safe workaround.** Untested here: microphone — this Mac has no input device, and whether a mic track triggers the same path is an inference, not a measurement. **Helper A/B narrows the with-audio path to the fragmentation line**: helper built twice from source identical to the rc.1 tag, differing only by `writer.movieFragmentInterval` (701 vs 700 lines) — with system audio at 1080p30, WITH the line `writer-failed` 2/2 (2.0 s, 1.0 s), WITHOUT it clean `recording-stopped` 3/3 (40.6 s, 37.9 s, 37.6 s). **Read those counts as a sample, not a law**: a later rebuild of the with-the-line arm survived 22.2 s at the same settings, so the failure is probabilistic and rate-dependent, and the byte-level evidence in #375 is what actually carries the case. The video-only local-vs-shipped gap (local survived 45 s, shipped failed 5/5) is explained by the same variable rather than by the released artifact — the shipped runs encoded at 56.6 fps against 29 fps locally. **Kill test** is confounded on the shipped build (capture already dead before the kill): 17.19 MB → only 1.0 s / 56 packets, 0 `moof`. On a helper that does not fail, a mid-write kill leaves 18 `moof`, decodes clean (`ffmpeg -v error -f null -` exit 0, 1373 packets) and no `mfra` — the shape the plan expects. **#363 gap confirmed, and on macOS it fires with no kill at all**: `writer-failed` alone loses the take; there is no app-side recovery. **Audio**: AAC 48 kHz stereo muxes into the fragmented container, video start `0.000000` vs audio `0.014479` → 14.5 ms drift, under one frame at 30 fps (measured on the 2.0 s written before the writer died). **Compositor + export pass**: preview renders with no camera declared; export MP4 1080p60 H.264+AAC via `h264_videotoolbox (zero-copy VT)`, 5,726,865 bytes, 318 packets, decodes clean, duration matches to within 7 ms — source 26.713 s minus trims 19.910 + 1.513 = 5.290 s expected vs 5.283 s measured, under one frame at 60 fps. **#366 not runnable as specified** (absent from rc.1, rc.2 unpublished, and record→editor never completes); adjacent behaviour measured on an existing project — close+reopen kept 19→19 projects, exactly ONE project references the recording, and Blur BG / padding survived (`showBlur=true`, `padding=16`). NOT covered: Windows-only DPI and wgc-capture, GIF, AI sections, packaging (per plan); webcam PiP and microphone — this Mac has neither (Device settings reports "No microphone found" / "No camera found"). | | | | | | | | | | | | | From 155ba4c0fa21679d49a6f8a697744d0730025502 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 14:19:52 +0200 Subject: [PATCH 23/39] fix(recording): stop macOS fragments carrying an offset the box cannot hold a6795d23 gave macOS the same crash-resilience Windows got, in one line: movieFragmentInterval. On macOS that line destroyed every recording it touched. Capture stopped after a few seconds while the HUD counted on, and stop answered AVFoundationErrorDomain -11800 / -16341, so the take was discarded: no sidecars, no editor. Six takes on the shipped rc.1 lost ~530 MB of perfectly decodable video between them. The container was never the problem, and neither were the timestamps -- every sample file has strictly monotonic DTS. What is wrong is in the fragment bytes: each `trun` goes out version 0 carrying composition offsets like 0xFFFFFFF6, which is -10 reinterpreted, because ISO/IEC 14496-12 8.8.8.2 defines that field as unsigned in version 0 and signed only in version 1. Offsets are negative only because the encoder reorders frames, and it reorders because AVVideoAllowFrameReorderingKey is never set, so it runs High profile with has_b_frames=2. MediaToolbox raises -16341 from exactly one site -- inside the function that writes moof/mfhd/traf/trun -- which is why the failure needs movieFragmentInterval to exist at all and always lands on a fragment boundary: the two audio failures hit at 1.0s and 2.0s against a 1s interval. Turning reordering off makes every offset zero and PTS == DTS, and the fragment becomes representable. A screen recorder pays nothing for it -- B-frames buy compression on lookahead-friendly content and cost encode latency, the wrong trade for real-time capture. Measured on macOS 26.5 / M1, 1080p30 with system audio, the configuration that kills the current build in 1-2s: clean stop at 43.66s, has_b_frames 2 -> 0, 0 of 819 packets with pts != dts. SIGKILL at 25s leaves 27 moof, decodes clean (ffmpeg -v error -f null - exit 0) and recovers 28.01s with both tracks. So the recording survives AND the crash-resilience the commit existed for now actually works on macOS, which it never did. The second change is why this cost a whole recording to learn one bit. A failed AVAssetWriter keeps accepting appends and keeps answering false; the helper discarded that Bool after the first frame and read writer.status only in finishWriter(). That is the entire reason the HUD counted to 02:02 over a writer that died at 00:04. The Windows helper checks every WriteSample HRESULT and escalates; this reports once, at the append that failed, carrying the live writer.error. It does not abort the capture -- handlers.ts tears its error listener down once recording-started arrives, so acting on this mid-recording is a TypeScript change and belongs in its own commit. --- .../ScreenCaptureRecorder.swift | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 42e764e3..eaca432e 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -141,6 +141,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var audioMixer: AudioTrackMixer? private var didStartWriting = false private var didEmitRecordingStarted = false + private var didReportWriterFailure = false private var isStopping = false private var isPaused = false private var pauseStartedAt: CMTime? @@ -309,7 +310,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } if videoInput.isReadyForMoreMediaData { - if videoInput.append(sampleBuffer), !didEmitRecordingStarted { + let appended = videoInput.append(sampleBuffer) + if appended, !didEmitRecordingStarted { didEmitRecordingStarted = true emit([ "event": "recording-started", @@ -318,10 +320,31 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "height": outputHeight, "captureBounds": captureBoundsPayload(), ]) + } else if !appended { + reportWriterFailure("video append") } } } + /// A failed AVAssetWriter keeps accepting appends and keeps answering false, so + /// a recorder that discards that Bool records nothing while the HUD counts on. + /// That is how a two-minute take was already lost by its fourth second and only + /// said so at finishWriting(). The Windows helper checks every WriteSample + /// HRESULT and escalates; this is the macOS half of the same contract -- report + /// once, at the append that actually failed, carrying the live writer.error. + private func reportWriterFailure(_ stage: String) { + guard !didReportWriterFailure, let writer else { + return + } + didReportWriterFailure = true + emitError( + code: "writer-failed", + message: "\(stage): " + + (writer.error.map { "\($0)" } + ?? "AVAssetWriter status \(writer.status.rawValue)"), + ) + } + private func ensureRequestedPermissions() throws { if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() @@ -456,6 +479,25 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000, AVVideoExpectedSourceFrameRateKey: request.video.fps, + // Without this the encoder defaults to B-frames, and a reordered + // stream needs a composition offset per sample. AVAssetWriter emits + // those in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines + // the field as UNSIGNED -- so a negative offset goes out as + // 0xFFFFFFF6 and the fragment writer refuses the fragment it is + // about to emit. That refusal is -11800 / -16341, raised from the + // single site in MediaToolbox that writes moof/traf/trun, which is + // why it appears if and only if movieFragmentInterval is set and + // lands exactly on a fragment boundary. + // + // Turning reordering off makes every offset zero and PTS == DTS, so + // the fragment stays representable. A screen recorder gives up + // nothing for it: B-frames buy compression on lookahead-friendly + // content and cost encode latency, which is the wrong trade for + // real-time capture. Measured on macOS 26.5 / M1, 1080p30 with + // system audio: with reordering the writer dies after 1-2s, without + // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 + // readable `moof` fragments. + AVVideoAllowFrameReorderingKey: false, ], ] let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) From 327e74201b554744273198798a0fbd0666db5d0f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 15:32:21 +0200 Subject: [PATCH 24/39] fix(recording): separate the two writer-failure events, and quote the rate Review caught that reportWriterFailure and finishWriter both emitted `writer-failed`, and proposed routing finalization through the one-time reporter. That would break stopping. handlers.ts settles the stop promise on exactly one of `recording-stopped` or `writer-failed`, so suppressing the terminal event whenever an append already fired turns every writer failure into the "Saving..." hang instead of an error -- the exact symptom this branch exists to remove. The two sites answer different questions, so they now carry different codes: `writer-failed-during-capture` says when the writer died, `writer-failed` says whether stopping worked. Verified by putting the bug back and watching a failing run emit exactly one of each. Rebuilding that broken variant also corrected the evidence. It survived 22.2s at 30 fps, where the same configuration had failed twice at 1-2s, so the failure is probabilistic and my "2/2 versus 3/3" was a sample, not a law. It is rate-dependent: at ~57 fps, the rate the app drives and the rate at which the shipped binary failed 6/6, reordering on dies at 13.0s and reordering off stops clean at 31.6s. The comment now quotes the frame rate beside every number, because a reproduction that is only sometimes reproducible is exactly the kind a future reader will try once, fail to trigger, and conclude was never real. The case for the fix does not rest on those counts. It rests on the bytes: the composition offsets are unrepresentable in a version 0 trun in every fragmented file, whether or not that particular run happened to die. --- .../ScreenCaptureRecorder.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index eaca432e..c5e19105 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -332,13 +332,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { /// said so at finishWriting(). The Windows helper checks every WriteSample /// HRESULT and escalates; this is the macOS half of the same contract -- report /// once, at the append that actually failed, carrying the live writer.error. + /// + /// Deliberately not the code finishWriter() emits, and the difference is load + /// bearing. That one is the terminal result of stopping, and the Electron side + /// settles its stop on exactly one of `recording-stopped` or `writer-failed`. + /// Give both sites the same code behind this one-shot guard and a writer that + /// died mid-capture emits nothing at all at stop, so the stop promise never + /// settles and every failure becomes the "Saving..." hang instead of an error. + /// This event answers "when did the writer die"; that one answers "did stopping + /// work". Two questions, two codes. private func reportWriterFailure(_ stage: String) { guard !didReportWriterFailure, let writer else { return } didReportWriterFailure = true emitError( - code: "writer-failed", + code: "writer-failed-during-capture", message: "\(stage): " + (writer.error.map { "\($0)" } ?? "AVAssetWriter status \(writer.status.rawValue)"), @@ -493,10 +502,18 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { // the fragment stays representable. A screen recorder gives up // nothing for it: B-frames buy compression on lookahead-friendly // content and cost encode latency, which is the wrong trade for - // real-time capture. Measured on macOS 26.5 / M1, 1080p30 with - // system audio: with reordering the writer dies after 1-2s, without - // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 - // readable `moof` fragments. + // real-time capture. + // + // Measured on macOS 26.5 / M1, 1080p with system audio. How reliably + // the bug bites scales with append rate, so quote the rate with the + // result: at ~57 fps, the rate the app actually drives, reordering + // on dies at 13.0s while reordering off stops clean at 31.6s; at + // 30 fps it is intermittent, dying at 1.0s and 2.0s but once + // surviving 22.2s. That intermittency is why the byte-level evidence + // leads here and the run counts only corroborate: the offsets are + // out of spec in every fragmented file whether or not that + // particular run happened to die. Reordering off is 3/3 clean across + // both rates, and a SIGKILL at 25s still leaves 27 readable `moof`. AVVideoAllowFrameReorderingKey: false, ], ] From fcd96d6ffc27c1bf6968175c774e5e8e4eaaf854 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:33:52 +0000 Subject: [PATCH 25/39] chore(release): bump to 1.9.5-rc.2 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98c0ff6c..62327ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index c7869e5d..6d7b2776 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.5-rc.1", + "version": "1.9.5-rc.2", "type": "module", "packageManager": "npm@10.9.4", "engines": { From afbfb7bbc3c3108a74faca8ffd99030946ddc1f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:30:57 +0000 Subject: [PATCH 26/39] chore(release): bump to 1.9.5 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62327ee6..03324d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.5-rc.2", + "version": "1.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.5-rc.2", + "version": "1.9.5", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 6d7b2776..76f8c962 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.5-rc.2", + "version": "1.9.5", "type": "module", "packageManager": "npm@10.9.4", "engines": { From b162b18fea31456d855172cdd078e8ddf1d88814 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 00:46:30 +0200 Subject: [PATCH 27/39] fix(store): give msstore a project to publish, not just a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.9.5 was the first release where publish-msstore actually ran — the job did not exist on the v1.9.1 or v1.9.2 builds — and it failed: We could not find a project publisher for the project at ...\artifacts\store\1.9.5\Openscreen.Setup.1.9.5.appx Credentials were never the problem; the CLI reported the configuration valid and resolved product 9MXQ1HQJL5G5. The call was wrong in two ways that compound. `msstore publish` takes a PROJECT ROOT positionally, detects the app type there (Electron, from package.json), and only then accepts a built package through `--inputFile` — so passing the .appx positionally asked it to find a project inside a zip. And the job never checked the repo out, so even the corrected command had nothing to point at. Checkout goes before the artifact download, not after: actions/checkout cleans the workspace and would delete the package it is meant to submit. Unverified, deliberately said out loud in the doc: `--inputFile` is documented for .msix and .msixupload, and build:win:store emits an .appx. Whether the CLI takes that extension cannot be tested without a stable release or a workflow_dispatch at a stable tag, so the Store keeps needing a manual upload until one of those goes green. Worth recording that this was visible at all only because the same release carried the fix reporting the submission's real outcome rather than the configuration's — the previous version would have printed "Submitted to the Store" over this. (cherry picked from commit 48afcc15a418b709e93a6b9e769bf02a6bfc595e) --- .github/workflows/build.yml | 16 +++++++++++++++- .../engineering/release-and-secrets.md | 6 ++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c789b0a..c15d7782 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -999,6 +999,16 @@ jobs: exit 1 fi + # `msstore publish` takes a PROJECT root, not a package: it detects the app + # type there (Electron, via package.json) and only then accepts the built + # package through `--inputFile`. This job used to check nothing out, so + # there was no project to point it at. Checkout runs before the artifact + # download on purpose — actions/checkout cleans the workspace, and would + # delete the package if it ran after. + - name: Check out the project + if: steps.store.outputs.enabled == 'true' + uses: actions/checkout@v7 + - name: Download Store package if: steps.store.outputs.enabled == 'true' uses: actions/download-artifact@v4 @@ -1031,7 +1041,11 @@ jobs: throw 'more than one .appx in the artifact — refusing to guess which one to submit' } Write-Output "Submitting $($appx.Name) to product $env:PRODUCT_ID" - msstore publish $appx.FullName -id $env:PRODUCT_ID + # The positional argument is the project root, NOT the package — passing + # the .appx there is what failed the first real run of this job on + # v1.9.5: "We could not find a project publisher for the project at + # ...Openscreen.Setup.1.9.5.appx". The package goes through --inputFile. + msstore publish . --inputFile $appx.FullName --appId $env:PRODUCT_ID # Report what happened, not what was configured. Keyed off `enabled` alone # under always(), this claimed "Submitted to the Store" when `msstore diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 3451ece8..83bdf810 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -169,6 +169,12 @@ Two constraints from Microsoft's documentation: automated updates through GitHub `msstore submission updateMetadata` can also drive the Store listing text from a versioned `metadata.json`, which would replace the CSV export/import round-trip. Not wired up here. +**It has submitted nothing yet.** v1.9.5 was the job's first real run — it did not exist on the v1.9.1 or v1.9.2 builds — and it failed: `We could not find a project publisher for the project at …Openscreen.Setup.1.9.5.appx`. Credentials were fine; the CLI reported the configuration valid and resolved the product. The call was wrong. `msstore publish` takes a **project root** as its positional argument, detects the app type there, and only then accepts a built package through `--inputFile`; the job passed the `.appx` positionally and never checked the repo out, so there was no project to detect. Fixed by adding a checkout (before the artifact download — `actions/checkout` cleans the workspace) and calling `msstore publish . --inputFile --appId `. + +That failure was visible only because the same release carried the fix that reports the submission's real outcome instead of the configuration's. The prior version wrote "Submitted to the Store" whenever credentials resolved, under `always()` — so this exact failure would have shipped as a green success. + +**Still unverified, and the next thing likely to break:** `--inputFile` is documented for `.msix` and `.msixupload`, and `build:win:store` produces an `.appx` (`electron-builder --win appx`). Whether the CLI accepts that extension is untested — the only way to find out is a stable release or a `workflow_dispatch` of `build.yml` with a stable `release_tag`. Until one of those goes green, assume the Store still needs the manual upload below. + Rotate by issuing a new client secret on the Entra registration, updating `AZURE_AD_APPLICATION_SECRET`, publishing one release to confirm, then deleting the old secret. The tenant, client and seller IDs change only when the registration or account does. ## Discord secrets and variables From dc874af15f983873ff528c76d2be8f3998aa3da0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 00:57:24 +0200 Subject: [PATCH 28/39] fix(store): drop the write token from the Store job, and stop calling a dispatch a dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both on things this PR introduced. The checkout I added inherits the workflow-wide `contents: write` token and, at checkout's default, writes it into .git/config where every later step can read it — in the one job that also handles Partner Center credentials and runs a third-party CLI action. Nothing here pushes, so: persist-credentials: false, plus job-level `permissions: contents: read`. The artifact download is same-run and uses the runtime token, so it is unaffected. And the doc offered a workflow_dispatch as the way to check whether the CLI accepts an .appx. That is not a check. `msstore publish` commits the submission unless given --noCommit, which this job does not pass, so a dispatch fired to satisfy curiosity puts a build into certification and onto users' machines. The doc now says there is no dry run, names --noCommit as what one would require, and points at the next stable release as the test. (cherry picked from commit b49c7ce110d1607e6d78d8d2f077820ae4dac35a) --- .github/workflows/build.yml | 14 ++++++++++++++ .../engineering/release-and-secrets.md | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c15d7782..01b031fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -957,6 +957,12 @@ jobs: publish-msstore: name: Publish to Microsoft Store runs-on: windows-latest + # The workflow-wide token is `contents: write` because publish-release needs + # it. This job only reads: it checks the tree out so the CLI can identify the + # project, downloads a same-run artifact (which uses the runtime token, not + # this one), and talks to Partner Center with its own Entra credentials. + permissions: + contents: read needs: - build-windows-store - publish-release @@ -1008,6 +1014,14 @@ jobs: - name: Check out the project if: steps.store.outputs.enabled == 'true' uses: actions/checkout@v7 + with: + # Nothing here pushes; the tree is only read so the CLI can see it is + # an Electron project. Left at the default, checkout writes the + # workflow's `contents: write` token into .git/config, where every + # later step can read it — including a third-party CLI action and the + # Store submission. See the job-level `permissions` above: same reason, + # other half. + persist-credentials: false - name: Download Store package if: steps.store.outputs.enabled == 'true' diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 83bdf810..6cdfb8a3 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -173,7 +173,9 @@ Two constraints from Microsoft's documentation: automated updates through GitHub That failure was visible only because the same release carried the fix that reports the submission's real outcome instead of the configuration's. The prior version wrote "Submitted to the Store" whenever credentials resolved, under `always()` — so this exact failure would have shipped as a green success. -**Still unverified, and the next thing likely to break:** `--inputFile` is documented for `.msix` and `.msixupload`, and `build:win:store` produces an `.appx` (`electron-builder --win appx`). Whether the CLI accepts that extension is untested — the only way to find out is a stable release or a `workflow_dispatch` of `build.yml` with a stable `release_tag`. Until one of those goes green, assume the Store still needs the manual upload below. +**Still unverified, and the next thing likely to break:** `--inputFile` is documented for `.msix` and `.msixupload`, and `build:win:store` produces an `.appx` (`electron-builder --win appx`). Whether the CLI accepts that extension is untested. + +**There is no dry run, so do not reach for one.** The job is gated to stable tags, so the only ways to exercise it are a real release or a `workflow_dispatch` of `build.yml` with a stable `release_tag` — and neither is a rehearsal. `msstore publish` commits the submission unless it is given `-nc, --noCommit`, which this job does not pass, so a dispatch fired "just to see whether the `.appx` is accepted" creates a submission that enters certification and reaches users. Adding `--noCommit` behind a dispatch input is what a real validation path would need; until someone builds that, assume the Store needs the manual upload below, and treat the next stable release as the test. Rotate by issuing a new client secret on the Entra registration, updating `AZURE_AD_APPLICATION_SECRET`, publishing one release to confirm, then deleting the old secret. The tenant, client and seller IDs change only when the registration or account does. From cdd5fa6402fe5f53f7b8a7d2d7c40e533d38fa0d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 01:08:06 +0200 Subject: [PATCH 29/39] ci(store): add a retry path for Store submission that rebuilds nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.yml's publish-msstore job has no usable retry, which v1.9.5 found the hard way. Re-running the failed job replays the workflow definition frozen into the original run, so the fix landed afterwards is not picked up. Re-dispatching build.yml rebuilds all five platforms and re-uploads the release assets with `--clobber` — rewriting a published release to correct a Store submission — and dispatching it from main rather than the tag would rewrite it with binaries built from code that release never contained. So: a workflow_dispatch that takes the appx build already produced and submits it. No rebuild, no release asset touched, and the macOS legs that needed three attempts are not in the path. `dry_run` passes --noCommit, which leaves the submission in draft. That is the validation path the review of #379 asked for and build.yml still lacks: without it, "let's see whether the .appx is accepted" puts a build into certification. It also answers the open question from that PR cheaply, since --inputFile is documented for .msix/.msixupload and we produce .appx. Read-only token, persist-credentials off, and the tag checked out rather than the default branch so the project state matches the package. Verified as far as it can be without running: YAML parses, all five bash steps pass `bash -n`, and the pwsh block parses through Parser::ParseFile — which caught two real defects. `$args` is a PowerShell automatic variable, and an em dash inside a double-quoted string terminated it early under a non-UTF-8 read, orphaning the rest of the message. (cherry picked from commit 89d58bd3304f1652704992ff6845557e53b8aa6c) --- .github/workflows/publish-msstore.yml | 195 ++++++++++++++++++ .../engineering/release-and-secrets.md | 8 +- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-msstore.yml diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml new file mode 100644 index 00000000..a92eb876 --- /dev/null +++ b/.github/workflows/publish-msstore.yml @@ -0,0 +1,195 @@ +name: Publish to Microsoft Store (retry) + +# Submits an already-built appx to the Store, without rebuilding anything. +# +# build.yml's own publish-msstore job is the normal path. This exists because +# that job has no usable retry: re-running it replays the workflow definition +# frozen into the original run, so a fix landed afterwards is not picked up, and +# re-dispatching build.yml rebuilds every platform and re-uploads the release +# assets with `--clobber` — rewriting a published release to correct a Store +# submission. v1.9.5 hit exactly that dead end. +# +# So this takes the appx that build already produced and submits it. Nothing is +# rebuilt, no release asset is touched, and the flaky macOS legs are not in the +# way. + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Stable tag whose appx should be submitted (e.g. v1.9.5)" + required: true + type: string + run_id: + description: "Build run to take the appx from. Leave empty to use the most recent build for the tag." + required: false + type: string + dry_run: + description: "Create the submission but leave it in draft (--noCommit). Use this to test without shipping." + required: false + type: boolean + default: false + +# Read-only: this checks the tree out so the CLI can identify the project, and +# reads a build artifact. Partner Center is reached with its own Entra +# credentials, not with this token. +permissions: + contents: read + actions: read + +concurrency: + group: publish-msstore-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + submit: + name: Submit ${{ inputs.release_tag }} to the Store + runs-on: windows-latest + # Same gate as build.yml: an RC reaching the Store would go through + # certification and land on every user's machine as an automatic update. + if: ${{ vars.MSSTORE_PRODUCT_ID != '' }} + steps: + - name: Validate the tag + id: tag + shell: bash + env: + TAG: ${{ inputs.release_tag }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Expected a stable tag like v1.9.5; got '${TAG}'. RCs must never reach the Store." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # All-or-nothing, as in build.yml: a half-configured publisher is a + # misnamed secret, and failing loudly beats submitting nothing quietly. + - name: Resolve Store credentials + id: store + shell: bash + env: + AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + SELLER_ID: ${{ secrets.SELLER_ID }} + run: | + required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID + AZURE_AD_APPLICATION_SECRET SELLER_ID) + missing=() + for name in "${required[@]}"; do + [[ -n "${!name}" ]] || missing+=("$name") + done + if [[ ${#missing[@]} -ne 0 ]]; then + echo "::error::Store credentials incomplete; missing: ${missing[*]}" + exit 1 + fi + + - name: Check out the tag + uses: actions/checkout@v7 + with: + # `msstore publish` takes a project root and detects the app type + # there; it is not given a package to introspect. Checking out the tag + # rather than the default branch keeps that project state matching the + # appx being submitted. + ref: ${{ steps.tag.outputs.tag }} + # Nothing here pushes, and the token would otherwise sit in .git/config + # for the third-party CLI action below to read. + persist-credentials: false + + - name: Resolve the build run + id: run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + RUN_ID: ${{ inputs.run_id }} + run: | + if [[ -n "$RUN_ID" ]]; then + echo "Using the run id given: $RUN_ID" + else + # Deliberately not filtered on conclusion: the run this is most + # likely to be retrying is the one whose Store step failed, so + # requiring success would skip exactly the build we want. + RUN_ID="$(gh run list --workflow build.yml --branch "$TAG" \ + --limit 1 --json databaseId --jq '.[0].databaseId')" + if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then + echo "::error::No build.yml run found for ${TAG}. Pass run_id explicitly." + exit 1 + fi + echo "Resolved the most recent build for ${TAG}: $RUN_ID" + fi + echo "id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Download the Store package + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ steps.run.outputs.id }} + run: | + mkdir -p artifacts/store + gh run download "$RUN_ID" --name openscreen-windows-store --dir artifacts/store + + - name: Configure Microsoft Store CLI + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Submit the package to the Store + id: submit + shell: pwsh + env: + PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} + TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + SELLER_ID: ${{ secrets.SELLER_ID }} + CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + # Through env rather than `${{ }}` expanded straight into shell source. + msstore reconfigure ` + --tenantId $env:TENANT_ID ` + --sellerId $env:SELLER_ID ` + --clientId $env:CLIENT_ID ` + --clientSecret $env:CLIENT_SECRET + + $packages = @(Get-ChildItem artifacts/store -Recurse -Include '*.appx','*.msix','*.msixupload') + if ($packages.Count -eq 0) { throw 'no package in the downloaded artifact' } + if ($packages.Count -ne 1) { + throw "expected one package, found $($packages.Count): refusing to guess which to submit" + } + $pkg = $packages[0] + + # The positional argument is the project root, NOT the package: passing + # the package there is what failed v1.9.5 ("could not find a project + # publisher"). The package goes through --inputFile. + # Not $args: that is a PowerShell automatic variable. + $cmdArgs = @('publish', '.', '--inputFile', $pkg.FullName, '--appId', $env:PRODUCT_ID) + if ($env:DRY_RUN -eq 'true') { + # Leaves the submission in draft instead of sending it to + # certification: the only way to test this path without shipping. + $cmdArgs += '--noCommit' + Write-Output "DRY RUN: submitting $($pkg.Name) as a draft only" + } else { + Write-Output "Submitting $($pkg.Name) to product $env:PRODUCT_ID" + } + msstore @cmdArgs + + # Report what happened, not what was configured — the mistake that let + # v1.9.5's failed submission read as a success (see 1617c930). + - name: Summary + if: always() + shell: bash + env: + SUBMIT: ${{ steps.submit.outcome }} + TAG: ${{ inputs.release_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + case "$SUBMIT" in + success) + if [[ "$DRY_RUN" == "true" ]]; then + echo "Draft submission created for ${TAG}; nothing was sent to certification." >> "$GITHUB_STEP_SUMMARY" + else + echo "Submitted ${TAG} to the Store. Certification still has to pass before it goes live." >> "$GITHUB_STEP_SUMMARY" + fi + ;; + *) + echo "Store submission for ${TAG} did NOT happen (submit step: ${SUBMIT:-did not run}). The appx is unchanged; upload it by hand if this keeps failing." >> "$GITHUB_STEP_SUMMARY" + ;; + esac diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 6cdfb8a3..169d610e 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -175,7 +175,13 @@ That failure was visible only because the same release carried the fix that repo **Still unverified, and the next thing likely to break:** `--inputFile` is documented for `.msix` and `.msixupload`, and `build:win:store` produces an `.appx` (`electron-builder --win appx`). Whether the CLI accepts that extension is untested. -**There is no dry run, so do not reach for one.** The job is gated to stable tags, so the only ways to exercise it are a real release or a `workflow_dispatch` of `build.yml` with a stable `release_tag` — and neither is a rehearsal. `msstore publish` commits the submission unless it is given `-nc, --noCommit`, which this job does not pass, so a dispatch fired "just to see whether the `.appx` is accepted" creates a submission that enters certification and reaches users. Adding `--noCommit` behind a dispatch input is what a real validation path would need; until someone builds that, assume the Store needs the manual upload below, and treat the next stable release as the test. +### Retrying a Store submission + +`publish-msstore.yml` submits an already-built appx on demand: `workflow_dispatch` with a stable `release_tag`, optionally a `run_id` (defaults to the most recent `build.yml` run for that tag), and a **`dry_run`** flag. + +It exists because `build.yml`'s own job has no usable retry. Re-running the failed job replays the workflow definition frozen into the original run, so a fix landed afterwards is never picked up; and re-dispatching `build.yml` rebuilds every platform and re-uploads the release assets with `--clobber`, rewriting a published release to correct a Store submission — and, if dispatched from `main` rather than the tag, rewriting it with binaries built from code that release never contained. v1.9.5 hit both walls. + +**`dry_run: true` is the only safe way to test this path.** It passes `-nc, --noCommit`, which creates the submission and leaves it in draft instead of sending it to certification. Without it — and this is what `build.yml` does — `msstore publish` commits, so a dispatch fired "just to see whether the `.appx` is accepted" puts a build in front of users. Validate with the dry run first; submit for real only once it comes back clean. Rotate by issuing a new client secret on the Entra registration, updating `AZURE_AD_APPLICATION_SECRET`, publishing one release to confirm, then deleting the old secret. The tenant, client and seller IDs change only when the registration or account does. From 19e40ce89f541d4a3606847056f8492a1fc8c02d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 01:18:55 +0200 Subject: [PATCH 30/39] ci(store): fix an empty expression, a silent skip, and an unchecked run id Three review findings, one of which would have stopped the workflow from running at all. `${{ }}` inside a `run:` block is not a comment. Actions substitutes expressions across the whole block before the shell sees it, and an empty one is a parse error. build.yml has the same text at line 1072 and is fine, because there it sits in a YAML comment that never reaches the expression parser -- the distinction is which side of `run:` it falls on. My local YAML and shell checks could not see this: it is neither. The job-level `if: vars.MSSTORE_PRODUCT_ID != ''` skipped the whole job when unconfigured, and a skipped job is green and silent. build.yml can afford that as one job in an automatic release; this one exists to be triggered by hand, where "nothing happened, no error" is the worst answer. MSSTORE_PRODUCT_ID moves into the configuration check and fails loudly with a Summary line. And an explicitly supplied run_id was trusted as given. Nothing downstream inspects what is inside the artifact, so a transposed digit would submit another commit's package to the Store under this tag. It is now checked to be a build.yml run whose head_sha matches the tag being published -- verified against the real v1.9.5 run first, so the check accepts the run it exists to retry rather than rejecting it. Not taken: "afterwards" -> "afterward". The repo uses "afterwards" throughout (AGENTS.md, build-and-packaging.md, release-and-secrets.md, manual-e2e-checklist.md, website/docs); changing one instance would make it the odd one out. (cherry picked from commit ae13d5b002ff8a615ef61fa8b4d72df05fab2a4f) --- .github/workflows/publish-msstore.yml | 48 ++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml index a92eb876..36b6917f 100644 --- a/.github/workflows/publish-msstore.yml +++ b/.github/workflows/publish-msstore.yml @@ -45,9 +45,12 @@ jobs: submit: name: Submit ${{ inputs.release_tag }} to the Store runs-on: windows-latest - # Same gate as build.yml: an RC reaching the Store would go through - # certification and land on every user's machine as an automatic update. - if: ${{ vars.MSSTORE_PRODUCT_ID != '' }} + # No job-level `if` on MSSTORE_PRODUCT_ID, deliberately. build.yml can afford + # to skip: it is one job among many in an automatic release. This one is + # something a person asked for by hand, and a skipped job is green and + # silent — the exact shape that let Homebrew and WinGet report success while + # publishing nothing for eight releases. Missing configuration is checked + # below and fails loudly instead. steps: - name: Validate the tag id: tag @@ -63,23 +66,28 @@ jobs: # All-or-nothing, as in build.yml: a half-configured publisher is a # misnamed secret, and failing loudly beats submitting nothing quietly. - - name: Resolve Store credentials + - name: Resolve Store configuration id: store shell: bash env: + MSSTORE_PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} SELLER_ID: ${{ secrets.SELLER_ID }} run: | - required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID - AZURE_AD_APPLICATION_SECRET SELLER_ID) + # MSSTORE_PRODUCT_ID is in here rather than in a job-level `if` so an + # unconfigured repository gets an error and a Summary line, not a + # silent skip on a run somebody triggered on purpose. + required=(MSSTORE_PRODUCT_ID AZURE_AD_TENANT_ID + AZURE_AD_APPLICATION_CLIENT_ID AZURE_AD_APPLICATION_SECRET + SELLER_ID) missing=() for name in "${required[@]}"; do [[ -n "${!name}" ]] || missing+=("$name") done if [[ ${#missing[@]} -ne 0 ]]; then - echo "::error::Store credentials incomplete; missing: ${missing[*]}" + echo "::error::Store configuration incomplete; missing: ${missing[*]}" exit 1 fi @@ -104,7 +112,28 @@ jobs: RUN_ID: ${{ inputs.run_id }} run: | if [[ -n "$RUN_ID" ]]; then - echo "Using the run id given: $RUN_ID" + # A hand-typed run id is the one input that can quietly ship the + # wrong bytes: nothing downstream re-checks what is inside the + # artifact, so a transposed digit could submit another commit's + # package to the Store under this tag. Confirm it is a build.yml run + # and that it was built from the tag being published. + INFO="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" \ + --jq '{path: .path, sha: .head_sha}' 2>/dev/null)" || { + echo "::error::Run ${RUN_ID} not found in ${GITHUB_REPOSITORY}." + exit 1 + } + RUN_PATH="$(jq -r .path <<<"$INFO")" + RUN_SHA="$(jq -r .sha <<<"$INFO")" + TAG_SHA="$(git rev-parse HEAD)" + if [[ "$RUN_PATH" != ".github/workflows/build.yml" ]]; then + echo "::error::Run ${RUN_ID} is ${RUN_PATH}, not build.yml." + exit 1 + fi + if [[ "$RUN_SHA" != "$TAG_SHA" ]]; then + echo "::error::Run ${RUN_ID} built ${RUN_SHA}, but ${TAG} is ${TAG_SHA}." + exit 1 + fi + echo "Using run ${RUN_ID}: build.yml at ${RUN_SHA}" else # Deliberately not filtered on conclusion: the run this is most # likely to be retrying is the one whose Store step failed, so @@ -142,7 +171,8 @@ jobs: CLIENT_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} DRY_RUN: ${{ inputs.dry_run }} run: | - # Through env rather than `${{ }}` expanded straight into shell source. + # Secrets arrive through env, not through expression interpolation + # expanded straight into shell source. msstore reconfigure ` --tenantId $env:TENANT_ID ` --sellerId $env:SELLER_ID ` From 7d253c58bacc00396d560866083eae5d51c3088b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 01:33:14 +0200 Subject: [PATCH 31/39] fix(store): the option is --inputDirectory, and it takes a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.9.5 dry run answered this in ninety seconds, without shipping anything: DRY RUN: submitting Openscreen.Setup.1.9.5.appx as a draft only Unrecognized command or argument '--inputFile'. The CLI then printed its usage, which disagrees with the published docs: -i, --inputDirectory The directory where the '.msix' or '.msixupload' file ... is Microsoft Learn documents `-i, --inputFile` taking a path to the package. The binary the microsoft/microsoft-store-apppublisher action installs takes `--inputDirectory` and wants the folder. I wrote the previous fix from the documentation; the binary is what runs. Both call sites corrected — build.yml carried the same mistake, since that is where the first fix landed. Everything before the submit step already worked on that run: tag validated, configuration resolved, tag checked out, build run resolved and matched by head_sha, artifact downloaded, CLI configured. So this is the last known unknown before the .appx format question, which the next dry run will answer the same cheap way. (cherry picked from commit e1d14528d22543881890485096fa80491e7fcfe1) --- .github/workflows/build.yml | 9 ++++++--- .github/workflows/publish-msstore.yml | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01b031fd..71e95f54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1007,7 +1007,7 @@ jobs: # `msstore publish` takes a PROJECT root, not a package: it detects the app # type there (Electron, via package.json) and only then accepts the built - # package through `--inputFile`. This job used to check nothing out, so + # package through `--inputDirectory`. This job used to check nothing out, so # there was no project to point it at. Checkout runs before the artifact # download on purpose — actions/checkout cleans the workspace, and would # delete the package if it ran after. @@ -1058,8 +1058,11 @@ jobs: # The positional argument is the project root, NOT the package — passing # the .appx there is what failed the first real run of this job on # v1.9.5: "We could not find a project publisher for the project at - # ...Openscreen.Setup.1.9.5.appx". The package goes through --inputFile. - msstore publish . --inputFile $appx.FullName --appId $env:PRODUCT_ID + # ...Openscreen.Setup.1.9.5.appx". The package goes through the option + # below, which takes the DIRECTORY holding it — the CLI's own usage + # says `-i, --inputDirectory`, and rejects the `--inputFile` that + # Microsoft Learn documents. The binary wins. + msstore publish . --inputDirectory $appx.Directory.FullName --appId $env:PRODUCT_ID # Report what happened, not what was configured. Keyed off `enabled` alone # under always(), this claimed "Submitted to the Store" when `msstore diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml index 36b6917f..6f0d135c 100644 --- a/.github/workflows/publish-msstore.yml +++ b/.github/workflows/publish-msstore.yml @@ -188,9 +188,12 @@ jobs: # The positional argument is the project root, NOT the package: passing # the package there is what failed v1.9.5 ("could not find a project - # publisher"). The package goes through --inputFile. + # publisher"). The package goes through the option below, which takes + # the DIRECTORY holding it: the CLI's own usage says + # `-i, --inputDirectory`, and rejects the `--inputFile` that Microsoft + # Learn documents. The v1.9.5 dry run is what caught that. # Not $args: that is a PowerShell automatic variable. - $cmdArgs = @('publish', '.', '--inputFile', $pkg.FullName, '--appId', $env:PRODUCT_ID) + $cmdArgs = @('publish', '.', '--inputDirectory', $pkg.Directory.FullName, '--appId', $env:PRODUCT_ID) if ($env:DRY_RUN -eq 'true') { # Leaves the submission in draft instead of sending it to # certification: the only way to test this path without shipping. From 765b6f1a8587a5eba867305db0d8bf2a74841b72 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 11:47:36 +0200 Subject: [PATCH 32/39] ci(aur): say which key AUR refused, instead of just that it refused one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.9.5 got all the way to the push and died on: aur@aur.archlinux.org: Permission denied (publickey) Everything before it worked — tag resolved, .pacman asset found, AUR repo cloned, PKGBUILD audited, version and every checksum recomputed, .SRCINFO regenerated, diff verified, commit created. Only the push failed, and v1.9.1 and v1.9.2 failed the same way. That message covers three different problems and distinguishes none of them: AUR_SSH_PRIVATE_KEY not being a readable key, a key nobody registered on the account, or a key whose account is not a maintainer of this package. After a public key was added upstream we still cannot tell whether the one CI presents is the one that was added. So print it. A public key is public, and `ssh -T` is the decisive probe because AUR answers it by naming the account it authenticated. Runs on the real path only, after the existing "key touches disk last" step, and `continue-on-error` so a diagnostic can never be what fails a release. Not moved into dry_run: that mode deliberately never writes the key to disk, and its summary says so. (cherry picked from commit dc8303153c3193de1dfb2648ebb5e5f795d7c608) --- .github/workflows/aur-publish.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 8ee64a79..2a735fdf 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -370,6 +370,28 @@ jobs: UserKnownHostsFile ~/.ssh/aur_known_hosts SSHCONF + # "Permission denied (publickey)" is the same message for three different + # problems: a secret that is not a readable key, a key nobody registered + # on AUR, and a key whose account is not a maintainer of this package. + # v1.9.1, v1.9.2 and v1.9.5 all died here and none of them said which. + # A public key is public, so printing it costs nothing and lets the value + # CI presents be compared against what is on the account; `ssh -T` is the + # decisive one, because AUR answers it by naming the user it authenticated. + - name: Identify the key AUR sees + if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run + continue-on-error: true + run: | + if ! ssh-keygen -y -f ~/.ssh/aur_key > /tmp/aur_key.pub 2>/tmp/aur_key.err; then + echo "::error::AUR_SSH_PRIVATE_KEY is not a readable private key: $(cat /tmp/aur_key.err)" + exit 0 + fi + echo "Public key this workflow presents:" + cat /tmp/aur_key.pub + ssh-keygen -lf /tmp/aur_key.pub || true + echo "--- what AUR says about it ---" + # Exits non-zero by design (interactive shell disabled); the message is the payload. + ssh -o BatchMode=yes -T aur@aur.archlinux.org 2>&1 || true + - name: Commit and push if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run working-directory: aur-repo From 596bddac7b7bcee4a9e52acc82bbe04a8e0f8fb9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 12:00:51 +0200 Subject: [PATCH 33/39] ci(aur): stop overclaiming what the probe proves, bound it, pin the key Three review findings, all fair. The comment said `ssh -T` was decisive about maintainer access. It is not: it identifies the authenticated account, and says nothing about whether that account may write ${PACKAGE}. Reworded to what it actually does, which is narrow by elimination -- if AUR answers, the key parses and is registered, so only authorization is left. Switched from `-T` to `help`, which is the documented way to test AUR auth without pushing, and whose reply also enumerates the commands the account may run. That is where to look for a repo-listing command if this has to go further, rather than me asserting one exists: the AUR wiki and RPC are both behind Anubis from here, so I could not verify it. Bounded it. `timeout -k 5 30` plus ConnectTimeout=10, because a diagnostic that hangs is worse than the missing diagnostic it replaced. And `IdentitiesOnly yes` in the ssh config, so both the probe and the push offer this key and nothing else. Without it the probe can report on a different identity than the one the push uses -- which would make the diagnostic actively misleading -- and it is the standard cause of AUR permission-denied where more than one key is reachable. (cherry picked from commit f5f657418c364ba43cbec9880717d9bf30af2937) --- .github/workflows/aur-publish.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 2a735fdf..fce4e0f1 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -366,20 +366,33 @@ jobs: HostName aur.archlinux.org User aur IdentityFile ~/.ssh/aur_key + # Offer this key and nothing else. Without it ssh walks whatever + # else it can find first, and AUR can refuse on a key that is not + # the one being diagnosed -- so the probe below would be reporting + # on a different identity than the push. It is also the standard + # cause of "permission denied" against AUR with more than one key. + IdentitiesOnly yes StrictHostKeyChecking yes UserKnownHostsFile ~/.ssh/aur_known_hosts SSHCONF # "Permission denied (publickey)" is the same message for three different # problems: a secret that is not a readable key, a key nobody registered - # on AUR, and a key whose account is not a maintainer of this package. - # v1.9.1, v1.9.2 and v1.9.5 all died here and none of them said which. - # A public key is public, so printing it costs nothing and lets the value - # CI presents be compared against what is on the account; `ssh -T` is the - # decisive one, because AUR answers it by naming the user it authenticated. + # on AUR, and a key registered to an account that does not maintain this + # package. v1.9.1, v1.9.2 and v1.9.5 all died here and none said which. + # + # This narrows it by elimination rather than proving the last one. A + # public key is public, so printing it costs nothing and lets what CI + # presents be compared against what is on the account. `help` is the + # documented way to test AUR auth without pushing: if it answers, the key + # parses AND is registered, so only authorization for ${PACKAGE} is left. + # Its reply also enumerates the commands the account may run, which is + # where to look for a repo-listing one if this needs to go further. - name: Identify the key AUR sees if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run continue-on-error: true + env: + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} run: | if ! ssh-keygen -y -f ~/.ssh/aur_key > /tmp/aur_key.pub 2>/tmp/aur_key.err; then echo "::error::AUR_SSH_PRIVATE_KEY is not a readable private key: $(cat /tmp/aur_key.err)" @@ -388,9 +401,11 @@ jobs: echo "Public key this workflow presents:" cat /tmp/aur_key.pub ssh-keygen -lf /tmp/aur_key.pub || true - echo "--- what AUR says about it ---" - # Exits non-zero by design (interactive shell disabled); the message is the payload. - ssh -o BatchMode=yes -T aur@aur.archlinux.org 2>&1 || true + echo "--- what AUR says about it (auth only; NOT write access to ${PACKAGE}) ---" + # Bounded: a diagnostic must never be the thing that hangs a release. + # Exits non-zero by design; the message is the payload. + timeout -k 5 30 ssh -o BatchMode=yes -o ConnectTimeout=10 \ + aur@aur.archlinux.org help 2>&1 || true - name: Commit and push if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run From d49385e655ed8b2deeb5b27fecb40d9fc3a5e07f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 13:07:03 +0200 Subject: [PATCH 34/39] fix(hud): give click-through a way out that Windows cannot revoke The HUD asks to be input-transparent on mount, and every route back out -- pointerenter/pointerdown on the bar, pointermove on the root, the popover effect -- needs a DOM mouse event. Chromium delivers none to a window it has made input-transparent, so the only supply was Electron's `{ forward: true }` WH_MOUSE_LL hook: installed unchecked (SetWindowsHookEx's return value is discarded), latched behind `forwarding_mouse_messages_` so it re-arms only after a setIgnoreMouseEvents(false) the renderer can no longer request, and silently revoked by Windows for any callback that overruns LowLevelHooksTimeout -- "there is no way for the application to know whether the hook is removed". One hook that never installs or quietly dies and the HUD is painted, inert, forever, with the tray icon as the only way to quit. That is #266, and #385 after it, on 1.9.5 -- a build that already carries the #266 fix. That fix moved *when* the hook is installed, from construction onto an IPC message, and left the trapdoor exactly where it was: the renderer still cannot ask to leave a state that stops it receiving the event it would have to ask with. So the escape no longer runs on anything Windows can take away. getCursorScreenPoint() is a plain positional read the main process can always make; it is polled only while the window is click-through -- the state the poll exists to escape -- and the window-relative point is pushed to the renderer, which hit-tests it with elementFromPoint().closest("[data-hud-interactive]"), the same predicate handleRootPointerMove already used against the same layout. Every tick re-derives the answer from scratch, so no dropped message, dead hook or stale flag can strand it. `forward` is gone, and the e2e test now pins it off rather than pinning it on. The point is deduped window-relative rather than by cursor position, because "hud-overlay-set-size" re-anchors the window on every content change: the bar can arrive under a cursor that never moved, and that changes the answer too. Verified against the built app, driving the real main process and moving the window under a stationary cursor rather than the mouse: tape after mount: [[true]] tape with the empty reserve under the cursor: [[true]] tape after placing the bar under the cursor: [[true],[false]] -- entered with no `forward` argument, held click-through over the transparent reserve so desktop clicks still pass through, and released it with no pointer event of any kind. The new unit test fails on the unpatched renderer. Not addressed here, and reported separately: the opaque black surround. On anything below Windows 11 22H2, Electron 41's setContentProtection(true) runs `SetLayered()` -- WS_EX_LAYERED with SetLayeredWindowAttributes and UpdateLayeredWindow never called. Removing it would put the HUD back into recordings, which is a product call, not a bug fix. Fixes #385 (cherry picked from commit 3bee346a3e4f1e1402ccbb9acd365de52b26cac1) --- electron/electron-env.d.ts | 3 + electron/preload.ts | 5 ++ electron/windows.ts | 84 ++++++++++++++++++--- src/components/launch/LaunchWindow.test.tsx | 50 ++++++++++++ src/components/launch/LaunchWindow.tsx | 19 +++++ tests/e2e/windows-native-checklist.spec.ts | 18 +++-- 6 files changed, 161 insertions(+), 18 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9e1fb7d0..789eb7e5 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -395,6 +395,9 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; + /** Window-relative cursor position, pushed while the HUD is click-through and + * therefore receiving no pointer events of its own. Returns an unsubscribe. */ + onHudOverlayCursor: (callback: (x: number, y: number) => void) => () => void; /** Pins the overlay's current position as the origin for `dragHudOverlayTo`. */ beginHudOverlayDrag: () => void; /** Total pointer travel since `beginHudOverlayDrag`, not a per-frame delta. */ diff --git a/electron/preload.ts b/electron/preload.ts index 8e018ed8..66fe2af6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -80,6 +80,11 @@ contextBridge.exposeInMainWorld("electronAPI", { setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); }, + onHudOverlayCursor: (callback: (x: number, y: number) => void) => { + const listener = (_e: Electron.IpcRendererEvent, x: number, y: number) => callback(x, y); + ipcRenderer.on("hud-overlay-cursor", listener); + return () => ipcRenderer.removeListener("hud-overlay-cursor", listener); + }, beginHudOverlayDrag: () => { ipcRenderer.send("hud-overlay-drag-start"); }, diff --git a/electron/windows.ts b/electron/windows.ts index 0b19d5b9..4b5ceb7f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -104,9 +104,75 @@ ipcMain.on("hud-overlay-hide", () => { } }); +// The cursor, sampled here and pushed to the renderer, because while the HUD is +// click-through nothing else can tell it where the pointer is. +// +// Chromium delivers no pointer event of any kind to a window it has made +// input-transparent — including the pointermove the renderer needs to ask for input +// back. Electron's `{ forward: true }` covered that with a global WH_MOUSE_LL hook +// that re-posts WM_MOUSEMOVE, and that hook was the ONLY route out: its install is +// unchecked (SetWindowsHookEx's return value is discarded), latched behind Electron's +// `forwarding_mouse_messages_` so it re-arms only after a setIgnoreMouseEvents(false) +// the renderer can no longer request, and Windows silently revokes any low-level hook +// whose callback overruns LowLevelHooksTimeout — "there is no way for the application +// to know whether the hook is removed". One hook that never installs or quietly dies +// and the HUD is painted, inert, forever, with the tray icon as the only way to quit +// the app. That is issue #266, and issue #385 after it: #266 was closed by moving +// *when* the hook is installed, which left the trapdoor exactly where it was. +// +// So the escape no longer runs on anything Windows can take away. getCursorScreenPoint +// is a plain positional read the main process can always make, the poll exists only +// while the window is click-through — the state it is there to escape — and the +// renderer re-derives the answer from scratch on every tick, so no dropped message, +// dead hook or stale flag can strand it. +const HUD_CURSOR_POLL_MS = 32; +let hudCursorPoll: ReturnType | null = null; +let hudLastPoint: { x: number; y: number } | null = null; + +function stopHudCursorPoll() { + if (hudCursorPoll) clearInterval(hudCursorPoll); + hudCursorPoll = null; + hudLastPoint = null; +} + +function pollHudCursor() { + const win = hudOverlayWindow; + if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return; + + // getBounds() and getCursorScreenPoint() are both in DIP, and so is a renderer CSS + // pixel (the HUD is frameless, so the client area is the whole window). + const bounds = win.getBounds(); + const cursor = screen.getCursorScreenPoint(); + const x = cursor.x - bounds.x; + const y = cursor.y - bounds.y; + if (x < 0 || y < 0 || x >= bounds.width || y >= bounds.height) return; + + // Deduped on the WINDOW-RELATIVE point, not the cursor: "hud-overlay-set-size" + // re-anchors the window on every content change, so the bar can arrive under a + // cursor that never moved — and that changes the answer just as much. + if (hudLastPoint && hudLastPoint.x === x && hudLastPoint.y === y) return; + hudLastPoint = { x, y }; + + win.webContents.send("hud-overlay-cursor", x, y); +} + ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true }); + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + // No `forward`: the poll above replaces it, and leaving it on would keep the app + // depending on a hook it cannot check for a transition it no longer needs. + hudOverlayWindow.setIgnoreMouseEvents(ignore); + + if (!ignore) { + // Input is live again; the document's own pointer events are cheaper and + // finer-grained than anything sampled at 32 ms. + stopHudCursorPoll(); + return; + } + if (!hudCursorPoll) { + hudCursorPoll = setInterval(pollHudCursor, HUD_CURSOR_POLL_MS); } }); @@ -270,16 +336,9 @@ export function createHudOverlayWindow(): BrowserWindow { // ready-to-show, so the two are ~85 ms apart — measured, not assumed). What that // leaves open is an invisible rectangle that can swallow one desktop click in // those 85 ms, right after the user launched the app — against what doing it here - // cost them: the whole app (issue #266). On Windows the `forward` option is a global - // WH_MOUSE_LL hook, and that hook is the only way out of the state, because - // Chromium sends no pointermove to a window it has made input-transparent — so - // the renderer can never ask to leave it on its own. Electron latches - // the install behind `forwarding_mouse_messages_` and retries only after a - // setIgnoreMouseEvents(false) — the very call a dead hook prevents. One refused - // or revoked hook (Windows drops any whose callback overruns the 300 ms - // LowLevelHooksTimeout — on this thread, still busy booting the app) and the HUD - // is painted, inert, forever. Asking later moves the install onto an IPC message, - // i.e. onto a main thread that is provably pumping. + // cost them: the whole app (issue #266). A window nothing ever asks for — a + // renderer that dies before mount — then stays clickable instead of becoming a + // ghost. See the "hud-overlay-cursor" poll above for the way back out. // Keep the recording controls out of the recording (see applyContentProtection). applyContentProtection(win, "HUD"); @@ -307,6 +366,7 @@ export function createHudOverlayWindow(): BrowserWindow { if (hudOverlayWindow === win) { hudOverlayWindow = null; hudDragOrigin = null; + stopHudCursorPoll(); } }); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 76b6a6fe..1b0e3110 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -63,6 +63,7 @@ const recorderState = vi.hoisted(() => ({ }, })); +let hudCursorListeners: Array<(x: number, y: number) => void> = []; let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; let sourceSelectorClosedListeners: Array<() => void> = []; @@ -209,6 +210,12 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo })), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), + onHudOverlayCursor: vi.fn((callback) => { + hudCursorListeners.push(callback); + return () => { + hudCursorListeners = hudCursorListeners.filter((listener) => listener !== callback); + }; + }), beginHudOverlayDrag: vi.fn(), dragHudOverlayTo: vi.fn(), endHudOverlayDrag: vi.fn(), @@ -273,6 +280,7 @@ function resetLaunchMocks() { recorderState.value.webcamEnabled = false; recorderState.value.setWebcamEnabled.mockClear(); micDevicesState.value = []; + hudCursorListeners = []; selectedSourceChangedListeners = []; sourceSelectorClosedListeners = []; i18nState.value.systemLocaleSuggestion = null; @@ -409,6 +417,48 @@ describe("LaunchWindow record button", () => { expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); }); + // The #385 regression, and #266 before it. A HUD that has gone click-through + // receives no pointer event of any kind, so every DOM route back — pointerenter, + // pointerdown, pointermove — is unreachable by construction. This test therefore + // fires NO pointer events at all: it delivers only the cursor position the main + // process pushes, which is the one signal that survives input-transparency, and + // requires that to be enough to make the bar clickable again. + it("leaves click-through on a pushed cursor position alone, with no pointer event", async () => { + platformState.value = "win32"; + + renderLaunchWindow(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(true); + }); + expect(hudCursorListeners).not.toHaveLength(0); + + // jsdom has no layout and does not implement elementFromPoint at all, so it is + // defined here to return what a point over the bar resolves to in a browser. The + // assertion is that the pushed cursor drives the hit test, not that jsdom can hit-test. + const bar = document.querySelector("[data-hud-interactive='true']"); + expect(bar).not.toBeNull(); + const elementFromPoint = vi.fn(() => bar); + Object.defineProperty(document, "elementFromPoint", { + value: elementFromPoint, + configurable: true, + }); + + try { + for (const listener of hudCursorListeners) listener(410, 540); + + expect(elementFromPoint).toHaveBeenCalledWith(410, 540); + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + + // And a point over the transparent reserve must NOT claim the window back. + elementFromPoint.mockReturnValue(document.body); + for (const listener of hudCursorListeners) listener(10, 10); + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + } finally { + Reflect.deleteProperty(document, "elementFromPoint"); + } + }); + it("keeps the HUD interactive on Linux so the drag handle can receive pointer events", async () => { platformState.value = "linux"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 71d9eda2..7639936f 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -420,6 +420,25 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isPopoverOpen); }, [isPopoverOpen, setHudMouseEventsEnabled]); + // The way back out of click-through. Every other route below — pointerenter and + // pointerdown on the bar, pointermove on the root — needs an event this document + // stops receiving the moment the window goes input-transparent, which is what left + // the HUD painted and permanently dead in #266 and again in #385. So the main + // process samples the OS cursor and pushes it here instead, and the hit test is the + // one `handleRootPointerMove` already runs, against the same layout: elementFromPoint + // honours pointer-events, so a point over the transparent reserve resolves to the + // root and correctly stays click-through. + // + // Only ever turns click-through OFF. Turning it back on is the DOM handlers' job, + // and they are reliable by then — the window is receiving real input again. + useEffect(() => { + return window.electronAPI?.onHudOverlayCursor?.((x, y) => { + if (document.elementFromPoint(x, y)?.closest("[data-hud-interactive='true']")) { + setHudMouseEventsEnabled(true); + } + }); + }, [setHudMouseEventsEnabled]); + const defaultSourceName = t("sourceSelector.defaultSourceName"); const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index fcda17a2..22b37819 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -321,11 +321,17 @@ test.describe("Windows native checklist smoke tests", () => { }); // The HUD must reach click-through by *asking* for it from the renderer, never - // by being born that way. On Windows the `forward` option is a global - // WH_MOUSE_LL hook, and it is the only route back out: a HUD that is already - // input-transparent when the hook fails to install can never be clicked again, - // which is what bricked the app in issue #266. Both halves matter — that nothing - // asks during construction, and that the renderer still does after mount. + // by being born that way: a window born input-transparent whose renderer never + // mounts can never be clicked again, which is what bricked the app in issue #266. + // Both halves matter — that nothing asks during construction, and that the + // renderer still does after mount. + // + // The second assertion also pins `forward` OFF. It used to be the only route back + // out of click-through, via a global WH_MOUSE_LL hook that Windows can refuse or + // silently revoke — which is how #385 reproduced a dead HUD on a build that already + // carried the #266 fix. The way out is now the "hud-overlay-cursor" poll in + // electron/windows.ts, and asking for `forward` again would restore the dependency + // without restoring the need. // // Note what this test therefore cannot do, and what no test in this file can. // Only a real OS cursor move drives a WH_MOUSE_LL hook; CDP-injected input @@ -383,7 +389,7 @@ test.describe("Windows native checklist smoke tests", () => { // And the renderer does ask, once it has mounted. await expect .poll(() => app.evaluate(() => globalThis.__hudTape ?? []), { timeout: 20_000 }) - .toContainEqual([true, { forward: true }]); + .toContainEqual([true]); } finally { await app.evaluate(({ BrowserWindow }) => { const original = globalThis.__hudSetIgnoreMouseEvents; From 74023662da55ff0a303d0d542ce2c007f2942cd8 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 13:28:47 +0200 Subject: [PATCH 35/39] test(hud): make the click-through negative case able to fail CodeRabbit was right on both counts. The "transparent reserve must not claim the window back" assertion ran AFTER the bar had already claimed it, which made it vacuous: the renderer dedupes on hudIgnoreMouseEventsRef, so a point that wrongly enabled input would have sent no IPC at all and "still false" held either way. Moved it before the bar, while the window is still click-through -- there a wrong answer IS an IPC, so the assertion can fail. Confirmed by mutation: dropping the closest() guard from the cursor handler now fails with "expected vi.fn() to not be called at all, but actually been called 1 times", where before it passed. Adds the unmount test AGENTS.md asks for -- the effect returns the unsubscribe handed back by onHudOverlayCursor, and nothing covered it. Also mutation-checked: dropping the `return` fails with "expected [ [Function] ] to have a length of +0". No production change. (cherry picked from commit fa03693d00f06f83f5e8a5cd78af58ea8973f755) --- src/components/launch/LaunchWindow.test.tsx | 38 ++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 1b0e3110..3fdb7caa 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -434,31 +434,51 @@ describe("LaunchWindow record button", () => { expect(hudCursorListeners).not.toHaveLength(0); // jsdom has no layout and does not implement elementFromPoint at all, so it is - // defined here to return what a point over the bar resolves to in a browser. The - // assertion is that the pushed cursor drives the hit test, not that jsdom can hit-test. + // defined here to return what each point resolves to in a browser. The assertion + // is that the pushed cursor drives the hit test, not that jsdom can hit-test. const bar = document.querySelector("[data-hud-interactive='true']"); expect(bar).not.toBeNull(); - const elementFromPoint = vi.fn(() => bar); + const elementFromPoint = vi.fn((_x: number, _y: number): Element | null => document.body); Object.defineProperty(document, "elementFromPoint", { value: elementFromPoint, configurable: true, }); + const setIgnore = vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents); try { + // The transparent reserve goes FIRST, while the window is still click-through. + // Do it after the bar has claimed input back and the assertion is vacuous: the + // renderer dedupes, so a point that wrongly enabled input would send no IPC at + // all and "still false" would hold either way. Here a wrong answer is an IPC. + setIgnore.mockClear(); + for (const listener of hudCursorListeners) listener(10, 10); + expect(setIgnore).not.toHaveBeenCalled(); + + // And the bar hands input back. + elementFromPoint.mockReturnValue(bar); for (const listener of hudCursorListeners) listener(410, 540); expect(elementFromPoint).toHaveBeenCalledWith(410, 540); - expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); - - // And a point over the transparent reserve must NOT claim the window back. - elementFromPoint.mockReturnValue(document.body); - for (const listener of hudCursorListeners) listener(10, 10); - expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + expect(setIgnore).toHaveBeenCalledWith(false); } finally { Reflect.deleteProperty(document, "elementFromPoint"); } }); + it("unsubscribes from the pushed cursor when the HUD unmounts", async () => { + platformState.value = "win32"; + + const { unmount } = renderLaunchWindow(); + + await waitFor(() => { + expect(hudCursorListeners).not.toHaveLength(0); + }); + + unmount(); + + expect(hudCursorListeners).toHaveLength(0); + }); + it("keeps the HUD interactive on Linux so the drag handle can receive pointer events", async () => { platformState.value = "linux"; From 99a806ac4824df8ff7de3b6e5d6acad068216805 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 14:33:22 +0200 Subject: [PATCH 36/39] docs(agents): the HUD lift is a cursor poll now, not a WH_MOUSE_LL hook #388 deleted `{ forward: true }`, and this section still described it: `forward` as `@platform darwin,win32`, "Windows installs a global WH_MOUSE_LL hook, macOS forwards through its own event path". None of that is true any more. The main process polls screen.getCursorScreenPoint() while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with elementFromPoint().closest("[data-hud-interactive='true']") -- one path, no platform branch. The RULE is untouched, and that is the part worth being explicit about: an agent still has to move the real cursor, because the poll reads the OS cursor position and CDP-injected input does not change it. Says so, and says what the mechanism used to be, so the next reader who finds `forward` in the git history knows this page is current rather than stale. Also corrects the window size while in here: 600x160 was wrong before #388 -- createHudOverlayWindow builds 820x560 and the renderer then resizes to fit its content (measured 904x698, bar at the bottom, empty reserve above). (cherry picked from commit e7a0742a4dcec45a3972654ead49c42e3fda5c97) --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cb065a87..6bc4910f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +113,8 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **It is invisible in screenshots by default.** The HUD (and the Notes window) call `setContentProtection(true)` so the recording controls never end up baked into a recording — the same `SetWindowDisplayAffinity` that WGC honours also hides them from *your* screenshots. The window is there, and clicks land, but you are aiming blind at a rectangle you cannot see. Set **`OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`** in the app's environment to turn it off for a session; every skipped window logs a warning. Unset it before recording anything real, or the HUD ends up in the video. - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. -- Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** `forward` is `@platform darwin,win32` in Electron's own typings, and the renderer asks for click-through on both; **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. The implementations differ — Windows installs a global `WH_MOUSE_LL` hook, macOS forwards through its own event path — but the consequence is identical: moving the real cursor onto a control is what lifts the input-transparency. CDP-injected input never does that, on any platform: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. +- Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 820×560 at construction, then resized to fit its content — measured 904×698 with the bar at the bottom and mostly empty reserve above it). It is **click-through** (`setIgnoreMouseEvents(ignore)`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. +- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What lifts the input-transparency is therefore a change in the *OS cursor position*, which is precisely why CDP-injected input never does it, on any platform: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From b9686befb6c8afcaff628bbe43f1c99a6b683233 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 14:44:26 +0200 Subject: [PATCH 37/39] docs(agents): the poll keys off the cursor RELATIVE to the window CodeRabbit caught an over-strong premise, and it traces back to a deliberate choice in #388: pollHudCursor dedupes on the window-relative point, not on the cursor, precisely because "hud-overlay-set-size" re-anchors the window on every content change and the bar can arrive under a pointer that never moved. So "what lifts the input-transparency is a change in the OS cursor position" was not true -- a resize or re-anchor produces a fresh sample on its own. Reworded to what the poll actually reads, and the conclusion is now tied to the property that is airtight rather than to the one that is merely usual: synthesised input moves no pointer at all, so it can never put one on a control. That is what makes a passing injected click prove renderer wiring and not reachability, which is the whole reason this paragraph exists. No code change: a re-anchor lifting click-through is correct -- the pointer IS over the bar once the bar has moved under it. (cherry picked from commit ed57790de8722b6e70d2df191d7d32b58d30e2d9) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6bc4910f..91483965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 820×560 at construction, then resized to fit its content — measured 904×698 with the bar at the bottom and mostly empty reserve above it). It is **click-through** (`setIgnoreMouseEvents(ignore)`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. -- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What lifts the input-transparency is therefore a change in the *OS cursor position*, which is precisely why CDP-injected input never does it, on any platform: Playwright's `.click()`, `javascript_tool`-dispatched pointer events, and anything else synthesised into the renderer arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) +- **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What the poll keys off is the OS cursor's position *relative to the window*, so a resize or re-anchor that slides the bar under a motionless pointer produces a fresh sample too. What it can never key off is synthesised input: Playwright's `.click()`, `javascript_tool`-dispatched pointer events and everything like them move no pointer at all, so they never put one on a control. They arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). **The tray icon** (bottom-right notification area) From f9fbe8f29f09de02f316c600408e2b38acb4a993 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:29:14 +0000 Subject: [PATCH 38/39] chore(release): bump to 1.9.6-rc.1 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03324d6b..28e747ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.5", + "version": "1.9.6-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.5", + "version": "1.9.6-rc.1", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 76f8c962..3c72ecea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.5", + "version": "1.9.6-rc.1", "type": "module", "packageManager": "npm@10.9.4", "engines": { From 585312df441739e0cc847ef23b60fc5efd9bb06f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:54:01 +0000 Subject: [PATCH 39/39] chore(release): bump to 1.9.6 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28e747ee..011d7b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.9.6-rc.1", + "version": "1.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.9.6-rc.1", + "version": "1.9.6", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 3c72ecea..5612797d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.6-rc.1", + "version": "1.9.6", "type": "module", "packageManager": "npm@10.9.4", "engines": {