-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix(recording): publish Windows mic sidecar atomically #785
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"; | ||||||||||||||||||||||||
|
|
@@ -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)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -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) }; | ||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||
|
|
@@ -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 }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||
|
|
@@ -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", | ||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🧰 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. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| console.error("Failed to store microphone sidecar:", error); | ||||||||||||||||||||||||
| return { success: false, error: String(error) }; | ||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||
| clearCompanionAudioSidecarPending(videoPath); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
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
Add a regression test for a zero-byte
.wavcompanion.🧰 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