Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,11 @@ interface Window {
success: boolean;
paths: string[];
startDelayMsByPath?: Record<string, number>;
/**
* True while a companion audio sidecar for this recording is still being
* written, meaning `paths` may not list it yet.
*/
pending?: boolean;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
Expand Down
11 changes: 11 additions & 0 deletions electron/ipc/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ export const COMPANION_AUDIO_LAYOUTS = [
{ platform: "mac" as const, systemSuffix: ".system.webm", micSuffix: ".mic.webm" },
];

/**
* Suffix appended while a companion audio sidecar is still being produced.
*
* Encoders such as FFmpeg write media containers progressively and only patch
* their headers on completion, so a partially written file can still look like a
* complete, shorter recording. Staging under this suffix keeps in-progress files
* outside every sidecar discovery path until they are published with an atomic
* rename.
*/
export const INCOMPLETE_SIDECAR_SUFFIX = ".incomplete";

export const CURSOR_TELEMETRY_VERSION = 2;
export const CURSOR_SAMPLE_INTERVAL_MS = 33;
export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz
110 changes: 110 additions & 0 deletions electron/ipc/recording/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,57 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

type ExecFileCallback = (error: Error | null, stdout?: string, stderr?: string) => void;

const WAV_STREAMING_SIZE = 0xffffffff;

/**
* Builds a 48 kHz mono 16-bit WAV using the chunk layout FFmpeg emits
* (`fmt `, `LIST`, `data`). While FFmpeg is still encoding, the RIFF and data
* sizes hold the 0xFFFFFFFF "unknown length" sentinel and are only patched when
* the file is finalized.
*/
function buildWavFile(frameCount: number, { finalized }: { finalized: boolean }): Buffer {
const listPayload = Buffer.alloc(26);
listPayload.write("INFOISFT", 0, "ascii");
const pcm = Buffer.alloc(frameCount * 2);
const dataSize = finalized ? pcm.length : WAV_STREAMING_SIZE;

const header = Buffer.alloc(12 + 8 + 16 + 8 + listPayload.length + 8);
let offset = 0;
header.write("RIFF", offset, "ascii");
offset += 4;
header.writeUInt32LE(finalized ? header.length - 8 + pcm.length : WAV_STREAMING_SIZE, offset);
offset += 4;
header.write("WAVE", offset, "ascii");
offset += 4;
header.write("fmt ", offset, "ascii");
offset += 4;
header.writeUInt32LE(16, offset);
offset += 4;
header.writeUInt16LE(1, offset); // PCM
offset += 2;
header.writeUInt16LE(1, offset); // mono
offset += 2;
header.writeUInt32LE(48000, offset);
offset += 4;
header.writeUInt32LE(96000, offset); // byte rate
offset += 4;
header.writeUInt16LE(2, offset); // block align
offset += 2;
header.writeUInt16LE(16, offset); // bits per sample
offset += 2;
header.write("LIST", offset, "ascii");
offset += 4;
header.writeUInt32LE(listPayload.length, offset);
offset += 4;
listPayload.copy(header, offset);
offset += listPayload.length;
header.write("data", offset, "ascii");
offset += 4;
header.writeUInt32LE(dataSize, offset);

return Buffer.concat([header, pcm]);
}

describe("getCompanionAudioFallbackPaths", () => {
let tempRoot: string;
let appDataPath: string;
Expand Down Expand Up @@ -193,6 +244,65 @@ describe("getCompanionAudioFallbackPaths", () => {
});
});

it("ignores a microphone sidecar whose WAV header is not finalized yet", async () => {
const videoPath = path.join(tempRoot, "recording.mp4");
const systemPath = path.join(tempRoot, "recording.system.wav");
const micPath = path.join(tempRoot, "recording.mic.wav");

await Promise.all([
fs.writeFile(videoPath, "video"),
fs.writeFile(systemPath, buildWavFile(4800, { finalized: true })),
// Mid-encode: FFmpeg still advertises an unknown data size, so this file
// parses as a valid but far too short recording.
fs.writeFile(micPath, buildWavFile(4800, { finalized: false })),
]);

execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: Record<string, unknown>,
callback: ExecFileCallback,
) => {
const error = new Error("ffmpeg probe failed") as Error & { stderr?: string };
error.stderr = "Stream #0:0: Video: h264";
callback(error, "", error.stderr);
},
);

const { getCompanionAudioFallbackPaths } = await import("./diagnostics");

await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([systemPath]);

// Once the encoder finalizes the header the sidecar becomes usable.
await fs.writeFile(micPath, buildWavFile(4800, { finalized: true }));
await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([
systemPath,
micPath,
]);
});

