From 8f9890b820b98d4c30b822de3ea9388d59d0b2a9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 16:34:04 +0200 Subject: [PATCH 1/2] fix(macos): recover the take a dead writer or a killed helper left on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a macOS stop fails — the writer died, or the helper was killed — the file on disk is a fragmented MP4 that ffmpeg, libavformat, Chromium and the editor all open as it is. The take was still reported as lost. This keeps it. electron/recording/nativeMacCaptureSalvage.ts walks the file's top-level boxes by their own offsets and counts the video frames whose bytes are actually in the file: the flat sample table the moov holds for the first second, then every fragment's trun, with tfhd/trex defaults. It does not use mp4box. PR #571 did, in 1 MiB chunks, and AVAssetWriter's last mdat carries a size field of 0, so mp4box aborted whenever a chunk boundary fell inside it and a take was kept or lost depending on its byte length; it also accepted a file cut inside a moof, which nothing can open. A torn tail — a moof, or a header, cut short — is truncated to where it starts and the file inspected again, so success always means the file opens as it is on disk. A final mdat cut short is the fragment still being written and is left alone. The stop handler only salvages when the helper has exited (a running helper may still be writing), then takes the normal save path: cursor telemetry, session manifest and media links are written, the result carries `recovered`, and the warning names how much was kept ("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."). The warning reaches the editor and the CLI through the session hand-off from #661. Fixtures are five real takes from the helper with every mdat payload zeroed and gzipped (3-44 KB): two writer deaths, two killed helpers, one clean flat take. The frame counts the tests expect are ffmpeg's on the original files, and the torn cuts reproduce layouts ffmpeg refuses (cut inside a moof) and accepts once cut back. --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 69 ++- .../macos-fmp4/clean-flat-8s.mp4.gz | Bin 0 -> 11658 bytes .../macos-fmp4/helper-killed-29s.mp4.gz | Bin 0 -> 43959 bytes .../macos-fmp4/helper-killed-9s-main.mp4.gz | Bin 0 -> 14760 bytes .../macos-fmp4/writer-died-1s-no-moof.mp4.gz | Bin 0 -> 3028 bytes .../macos-fmp4/writer-died-4s.mp4.gz | Bin 0 -> 7271 bytes .../recording/nativeMacCaptureSalvage.test.ts | 219 ++++++++ electron/recording/nativeMacCaptureSalvage.ts | 505 ++++++++++++++++++ 9 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/helper-killed-9s-main.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/writer-died-4s.mp4.gz create mode 100644 electron/recording/nativeMacCaptureSalvage.test.ts create mode 100644 electron/recording/nativeMacCaptureSalvage.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 838ef6e77..dce4db3b7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -181,6 +181,8 @@ interface Window { discarded?: boolean; /** The take ended before it was stopped, but its recording was kept. */ warning?: string; + /** The stop failed and the recording was recovered from what was on disk. */ + recovered?: boolean; error?: string; }>; attachNativeMacWebcamRecording: (payload: { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5837502da..409c162dd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -81,6 +81,10 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; +import { + describeSalvagedTake, + salvageNativeMacCapture, +} from "../recording/nativeMacCaptureSalvage"; import { type NativeMacCaptureExit, nativeMacDiscardTargets, @@ -3230,26 +3234,50 @@ export function registerIpcHandlers( } return { success: true, discarded: true }; } - if (!stopResult.ok) { - pendingCursorRecordingData = null; - console.error("Failed to stop native macOS recording:", { - reason: stopResult.reason, - message: stopResult.message, - helperExited: stopResult.exited, - output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), - }); - return { success: false, error: stopResult.message }; - } - const screenVideoPath = stopResult.screenVideoPath; - nativeMacRecordingWarning = stopResult.warning - ? { screenVideoPath, message: stopResult.warning } - : null; - if (stopResult.warning) { - console.warn("[native-sck] the take ended before it was stopped; its recording was kept", { - warning: stopResult.warning, + let screenVideoPath: string; + let warning: string | undefined; + let recovered = false; + if (stopResult.ok) { + screenVideoPath = stopResult.screenVideoPath; + warning = stopResult.warning; + if (warning) { + console.warn( + "[native-sck] the take ended before it was stopped; its recording was kept", + { + warning, + path: screenVideoPath, + }, + ); + } + } else { + // A helper that exited left a file nothing writes to any more, and what its + // writer finished before the failure is usually a playable fragmented take. + // One still running may be mid-write, so it is left alone. + const salvage = + stopResult.exited && preferredPath ? await salvageNativeMacCapture(preferredPath) : null; + if (!salvage || !salvage.ok) { + pendingCursorRecordingData = null; + console.error("Failed to stop native macOS recording:", { + reason: stopResult.reason, + message: stopResult.message, + helperExited: stopResult.exited, + salvage: salvage ? salvage.reason : "not attempted: the helper had not exited", + output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), + }); + return { success: false, error: stopResult.message }; + } + screenVideoPath = salvage.screenVideoPath; + warning = describeSalvagedTake(stopResult.message, salvage.durationSec); + recovered = true; + console.warn("[native-sck] recovered the part of the take written before its stop failed", { + stopFailure: stopResult.message, path: screenVideoPath, + videoSamples: salvage.videoSamples, + durationSec: salvage.durationSec, + truncatedBytes: salvage.truncatedBytes, }); } + nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); @@ -3276,8 +3304,11 @@ export function registerIpcHandlers( success: true, path: screenVideoPath, session, - message: "Native macOS recording session stored successfully", - ...(stopResult.warning ? { warning: stopResult.warning } : {}), + message: recovered + ? "Native macOS recording recovered from a failed stop" + : "Native macOS recording session stored successfully", + ...(warning ? { warning } : {}), + ...(recovered ? { recovered: true } : {}), }; } catch (error) { console.error("Failed to stop native macOS recording:", error); diff --git a/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..2cae6a9536aeab66d86acbe19a12449c9a85a41e GIT binary patch literal 11658 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 a3AWnP&apdrFm1CQw|79NqfFkZedoUni*~L6 literal 0 HcmV?d00001 diff --git a/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..5bf2fbcba5d6766ef0e1f4c4740474411b538503 GIT binary patch literal 43959 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 E9}39UkpKVy literal 0 HcmV?d00001 diff --git a/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..fb246582d620bf0d58081b362452eee7bb013d3f GIT binary patch literal 3028 zcmb2|=3oE;Cg!(S4`#=>N-%u5nL8_Io9pCBNfTB$c8RU=+RA>hX=>A#3tDn(nu0t& zo8`X9Gk=p8bi3K@Kcn}--EwObw44rh2idGpul}&8LqX$_C%0Tn)6`#kx97i{W!5P7 z<;(Ah>p$!db;)5n{{!vH{Z(Uy8SELe*J9Qu{m?2 zXWrj?uJFjS;)4?X>_ zT4~<9w7ri$p7qN~S$ywLO7gd#3yn`aUbktlRBd{j--C}PMbAQ3>BRWFT(T#{wI?Lr-A1_5ruSB}X766{k9ki%zCHd(b)~+0oywc#zzvYEee|D_iPb2aytXqwe2@;y8KqWjW{*j@|y@5knB zm0#3r_}F;jf3dgLhH6QtFaGeie0pi_V<>wz*_; z(VK14{nrKe`Rk^4|E$w)KUFm6#kbQR#k@8eMNWIP(RAq#r|iu0cX*@MFY8@myyzGl}tH>$3T`s>=Qk0m8Qd$D3IKd!9<8Ifo z^UJZ_G|voKk~zQOx6mQq=FQ0zmHZZp0wp3LDgh}1!si~hpYDhK1Mb25hxZTfpI(pW z`~7&l!KH6*`(JI}u{?<`LnZEWd-ahWlS!JH&Y6+dW$}U=Bi@bM{RI{rfBkUi%bGWy z#&(DEX5@?n1_OWFE!v0%AC0`UKRB-ZEni%_=gI37Pp^P~|CJVdZckZUz^7nOKQH*X zc4y}5i~z#Po-c(lVe7984Tf5xRG?Mj;&BBJ$>JC5FS6JP2c~u%BFn!fvq3#6U4qFT z9jE$@U!u91OTZ=I64)XFo8WSkY&9RT{}3n1JH;_n3L_WS{l4L7zU12}IM8;H_k53! zzafp*_psuQXhJ^fNBx2s9|d8yNge*2UGm3{a|k}Nsw>HRh~uVI40w7x7v;T9K9OzBjcy7j-E zbl?3LA^&LyVgRiS9zEOBYj&@UsXj5DdVdf?(Gqe2_<~v@&>*f2Lo$;VNy0%=|Bqsp z>YWz@5I-ZjyEY08tln*2ZRCs}(@GzmR(d)9QDab6uJg~G4oKqj^$UJ&LOD2G`1$2q zxX%ZT+@J8&4*-Wm(Dx@x`6+4^kex;emS6Mnr)$)$MwCS37J5Pil^0i~2>SFj)hKBJ zCd0HhZ;k8D79O@Z)Vc>~dL9`;Qt!aMex|>WV=ji(<7bcU2AfagzghI_cVzn@x)rmH=v!)zPK()_+#`Hq=4E9K%3fK)p@Has*uW z_zbAQVpxI-U&CzGxU(kR3q;q86UtKkGUhQe8|R1Z@YmFgxtKb|-WA!{ys)N$1tKm1 zCF^D^xu3t0d`##Qwm1cNu4u4%BV)v|+!Aj~k~H@+`@#(C;Karl(ILoc8>V#w+Y1z* zifsF)cIF;gZ$K0#a!6U-8_B7?b`ns&eT&?&E2c}pCEybHb%A#F!wV}3nD_p3m3pP4 zvXt{3*l+`5U(=20Ojip1N^Y62j)^Q-<=;qP z;7F9r!}L9s02nrfPOi5DafcJmTfP*X(sRgUqqDaNTB|p~GP7Hb3Ns2Ge$h%o5gA)Es5INZqWIm0JNRikH z`2H8{&Pd||5BeQiu z;L|9E%4o=EV)Dg-2=t%_!o&==<_7|u1g0#>G%zGctZjB;>(i2(t?AsL5lpb%dXXZ{ zfK{yxHWQpVM)sYUfZVu@wxX;YsTf~S{|;3VJC?hVo|Z*Zs+kMakE`vly?Pm4ljlZs zbk3nuU$bD*sxq~XPn$JcbF76z@tBqXjMjlE$1N7EQ6$j@mEMR6vZ>N}$7TtFQjL!+dcCr*D^X6&U4>l2Cld+0hhp*695?8 z6{B1p|CYPDa+ZZ1t#ax!izwq!GiU6jnh7hkz$tHUTB;{lxyiJfZB4!3IN>e(&J29a zh~nD2N7fuaE1Kk%`W27vSw8I}4XkTUWXr~r03ul)R@~OZwM0NI@Lp-cP{_oCltvmv zW5G$I_E(u@>L*$+oO52uz4eVrRuS?Tii&kG4;UU7Mre`_ { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "native-mac-salvage-")); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +/** A private copy of a fixture, optionally cut to its first `bytes` bytes. */ +async function take(fixture: string, bytes?: number) { + const data = gunzipSync(await fs.readFile(path.join(FIXTURES, `${fixture}.mp4.gz`))); + const target = path.join( + dir, + `${fixture}-${bytes ?? "whole"}-${Math.random().toString(36).slice(2)}.mp4`, + ); + await fs.writeFile(target, bytes === undefined ? data : data.subarray(0, bytes)); + return target; +} + +async function sizeOf(filePath: string) { + return (await fs.stat(filePath)).size; +} + +describe("inspectNativeMacCapture on real helper output", () => { + it.each([ + ["writer-died-4s", 231, 4], + ["writer-died-1s-no-moof", 58, 1], + ["helper-killed-29s", 1652, 29], + ["helper-killed-9s-main", 513, 9], + ["clean-flat-8s", 461, 8], + ])("finds every frame ffmpeg reads in %s", async (fixture, frames, seconds) => { + const inspection = await inspectNativeMacCapture(await take(fixture)); + + expect(inspection).toMatchObject({ ok: true, videoSamples: frames }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + expect(Math.abs(inspection.durationSec - seconds)).toBeLessThan(0.2); + }); + + /** + * #571's check rejected this file at random: its last `mdat` has a size field of 0, + * and mp4box aborted whenever a read-chunk boundary fell inside it. + */ + it("reads a take whose last mdat declares size 0, with no read-size dependence", async () => { + const file = await take("helper-killed-29s", KILLED_29S.tailMdat + 8); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + }); + }); + + /** The fragment still being written when the take died: a final mdat cut short. */ + it("accepts a final mdat cut short, keeping the fragments indexed before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof - 1000); + const inspection = await inspectNativeMacCapture(file); + expect(inspection).toMatchObject({ ok: true, videoSamples: 1596 }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + }); + + it("marks where a torn moof starts without changing the file", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + const inspection = await inspectNativeMacCapture(file); + + expect(inspection).toMatchObject({ + ok: true, + validBytes: KILLED_29S.lastMoof, + videoSamples: 1596, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof + 300); + }); + + it("rejects a file whose movie header is torn", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "no movie header in the readable part of the file", + }); + }); + + it("rejects a file cut before its movie header", async () => { + const file = await take("helper-killed-29s", 600_000); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + }); + + it("rejects something that is not an MP4 at all", async () => { + const file = path.join(dir, "notes.txt"); + await fs.writeFile(file, "this is not a recording\n".repeat(10)); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "not an MP4 file", + }); + }); + + it("rejects a file that is not there", async () => { + await expect(inspectNativeMacCapture(path.join(dir, "missing.mp4"))).resolves.toMatchObject({ + ok: false, + }); + }); +}); + +describe("salvageNativeMacCapture", () => { + it("leaves a readable take exactly as it is", async () => { + const file = await take("writer-died-4s"); + const before = await sizeOf(file); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + screenVideoPath: file, + videoSamples: 231, + truncatedBytes: 0, + }); + expect(await sizeOf(file)).toBe(before); + }); + + /** + * A file cut inside a moof opens nowhere (ffmpeg, libavformat and Chromium all + * refuse it), though mp4box accepts it. Cut back to that moof it opens with every + * earlier fragment: ffmpeg reads 1596 frames from exactly this cut. + */ + it("cuts a torn last moof off and keeps every fragment before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1596, + truncatedBytes: 300, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof); + }); + + it("keeps the first second when the first moof is the torn one", async () => { + const file = await take("helper-killed-29s", KILLED_29S.firstMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 58, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.firstMoof); + }); + + it("cuts off a box header left half-written at the end", async () => { + const file = await take("helper-killed-29s", KILLED_29S.wide + 4); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + truncatedBytes: 4, + }); + }); + + /** Nothing is cut from a file that cannot be recovered anyway. */ + it("does not touch a file it cannot recover", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + expect(await sizeOf(file)).toBe(KILLED_29S.moov + 500); + }); +}); + +describe("describeSalvagedTake", () => { + it("quotes the readable part of an NSError", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x1 {Error Domain=NSPOSIXErrorDomain Code=28}}).', + 35.01, + ), + ).toBe("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."); + }); + + it("uses the whole message when there is no NSError in it", () => { + expect(describeSalvagedTake("The recorder stopped unexpectedly (signal SIGKILL).", 9)).toBe( + "Recording stopped after 0:09: The recorder stopped unexpectedly (signal SIGKILL). The part recorded until then was saved.", + ); + }); + + it("counts hours on a long take", () => { + expect(describeSalvagedTake("Disk Full", 3725)).toBe( + "Recording stopped after 1:02:05: Disk Full. The part recorded until then was saved.", + ); + }); +}); diff --git a/electron/recording/nativeMacCaptureSalvage.ts b/electron/recording/nativeMacCaptureSalvage.ts new file mode 100644 index 000000000..38485f32b --- /dev/null +++ b/electron/recording/nativeMacCaptureSalvage.ts @@ -0,0 +1,505 @@ +import fs from "node:fs/promises"; + +/** + * Recovering what a macOS take left on disk when its stop failed. + * + * # What a failed take leaves behind + * + * The helper's AVAssetWriter writes fragmented MP4 (`movieFragmentInterval` = 1 s). + * A clean stop rewrites it flat — `ftyp mdat moov` — but a writer that died, or a + * helper that was killed, leaves the fragmented shape as it was: + * + * ftyp mdat moov[… mvex] (mdat moof)* wide mdat + * + * The first `mdat` holds the first second, indexed by the `moov` sample table. + * Each later fragment writes its `mdat` BEFORE the `moof` that indexes it, with an + * absolute data offset (`tfhd` flag 0x1). The final `mdat` is the fragment that was + * still open, and no `moof` indexes it. ffmpeg, libavformat, Chromium and the editor + * all open such a file as it is, with the right duration (measured on real takes: + * 4 s, 10 s, 29 s, 73 s). + * + * # Why this is not mp4box + * + * PR #571 fed mp4box in 1 MiB chunks, and that last `mdat` has a size field of 0: + * whenever a multiple of the chunk size fell inside it, mp4box aborted with "Invalid + * box type", so whether a take was kept depended on its byte length. mp4box also + * accepted a file cut inside a `moof`, which nothing can open. This walk reads box + * headers at their own offsets, so the read size never matters. + * + * # The one layout that has to be repaired + * + * A file cut inside a box other than `mdat` (a torn `moof`) does not open anywhere. + * Cut back to the start of that box it opens, and keeps every fragment before it. + * So a torn tail is truncated and the file inspected again — a file is never + * reported recoverable while bytes that break it are still on disk. + */ + +/** Guards against a corrupt size field making a structural box look enormous. */ +const MAX_INDEX_BOX_BYTES = 64 * 1024 * 1024; + +type Box = { type: string; start: number; headerSize: number; size: number }; + +export type NativeMacCaptureInspection = + | { + ok: true; + fileBytes: number; + /** Bytes from the start of the file up to the first box that is torn or unreadable. */ + validBytes: number; + /** Video samples whose bytes lie entirely inside `validBytes`. */ + videoSamples: number; + durationSec: number; + fragments: number; + } + | { ok: false; reason: string; fileBytes: number; validBytes: number }; + +export type NativeMacSalvageResult = + | { + ok: true; + screenVideoPath: string; + videoSamples: number; + durationSec: number; + /** Bytes cut off a torn tail before the file could be opened; 0 when none. */ + truncatedBytes: number; + } + | { ok: false; reason: string }; + +function boxType(buffer: Buffer, offset: number) { + return buffer.toString("latin1", offset, offset + 4); +} + +function isPrintableType(type: string) { + return /^[\x20-\x7e]{4}$/.test(type); +} + +/** Complete child boxes of `[start, end)` inside an in-memory box. */ +function childBoxes(buffer: Buffer, start: number, end: number): Box[] { + const boxes: Box[] = []; + let position = start; + while (position + 8 <= end) { + let size = buffer.readUInt32BE(position); + const type = boxType(buffer, position + 4); + let headerSize = 8; + if (size === 1) { + if (position + 16 > end) { + break; + } + size = Number(buffer.readBigUInt64BE(position + 8)); + headerSize = 16; + } else if (size === 0) { + size = end - position; + } + if (size < headerSize || position + size > end) { + break; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return boxes; +} + +function childBox(buffer: Buffer, parent: Box, type: string) { + return childBoxes(buffer, parent.start + parent.headerSize, parent.start + parent.size).find( + (box) => box.type === type, + ); +} + +/** Offset of the first byte after a full box's version and flags. */ +function fullBoxBody(box: Box) { + return box.start + box.headerSize + 4; +} + +async function readAt(handle: fs.FileHandle, position: number, length: number) { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, position); + return buffer.subarray(0, bytesRead); +} + +type TopLevel = { boxes: Box[]; validBytes: number }; + +/** + * Walks the top-level boxes by their own offsets. A final `mdat` may run past the end + * of the file — that is the fragment still being written — but any other box that + * does, a header cut short, or bytes that are not a box header end the readable part + * where they start. + */ +async function walkTopLevel(handle: fs.FileHandle, fileBytes: number): Promise { + const boxes: Box[] = []; + let position = 0; + while (position < fileBytes) { + if (fileBytes - position < 8) { + return { boxes, validBytes: position }; + } + const header = await readAt(handle, position, 16); + const type = boxType(header, 4); + if (!isPrintableType(type)) { + return { boxes, validBytes: position }; + } + let size = header.readUInt32BE(0); + let headerSize = 8; + if (size === 1) { + if (header.length < 16) { + return { boxes, validBytes: position }; + } + size = Number(header.readBigUInt64BE(8)); + headerSize = 16; + } else if (size === 0) { + size = fileBytes - position; + } + if (size < headerSize) { + return { boxes, validBytes: position }; + } + if (position + size > fileBytes) { + if (type !== "mdat") { + return { boxes, validBytes: position }; + } + boxes.push({ type, start: position, headerSize, size: fileBytes - position }); + return { boxes, validBytes: fileBytes }; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return { boxes, validBytes: position }; +} + +type VideoTrack = { + trackId: number; + timescale: number; + defaultSampleDuration: number; + defaultSampleSize: number; +}; + +type SampleTally = { samples: number; duration: number }; + +/** Reads the video `trak`: its id, timescale and flat sample table, counting samples in the file. */ +function readVideoTrack( + moov: Buffer, + validBytes: number, +): { track: VideoTrack; flat: SampleTally } | null { + const root: Box = { type: "moov", start: 0, headerSize: 8, size: moov.length }; + for (const trak of childBoxes(moov, 8, moov.length).filter((box) => box.type === "trak")) { + const tkhd = childBox(moov, trak, "tkhd"); + const mdia = childBox(moov, trak, "mdia"); + if (!tkhd || !mdia) { + continue; + } + const hdlr = childBox(moov, mdia, "hdlr"); + if (!hdlr || boxType(moov, fullBoxBody(hdlr) + 4) !== "vide") { + continue; + } + const mdhd = childBox(moov, mdia, "mdhd"); + const minf = childBox(moov, mdia, "minf"); + const stbl = minf ? childBox(moov, minf, "stbl") : undefined; + const stsd = stbl ? childBox(moov, stbl, "stsd") : undefined; + if (!mdhd || !stbl || !stsd) { + continue; + } + // A visual sample entry: 8-byte header, 8 reserved/data-reference bytes, 16 + // pre-defined bytes, then width and height. + const entry = fullBoxBody(stsd) + 4; + if (moov.readUInt32BE(fullBoxBody(stsd)) === 0 || entry + 36 > stsd.start + stsd.size) { + continue; + } + const width = moov.readUInt16BE(entry + 32); + const height = moov.readUInt16BE(entry + 34); + if (width === 0 || height === 0) { + continue; + } + + const tkhdVersion = moov[tkhd.start + tkhd.headerSize]; + const trackId = moov.readUInt32BE(fullBoxBody(tkhd) + (tkhdVersion === 1 ? 16 : 8)); + const mdhdVersion = moov[mdhd.start + mdhd.headerSize]; + const timescale = moov.readUInt32BE(fullBoxBody(mdhd) + (mdhdVersion === 1 ? 16 : 8)); + if (timescale === 0) { + continue; + } + + let defaultSampleDuration = 0; + let defaultSampleSize = 0; + const mvex = childBox(moov, root, "mvex"); + if (mvex) { + for (const trex of childBoxes(moov, mvex.start + mvex.headerSize, mvex.start + mvex.size)) { + if (trex.type === "trex" && moov.readUInt32BE(fullBoxBody(trex)) === trackId) { + defaultSampleDuration = moov.readUInt32BE(fullBoxBody(trex) + 8); + defaultSampleSize = moov.readUInt32BE(fullBoxBody(trex) + 12); + } + } + } + + return { + track: { trackId, timescale, defaultSampleDuration, defaultSampleSize }, + flat: tallyFlatSamples(moov, stbl, validBytes), + }; + } + return null; +} + +function tallyFlatSamples(moov: Buffer, stbl: Box, validBytes: number): SampleTally { + const stsz = childBox(moov, stbl, "stsz"); + const stsc = childBox(moov, stbl, "stsc"); + const stco = childBox(moov, stbl, "stco") ?? childBox(moov, stbl, "co64"); + const stts = childBox(moov, stbl, "stts"); + if (!stsz || !stsc || !stco) { + return { samples: 0, duration: 0 }; + } + + const uniformSize = moov.readUInt32BE(fullBoxBody(stsz)); + const sampleCount = moov.readUInt32BE(fullBoxBody(stsz) + 4); + const sizeOf = (index: number) => + uniformSize !== 0 ? uniformSize : moov.readUInt32BE(fullBoxBody(stsz) + 8 + index * 4); + + const chunkCount = moov.readUInt32BE(fullBoxBody(stco)); + const chunkOffset = (index: number) => + stco.type === "co64" + ? Number(moov.readBigUInt64BE(fullBoxBody(stco) + 4 + index * 8)) + : moov.readUInt32BE(fullBoxBody(stco) + 4 + index * 4); + + const deltas: number[] = []; + if (stts) { + const entries = moov.readUInt32BE(fullBoxBody(stts)); + for (let entry = 0; entry < entries && deltas.length < sampleCount; entry += 1) { + const count = moov.readUInt32BE(fullBoxBody(stts) + 4 + entry * 8); + const delta = moov.readUInt32BE(fullBoxBody(stts) + 8 + entry * 8); + for (let index = 0; index < count && deltas.length < sampleCount; index += 1) { + deltas.push(delta); + } + } + } + + const runs = moov.readUInt32BE(fullBoxBody(stsc)); + let sample = 0; + const tally: SampleTally = { samples: 0, duration: 0 }; + for (let run = 0; run < runs && sample < sampleCount; run += 1) { + const firstChunk = moov.readUInt32BE(fullBoxBody(stsc) + 4 + run * 12); + const samplesPerChunk = moov.readUInt32BE(fullBoxBody(stsc) + 8 + run * 12); + const nextFirstChunk = + run + 1 < runs ? moov.readUInt32BE(fullBoxBody(stsc) + 4 + (run + 1) * 12) : chunkCount + 1; + for (let chunk = firstChunk; chunk < nextFirstChunk && chunk <= chunkCount; chunk += 1) { + let position = chunkOffset(chunk - 1); + for (let index = 0; index < samplesPerChunk && sample < sampleCount; index += 1) { + const size = sizeOf(sample); + if (position + size <= validBytes) { + tally.samples += 1; + tally.duration += deltas[sample] ?? 0; + } + position += size; + sample += 1; + } + } + } + return tally; +} + +/** Counts the video samples one `moof` indexes whose bytes are inside the file. */ +function tallyFragmentSamples( + moof: Buffer, + moofStart: number, + track: VideoTrack, + validBytes: number, +): SampleTally { + const tally: SampleTally = { samples: 0, duration: 0 }; + for (const traf of childBoxes(moof, 8, moof.length).filter((box) => box.type === "traf")) { + const children = childBoxes(moof, traf.start + traf.headerSize, traf.start + traf.size); + const tfhd = children.find((box) => box.type === "tfhd"); + if (!tfhd) { + continue; + } + const tfhdFlags = moof.readUInt32BE(tfhd.start + tfhd.headerSize) & 0xffffff; + if (moof.readUInt32BE(fullBoxBody(tfhd)) !== track.trackId) { + continue; + } + let cursor = fullBoxBody(tfhd) + 4; + let base = moofStart; + if (tfhdFlags & 0x1) { + base = Number(moof.readBigUInt64BE(cursor)); + cursor += 8; + } + if (tfhdFlags & 0x2) cursor += 4; + let defaultDuration = track.defaultSampleDuration; + if (tfhdFlags & 0x8) { + defaultDuration = moof.readUInt32BE(cursor); + cursor += 4; + } + let defaultSize = track.defaultSampleSize; + if (tfhdFlags & 0x10) { + defaultSize = moof.readUInt32BE(cursor); + } + + let dataPosition = base; + for (const trun of children.filter((box) => box.type === "trun")) { + const end = trun.start + trun.size; + const flags = moof.readUInt32BE(trun.start + trun.headerSize) & 0xffffff; + const count = moof.readUInt32BE(fullBoxBody(trun)); + let field = fullBoxBody(trun) + 4; + if (flags & 0x1) { + dataPosition = base + moof.readInt32BE(field); + field += 4; + } + if (flags & 0x4) field += 4; + const perSample = + (flags & 0x100 ? 4 : 0) + + (flags & 0x200 ? 4 : 0) + + (flags & 0x400 ? 4 : 0) + + (flags & 0x800 ? 4 : 0); + for (let index = 0; index < count && field + perSample <= end; index += 1) { + let duration = defaultDuration; + let size = defaultSize; + if (flags & 0x100) { + duration = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x200) { + size = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x400) field += 4; + if (flags & 0x800) field += 4; + if (dataPosition >= 0 && dataPosition + size <= validBytes) { + tally.samples += 1; + tally.duration += duration; + } + dataPosition += size; + } + } + } + return tally; +} + +/** What a capture file on disk can still give back, without changing it. */ +export async function inspectNativeMacCapture( + filePath: string, +): Promise { + let handle: fs.FileHandle | null = null; + let fileBytes = 0; + try { + handle = await fs.open(filePath, "r"); + fileBytes = (await handle.stat()).size; + const { boxes, validBytes } = await walkTopLevel(handle, fileBytes); + const fail = (reason: string): NativeMacCaptureInspection => ({ + ok: false, + reason, + fileBytes, + validBytes, + }); + + if (boxes[0]?.type !== "ftyp") { + return fail("not an MP4 file"); + } + const moovBox = boxes.find((box) => box.type === "moov"); + if (!moovBox) { + return fail("no movie header in the readable part of the file"); + } + if (moovBox.size > MAX_INDEX_BOX_BYTES) { + return fail("the movie header is implausibly large"); + } + const video = readVideoTrack(await readAt(handle, moovBox.start, moovBox.size), validBytes); + if (!video) { + return fail("no video track"); + } + + let samples = video.flat.samples; + let duration = video.flat.duration; + let fragments = 0; + for (const moof of boxes.filter((box) => box.type === "moof")) { + if (moof.size > MAX_INDEX_BOX_BYTES) { + continue; + } + fragments += 1; + const tally = tallyFragmentSamples( + await readAt(handle, moof.start, moof.size), + moof.start, + video.track, + validBytes, + ); + samples += tally.samples; + duration += tally.duration; + } + if (samples === 0) { + return fail("no complete video frame in the file"); + } + return { + ok: true, + fileBytes, + validBytes, + videoSamples: samples, + durationSec: duration / video.track.timescale, + fragments, + }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + fileBytes, + validBytes: 0, + }; + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * Makes a failed take's file openable if it can be, and says what it holds. + * + * Only for a file no process is writing to any more: the helper must have exited. + * A torn tail is cut off and the result inspected again, so success always means + * the file on disk opens as it is. + */ +export async function salvageNativeMacCapture(filePath: string): Promise { + const first = await inspectNativeMacCapture(filePath); + if (!first.ok) { + return { ok: false, reason: first.reason }; + } + if (first.validBytes === first.fileBytes) { + return { + ok: true, + screenVideoPath: filePath, + videoSamples: first.videoSamples, + durationSec: first.durationSec, + truncatedBytes: 0, + }; + } + + try { + await fs.truncate(filePath, first.validBytes); + } catch (error) { + return { + ok: false, + reason: `could not cut off the torn end: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const second = await inspectNativeMacCapture(filePath); + if (!second.ok) { + return { ok: false, reason: second.reason }; + } + if (second.validBytes !== second.fileBytes) { + return { ok: false, reason: "the file is still torn after cutting off its end" }; + } + return { + ok: true, + screenVideoPath: filePath, + videoSamples: second.videoSamples, + durationSec: second.durationSec, + truncatedBytes: first.fileBytes - second.fileBytes, + }; +} + +function formatDuration(seconds: number) { + const total = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const rest = String(total % 60).padStart(2, "0"); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; +} + +/** + * The warning for a take whose stop failed but whose file was recovered. + * + * The stop's message usually embeds an NSError; its localized description is the + * part a person can read ("Disk Full"). + */ +export function describeSalvagedTake(failureMessage: string, durationSec: number) { + const description = /NSLocalizedDescription=([^,}]+)/.exec(failureMessage)?.[1]?.trim(); + const reason = (description ?? failureMessage) + .replace(/^Recording stopped:\s*/i, "") + .replace(/[.\s]+$/, ""); + return `Recording stopped after ${formatDuration(durationSec)}: ${reason}. The part recorded until then was saved.`; +} From 746503f6a4f2a60f29395237b4274fb8817a370a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 16:52:06 +0200 Subject: [PATCH 2/2] fix(macos): keep a recovered take when the disk is still full, and say what failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the salvage found two defects. A recovered take most often follows a disk that filled up, and the cursor telemetry and session manifest are written to the same volume right after. Either write throwing ENOSPC reached the stop handler's catch, so a take whose video had just been recovered was reported lost with a raw errno. For a recovered take those writes are now secondary: a failure is logged and its partial file removed, and the stop still succeeds with the warning. The warning replaced the helper's sentence with the NSError's localized description. For the -16364 writer death that description is AVFoundation's generic "The operation could not be completed", so the user was never told the video file could not be written. The sentence is now kept, and a description is added only when it says something: "Recording stopped after 0:35: the video file could not be written (Disk Full)." The tests use the helper's verbatim messages instead of an invented one, and the length is rounded rather than floored (a 35 s take's frame durations sum to 34.98 s, which printed "0:34"). Also: whether to salvage is `nativeMacSalvageTarget`, tested, instead of an inline condition in the handler — salvage truncates, and the helper-exited check is what keeps it off a file still being written. The module doc no longer says every torn box breaks a file: a torn moof does; a half-written trailing header is cut as a precaution. --- electron/ipc/handlers.ts | 30 +++++++- .../recording/nativeMacCaptureSalvage.test.ts | 77 ++++++++++++++++++- electron/recording/nativeMacCaptureSalvage.ts | 60 ++++++++++++--- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 409c162dd..7b6d64d2d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -83,6 +83,7 @@ import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; import { describeSalvagedTake, + nativeMacSalvageTarget, salvageNativeMacCapture, } from "../recording/nativeMacCaptureSalvage"; import { @@ -3253,8 +3254,8 @@ export function registerIpcHandlers( // A helper that exited left a file nothing writes to any more, and what its // writer finished before the failure is usually a playable fragmented take. // One still running may be mid-write, so it is left alone. - const salvage = - stopResult.exited && preferredPath ? await salvageNativeMacCapture(preferredPath) : null; + const salvageTarget = nativeMacSalvageTarget(stopResult, preferredPath); + const salvage = salvageTarget ? await salvageNativeMacCapture(salvageTarget) : null; if (!salvage || !salvage.ok) { pendingCursorRecordingData = null; console.error("Failed to stop native macOS recording:", { @@ -3279,10 +3280,29 @@ export function registerIpcHandlers( } nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; + // A recovered take most often follows a disk that filled up, and these writes + // go to the same volume. The video is already safe on disk, so for a recovered + // take a failed side write is logged, and its partial file removed, instead of + // turning the recovery back into a lost take. + const writeAlongside = async (label: string, target: string, write: () => Promise) => { + if (!recovered) { + await write(); + return; + } + try { + await write(); + } catch (error) { + console.warn(`[native-sck] could not write the recovered take's ${label}:`, error); + await fs.rm(target, { force: true }).catch(() => undefined); + } + }; + if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); - await writePendingCursorTelemetry(screenVideoPath); + await writeAlongside("cursor telemetry", `${screenVideoPath}.cursor.json`, () => + writePendingCursorTelemetry(screenVideoPath), + ); } const session: RecordingSession = { @@ -3297,7 +3317,9 @@ export function registerIpcHandlers( RECORDINGS_DIR, `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); - await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await writeAlongside("session manifest", sessionManifestPath, () => + fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"), + ); await registerRecordingMediaLinks(screenVideoPath, { cursorCaptureMode }); return { diff --git a/electron/recording/nativeMacCaptureSalvage.test.ts b/electron/recording/nativeMacCaptureSalvage.test.ts index a39454131..1cf39180c 100644 --- a/electron/recording/nativeMacCaptureSalvage.test.ts +++ b/electron/recording/nativeMacCaptureSalvage.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { describeSalvagedTake, inspectNativeMacCapture, + nativeMacSalvageTarget, salvageNativeMacCapture, } from "./nativeMacCaptureSalvage"; @@ -176,6 +177,7 @@ describe("salvageNativeMacCapture", () => { expect(await sizeOf(file)).toBe(KILLED_29S.firstMoof); }); + /** Players skip these 4 bytes anyway; the cut is a precaution, and it loses nothing. */ it("cuts off a box header left half-written at the end", async () => { const file = await take("helper-killed-29s", KILLED_29S.wide + 4); @@ -195,14 +197,76 @@ describe("salvageNativeMacCapture", () => { }); }); +describe("nativeMacSalvageTarget", () => { + const TARGET = "/rec/recording-1.mp4"; + + it("salvages the requested file once the helper has exited", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + TARGET, + ), + ).toBe(TARGET); + }); + + /** Salvage truncates; a helper that did not exit may still be inside finishWriting. */ + it("never salvages a file a helper that did not exit may still be writing", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "stop-timeout", message: "x", exited: false }, + TARGET, + ), + ).toBeNull(); + }); + + it("has nothing to salvage after a stop that worked", () => { + expect(nativeMacSalvageTarget({ ok: true, screenVideoPath: TARGET }, TARGET)).toBeNull(); + }); + + it("has nothing to salvage when no file was requested", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + null, + ), + ).toBeNull(); + }); +}); + describe("describeSalvagedTake", () => { - it("quotes the readable part of an NSError", () => { + /** Verbatim from the helper, in the disk-full end-to-end run. */ + it("keeps the helper's sentence and adds a description that says something", () => { expect( describeSalvagedTake( - 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x1 {Error Domain=NSPOSIXErrorDomain Code=28}}).', + 'Recording stopped: the video file could not be written (writer status: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x9e326c930 {Error Domain=NSPOSIXErrorDomain Code=28 "No space left on device"}, NSLocalizedRecoverySuggestion=Make room by deleting existing files and try again., NSLocalizedFailureReason=There is not enough available space to continue the file writing.}).', 35.01, ), - ).toBe("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."); + ).toBe( + "Recording stopped after 0:35: the video file could not be written (Disk Full). The part recorded until then was saved.", + ); + }); + + /** The -16364 writer death this whole path started from; its description is generic. */ + it("does not replace the helper's sentence with a generic NSError description", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16364), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x1 {Error Domain=NSOSStatusErrorDomain Code=-16364 "(null)"}}).', + 73.01, + ), + ).toBe( + "Recording stopped after 1:13: the video file could not be written. The part recorded until then was saved.", + ); + }); + + it("reads a bare NSError by its description", () => { + expect( + describeSalvagedTake( + 'Error Domain=com.apple.ScreenCaptureKit.SCStreamErrorDomain Code=-3815 "The stream was stopped by the system." UserInfo={NSLocalizedDescription=The stream was stopped by the system.}', + 12, + ), + ).toBe( + "Recording stopped after 0:12: The stream was stopped by the system. The part recorded until then was saved.", + ); }); it("uses the whole message when there is no NSError in it", () => { @@ -211,6 +275,13 @@ describe("describeSalvagedTake", () => { ); }); + /** Measured: a 35 s disk-full take whose frame durations sum to 34.98 s. */ + it("rounds the length rather than cutting a second off it", () => { + expect(describeSalvagedTake("Disk Full", 34.983333)).toBe( + "Recording stopped after 0:35: Disk Full. The part recorded until then was saved.", + ); + }); + it("counts hours on a long take", () => { expect(describeSalvagedTake("Disk Full", 3725)).toBe( "Recording stopped after 1:02:05: Disk Full. The part recorded until then was saved.", diff --git a/electron/recording/nativeMacCaptureSalvage.ts b/electron/recording/nativeMacCaptureSalvage.ts index 38485f32b..90f3d6dc6 100644 --- a/electron/recording/nativeMacCaptureSalvage.ts +++ b/electron/recording/nativeMacCaptureSalvage.ts @@ -28,12 +28,16 @@ import fs from "node:fs/promises"; * * # The one layout that has to be repaired * - * A file cut inside a box other than `mdat` (a torn `moof`) does not open anywhere. - * Cut back to the start of that box it opens, and keeps every fragment before it. - * So a torn tail is truncated and the file inspected again — a file is never - * reported recoverable while bytes that break it are still on disk. + * A file cut inside a `moof` opens nowhere: ffmpeg, libavformat and Chromium all + * refuse it. Cut back to the start of that `moof` it opens, and keeps every fragment + * before it. So a torn tail is truncated and the file inspected again. The same cut + * is applied to anything else left unfinished at the end, such as a header of a few + * bytes, which players skip anyway: it costs nothing, and a file is never reported + * recoverable while bytes that could break it are still on disk. */ +import type { NativeMacCaptureStopResult } from "./nativeMacCaptureStop"; + /** Guards against a corrupt size field making a structural box look enormous. */ const MAX_INDEX_BOX_BYTES = 64 * 1024 * 1024; @@ -483,23 +487,57 @@ export async function salvageNativeMacCapture(filePath: string): Promise 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; } +/** + * The file a failed stop may be salvaged from, or null. + * + * Only once the helper has exited: a stop that timed out leaves a helper that may + * still be inside finishWriting, and salvaging truncates. + */ +export function nativeMacSalvageTarget( + result: NativeMacCaptureStopResult, + targetPath: string | null, +): string | null { + return !result.ok && result.exited && targetPath ? targetPath : null; +} + +/** NSError descriptions that say nothing a person can act on. */ +const GENERIC_ERROR_DESCRIPTIONS = new Set(["The operation could not be completed"]); + /** * The warning for a take whose stop failed but whose file was recovered. * - * The stop's message usually embeds an NSError; its localized description is the - * part a person can read ("Disk Full"). + * The helper's messages are a sentence followed by a raw NSError, e.g. "Recording + * stopped: the video file could not be written (video append: Error Domain=… + * NSLocalizedDescription=Disk Full …)". The sentence is kept; the NSError is + * reduced to its localized description, and only when that says something — + * AVFoundation's -11800 says "The operation could not be completed". */ export function describeSalvagedTake(failureMessage: string, durationSec: number) { - const description = /NSLocalizedDescription=([^,}]+)/.exec(failureMessage)?.[1]?.trim(); - const reason = (description ?? failureMessage) - .replace(/^Recording stopped:\s*/i, "") - .replace(/[.\s]+$/, ""); + const text = failureMessage.trim().replace(/^Recording stopped:\s*/i, ""); + const description = /NSLocalizedDescription=([^,}]+)/.exec(text)?.[1]?.trim(); + const usefulDescription = + description && !GENERIC_ERROR_DESCRIPTIONS.has(description) ? description : undefined; + + let reason = text; + const errorStart = text.indexOf("Error Domain="); + if (errorStart !== -1) { + const openParen = text.lastIndexOf("(", errorStart); + const sentence = text.slice(0, openParen === -1 ? errorStart : openParen).trim(); + if (sentence && usefulDescription) { + reason = `${sentence} (${usefulDescription})`; + } else { + reason = sentence || usefulDescription || description || "the recorder failed"; + } + } + reason = reason.replace(/[.\s]+$/, ""); return `Recording stopped after ${formatDuration(durationSec)}: ${reason}. The part recorded until then was saved.`; }