it("classifies finalized, unfinalized and non-RIFF sidecars", async () => {
const { isFinalizedWavFile } = await import("./diagnostics");

const finalizedPath = path.join(tempRoot, "finalized.wav");
const streamingPath = path.join(tempRoot, "streaming.wav");
const notRiffPath = path.join(tempRoot, "not-riff.wav");
const missingPath = path.join(tempRoot, "missing.wav");

await Promise.all([
fs.writeFile(finalizedPath, buildWavFile(960, { finalized: true })),
fs.writeFile(streamingPath, buildWavFile(960, { finalized: false })),
fs.writeFile(notRiffPath, "mic"),
]);

await expect(isFinalizedWavFile(finalizedPath)).resolves.toBe(true);
await expect(isFinalizedWavFile(streamingPath)).resolves.toBe(false);
// Non-WAV companions (m4a/webm) and unreadable files are left to other checks.
await expect(isFinalizedWavFile(notRiffPath)).resolves.toBe(true);
await expect(isFinalizedWavFile(missingPath)).resolves.toBe(true);
});

it("scales audio mux timeout for long recordings", async () => {
const { getRecordingAudioMuxTimeoutMs } = await import("./diagnostics");

Expand Down
60 changes: 58 additions & 2 deletions electron/ipc/recording/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import type { FileHandle } from "node:fs/promises";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { COMPANION_AUDIO_LAYOUTS } from "../constants";
Expand Down Expand Up @@ -416,6 +417,62 @@ export async function writeRecordingDiagnosticsSnapshot(
return diagnosticsPath;
}

/**
* WAV writers that stream their output advertise an unknown `data` chunk size of
* 0xFFFFFFFF and only patch it in once the file is finalized. Such a file parses
* as a valid WAV that ends wherever the writer happens to have flushed, which is
* why a mid-write microphone sidecar used to surface in the editor as a recording
* that stops after a few seconds. Treat those files as not yet usable.
*/
const WAV_STREAMING_DATA_SIZE = 0xffffffff;
const WAV_HEADER_SCAN_BYTES = 4096;

export async function isFinalizedWavFile(filePath: string): Promise<boolean> {
let handle: FileHandle | null = null;
try {
handle = await fs.open(filePath, "r");
const buffer = Buffer.alloc(WAV_HEADER_SCAN_BYTES);
const { bytesRead } = await handle.read(buffer, 0, WAV_HEADER_SCAN_BYTES, 0);
if (bytesRead < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") {
// Not a RIFF/WAV container, so this check does not apply.
return true;
}

if (buffer.readUInt32LE(4) === WAV_STREAMING_DATA_SIZE) {
return false;
}

let offset = 12;
while (offset + 8 <= bytesRead) {
const chunkId = buffer.toString("ascii", offset, offset + 4);
const chunkSize = buffer.readUInt32LE(offset + 4);
if (chunkId === "data") {
return chunkSize !== WAV_STREAMING_DATA_SIZE;
}
if (chunkSize === 0) {
break;
}
offset += 8 + chunkSize + (chunkSize % 2);
}

// Header is larger than the scanned window; assume the file is usable.
return true;
} catch {
return true;
} finally {
await handle?.close().catch(() => undefined);
}
}

async function isUsableCompanionAudioFile(companionPath: string): Promise<boolean> {
const stat = await fs.stat(companionPath);
if (stat.size <= 0) {
return false;
}

return companionPath.toLowerCase().endsWith(".wav") ? isFinalizedWavFile(companionPath) : true;
Comment on lines +467 to +473

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep zero-byte sidecars eligible for recovery.

Line 469 rejects zero-byte files before WAV finalization inspection. The fallback lookup therefore cannot return a zero-byte sidecar. This conflicts with the recovery contract in the PR objective.

Proposed fix
 const stat = await fs.stat(companionPath);
- if (stat.size <= 0) {
+ if (stat.size < 0) {
   return false;
 }

Add a regression test for a zero-byte .wav companion.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/recording/diagnostics.ts` around lines 467 - 473, Update
isUsableCompanionAudioFile so zero-byte companion files remain eligible for
recovery instead of being rejected by the stat.size check; preserve the existing
finalized-WAV validation for .wav files and the unconditional eligibility of
non-WAV files. Add a regression test covering a zero-byte .wav companion
returned by the fallback lookup.

}

export async function getUsableCompanionAudioCandidates(
videoPath: string,
): Promise<CompanionAudioCandidate[]> {
Expand All @@ -429,8 +486,7 @@ export async function getUsableCompanionAudioCandidates(

for (const companionPath of [systemPath, micPath]) {
try {
const stat = await fs.stat(companionPath);
if (stat.size > 0) {
if (await isUsableCompanionAudioFile(companionPath)) {
usablePaths.push(companionPath);
}
} catch {
Expand Down
4 changes: 4 additions & 0 deletions electron/ipc/recording/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AUTO_RECORDING_MAX_AGE_MS,
AUTO_RECORDING_RETENTION_COUNT,
COMPANION_AUDIO_LAYOUTS,
INCOMPLETE_SIDECAR_SUFFIX,
LEGACY_PROJECT_FILE_EXTENSIONS,
PROJECT_FILE_EXTENSION,
PROJECTS_DIRECTORY_NAME,
Expand Down Expand Up @@ -174,6 +175,9 @@ export async function pruneAutoRecordings(exemptPaths: string[] = []) {
COMPANION_AUDIO_LAYOUTS.flatMap((layout) => [
layout.systemSuffix,
layout.micSuffix,
// Staging files left behind if the app exited mid-conversion.
`${layout.systemSuffix}${INCOMPLETE_SIDECAR_SUFFIX}`,
`${layout.micSuffix}${INCOMPLETE_SIDECAR_SUFFIX}`,
]),
),
);
Expand Down
59 changes: 56 additions & 3 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "electron";
import { showCursor } from "../../cursorHider";
import { getMonitorHandles } from "../monitorResolver";
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import { ALLOW_RECORDLY_WINDOW_CAPTURE, INCOMPLETE_SIDECAR_SUFFIX } from "../constants";
import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds";
import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor";
Expand Down Expand Up @@ -171,6 +171,33 @@ async function writeWindowsRecordingDiagnostics(
}
}

/**
* Recordings whose companion audio sidecar is still being written.
*
* The editor opens as soon as capture stops, while converting the microphone
* sidecar takes a few more seconds. Sidecars are only published once complete,
* so a lookup during that window legitimately reports no microphone track. This
* set lets the editor learn that a track is still on its way and re-check,
* instead of depending solely on the session-changed broadcast.
*/
const pendingCompanionAudioSidecars = new Set<string>();

function getCompanionAudioSidecarKey(videoPath: string) {
return path.resolve(videoPath).toLowerCase();
}

function markCompanionAudioSidecarPending(videoPath: string) {
pendingCompanionAudioSidecars.add(getCompanionAudioSidecarKey(videoPath));
}

function clearCompanionAudioSidecarPending(videoPath: string) {
pendingCompanionAudioSidecars.delete(getCompanionAudioSidecarKey(videoPath));
}

function isCompanionAudioSidecarPending(videoPath: string) {
return pendingCompanionAudioSidecars.has(getCompanionAudioSidecarKey(videoPath));
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
Expand Down Expand Up @@ -1396,7 +1423,12 @@ export function registerRecordingHandlers(
rememberApprovedLocalReadPath(videoPath),
...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)),
]);
return { success: true, paths, startDelayMsByPath };
return {
success: true,
paths,
startDelayMsByPath,
pending: isCompanionAudioSidecarPending(videoPath),
};
} catch (error) {
console.error("Failed to resolve companion audio fallback paths:", error);
return { success: false, paths: [], startDelayMsByPath: {}, error: String(error) };
Expand Down Expand Up @@ -1617,9 +1649,19 @@ export function registerRecordingHandlers(
) => {
const baseName = videoPath.replace(/\.[^.]+$/, "");
const sidecarPath = `${baseName}.mic.wav`;
// FFmpeg writes WAV data progressively and only patches the RIFF/data sizes
// when it finalizes the file. A reader that opens the output while the encode
// is still running therefore sees a valid-looking WAV that ends early, because
// the unfinalized `data` chunk advertises 0xFFFFFFFF ("read until EOF").
// The editor opens right after capture stops, several seconds before this
// conversion finishes, so encoding straight to `sidecarPath` made it load a
// truncated microphone track. Encode to a staging path that no consumer scans
// for, then publish it with a single atomic rename.
const stagingSidecarPath = `${sidecarPath}${INCOMPLETE_SIDECAR_SUFFIX}`;
const sourceWebmPath = `${baseName}.mic.source.webm`;
const tempWebmPath = `${sourceWebmPath}.tmp`;

markCompanionAudioSidecarPending(videoPath);
try {
await fs.writeFile(tempWebmPath, Buffer.from(audioData));
await execFileAsync(
Expand All @@ -1643,7 +1685,11 @@ export function registerRecordingHandlers(
].join(","),
"-c:a",
"pcm_s16le",
sidecarPath,
// The staging filename intentionally does not end in .wav, so the
// container has to be stated explicitly instead of inferred.
"-f",
"wav",
stagingSidecarPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
Expand Down Expand Up @@ -1712,6 +1758,8 @@ export function registerRecordingHandlers(
};
if (Object.keys(metadata).length > 0) {
try {
// Written before the sidecar is published so a consumer that
// discovers the microphone track always finds its timing metadata.
await fs.writeFile(`${sidecarPath}.json`, JSON.stringify(metadata));
} catch (metadataError) {
console.warn(
Expand All @@ -1720,6 +1768,8 @@ export function registerRecordingHandlers(
);
}
}
// Publish atomically: consumers only ever observe the finalized file.
await moveFileWithOverwrite(stagingSidecarPath, sidecarPath);
await writeRecordingDiagnosticsSnapshot(videoPath, {
backend: "browser-store",
phase: "mic-sidecar",
Expand All @@ -1740,10 +1790,13 @@ export function registerRecordingHandlers(
} catch (error) {
await Promise.all([
fs.rm(tempWebmPath, { force: true }).catch(() => undefined),
fs.rm(stagingSidecarPath, { force: true }).catch(() => undefined),
fs.rm(sidecarPath, { force: true }).catch(() => undefined),
]);
Comment on lines 1791 to 1795

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Remove the metadata sidecar during conversion cleanup.

Line 1763 can create ${sidecarPath}.json before moveFileWithOverwrite fails. This cleanup leaves that metadata behind. A later retry without metadata can then use stale timing data for the new sidecar.

Proposed fix
 await Promise.all([
   fs.rm(tempWebmPath, { force: true }).catch(() => undefined),
   fs.rm(stagingSidecarPath, { force: true }).catch(() => undefined),
   fs.rm(sidecarPath, { force: true }).catch(() => undefined),
+  fs.rm(`${sidecarPath}.json`, { force: true }).catch(() => undefined),
 ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await Promise.all([
fs.rm(tempWebmPath, { force: true }).catch(() => undefined),
fs.rm(stagingSidecarPath, { force: true }).catch(() => undefined),
fs.rm(sidecarPath, { force: true }).catch(() => undefined),
]);
await Promise.all([
fs.rm(tempWebmPath, { force: true }).catch(() => undefined),
fs.rm(stagingSidecarPath, { force: true }).catch(() => undefined),
fs.rm(sidecarPath, { force: true }).catch(() => undefined),
fs.rm(`${sidecarPath}.json`, { force: true }).catch(() => undefined),
]);
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import type { ChildProcessWithoutNullStreams } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/register/recording.ts` around lines 1791 - 1795, Update the
conversion cleanup Promise.all near moveFileWithOverwrite to also remove the
metadata sidecar created from sidecarPath. Delete the ${sidecarPath}.json
artifact with forced, non-failing cleanup alongside tempWebmPath,
stagingSidecarPath, and sidecarPath, preserving cleanup behavior on retries.

console.error("Failed to store microphone sidecar:", error);
return { success: false, error: String(error) };
} finally {
clearCompanionAudioSidecarPending(videoPath);
}
},
);
Expand Down
Loading