diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts index 6702f66c..f126f6e3 100644 --- a/electron/ipc/cursor/interaction.test.ts +++ b/electron/ipc/cursor/interaction.test.ts @@ -11,7 +11,21 @@ vi.mock("electron", () => ({ }, })); -import { repairBundledUiohookBinaryForCurrentArch } from "./interaction"; +import { + repairBundledUiohookBinaryForCurrentArch, + shouldStartGlobalInteractionHook, +} from "./interaction"; + +describe("shouldStartGlobalInteractionHook", () => { + it("does not start the synchronous uiohook event tap on macOS", () => { + expect(shouldStartGlobalInteractionHook("darwin")).toBe(false); + }); + + it("keeps global interaction capture enabled on Windows and Linux", () => { + expect(shouldStartGlobalInteractionHook("win32")).toBe(true); + expect(shouldStartGlobalInteractionHook("linux")).toBe(true); + }); +}); describe("repairBundledUiohookBinaryForCurrentArch", () => { const tempRoots: string[] = []; @@ -68,4 +82,4 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => { expect(repaired).toBe(false); expect(await fs.readFile(buildPath, "utf8")).toBe("existing-build"); }); -}); \ No newline at end of file +}); diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 9b6f3ac9..2a82d991 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -172,6 +172,16 @@ function loadUiohookModule() { } } +export function shouldStartGlobalInteractionHook(platform: NodeJS.Platform = process.platform) { + // On macOS, uiohook can block forever while its native event tap starts + // (notably when Accessibility permission is unavailable or stale). Because + // start() executes synchronously, that freezes Electron's main thread and + // makes every window, including the recording HUD, unresponsive. Cursor + // position and visual-state telemetry still come from the existing native + // macOS monitor and Electron sampler. + return platform !== "darwin"; +} + export async function startInteractionCapture() { if (!isCursorCaptureActive) { return; @@ -181,6 +191,13 @@ export async function startInteractionCapture() { return; } + if (!shouldStartGlobalInteractionHook()) { + console.warn( + "[CursorTelemetry] Skipping the blocking global interaction hook on macOS.", + ); + return; + } + stopInteractionCapture(); try { diff --git a/electron/ipc/recording/mac.ts b/electron/ipc/recording/mac.ts index 01951345..57a9e688 100644 --- a/electron/ipc/recording/mac.ts +++ b/electron/ipc/recording/mac.ts @@ -77,6 +77,49 @@ export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStream }); } +export function waitForNativeCaptureCommand( + process: ChildProcessWithoutNullStreams, + marker: "Recording paused" | "Recording resumed", +) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ScreenCaptureKit helper: ${marker}`)); + }, 5000); + + let stdoutBuffer = ""; + const onStdout = (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + if (stdoutBuffer.includes(marker)) { + cleanup(); + resolve(); + } + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + `Native capture helper exited before ${marker.toLowerCase()} (code ${code ?? "unknown"})`, + ), + ); + }; + const cleanup = () => { + clearTimeout(timer); + process.stdout.off("data", onStdout); + process.off("error", onError); + process.off("exit", onExit); + }; + + process.stdout.on("data", onStdout); + process.once("error", onError); + process.once("exit", onExit); + }); +} + export function waitForNativeCaptureStop(process: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const onClose = (code: number | null) => { diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84..fa9b32f3 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -65,6 +65,7 @@ import { finalizeStoredVideo, muxNativeMacRecordingWithAudio, recoverNativeMacCaptureOutput, + waitForNativeCaptureCommand, waitForNativeCaptureStart, waitForNativeCaptureStop, } from "../recording/mac"; @@ -1305,7 +1306,12 @@ export function registerRecordingHandlers( } try { + const commandApplied = waitForNativeCaptureCommand( + nativeCaptureProcess, + "Recording paused", + ); nativeCaptureProcess.stdin.write("pause\n"); + await commandApplied; setNativeCapturePaused(true); return { success: true }; } catch (error) { @@ -1356,7 +1362,12 @@ export function registerRecordingHandlers( } try { + const commandApplied = waitForNativeCaptureCommand( + nativeCaptureProcess, + "Recording resumed", + ); nativeCaptureProcess.stdin.write("resume\n"); + await commandApplied; setNativeCapturePaused(false); return { success: true }; } catch (error) { diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 1e2a397a..7acf8e66 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -18,6 +18,10 @@ struct CaptureConfig: Codable { let targetCaptureFPS = 60 let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600) +/// How long finalization waits for a backed-up encoder queue before giving up on +/// the optional tail frame: 100 polls x 10 ms = 1 s. +let writerReadinessPollAttempts = 100 +let writerReadinessPollInterval: UInt64 = 10_000_000 final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private let queue = DispatchQueue(label: "recordly.screencapturekit.video") @@ -271,8 +275,6 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { lastVideoPresentationTime = .zero lastVideoDuration = .zero startWindowValidationIfNeeded() - print("Recording started") - fflush(stdout) } func stopCapture() async throws -> String { @@ -283,17 +285,33 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return try await finishCapture() } - func pauseCapture() { - guard isRecording, !isPaused else { return } - isPaused = true - pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) - pendingResumeAdjustment = false + func pauseCapture() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + guard self.isRecording, !self.isPaused else { + continuation.resume(returning: self.isRecording && self.isPaused) + return + } + self.isPaused = true + self.pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) + self.pendingResumeAdjustment = false + continuation.resume(returning: true) + } + } } - func resumeCapture() { - guard isRecording, isPaused else { return } - isPaused = false - pendingResumeAdjustment = true + func resumeCapture() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + guard self.isRecording, self.isPaused else { + continuation.resume(returning: self.isRecording && !self.isPaused) + return + } + self.isPaused = false + self.pendingResumeAdjustment = true + continuation.resume(returning: true) + } + } } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { @@ -309,7 +327,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return } - guard let videoInput = videoInput, videoInput.isReadyForMoreMediaData else { return } + guard let videoInput = videoInput, + assetWriter?.status == .writing, + videoInput.isReadyForMoreMediaData else { return } if firstSampleTime == .zero { firstSampleTime = sampleBuffer.presentationTimeStamp @@ -318,31 +338,38 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { lastSampleBuffer = sampleBuffer let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { - videoInput.append(retimedSampleBuffer) - lastVideoPresentationTime = presentationTime - lastVideoDuration = sampleBuffer.duration - frameCount += 1 + if videoInput.append(retimedSampleBuffer) { + lastVideoPresentationTime = presentationTime + lastVideoDuration = sampleBuffer.duration + frameCount += 1 + if frameCount == 1 { + // Signal readiness only after AVAssetWriter has accepted a + // real frame, so countdown warm-start cannot pause too early. + print("Recording started") + fflush(stdout) + } + } } return } if outputType == .audio { guard let systemAudioInput else { return } - appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime) + appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime) // Also write system audio to the inline video track if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { - appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) } return } if outputType.rawValue == microphoneOutputTypeRawValue { if let microphoneOnlyInput { - appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime) + appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime) } // Write mic to inline video track only if there's no system audio (avoids double-writing) if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData { - appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) + appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime) } return } @@ -370,7 +397,16 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { stream = nil isRecording = false - if let originalBuffer = lastSampleBuffer, let videoInput = videoInput { + // The tail frame only gives the last captured frame its full duration, so + // it must never put the file at risk. Appending to an input whose encoder + // queue is still backed up — routine after a long high-resolution capture — + // raises an Objective-C exception that Swift cannot catch, aborting the + // helper before `finishWriting()` and leaving an mdat with no moov atom: + // an unplayable recording. Wait briefly for the queue to drain, then skip + // the frame rather than lose the recording. + if let originalBuffer = lastSampleBuffer, + let videoInput = videoInput, + await waitUntilReady(videoInput, of: assetWriter) { let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer) let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { @@ -378,19 +414,29 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } } + // `endSession`, `markAsFinished` and `finishWriting` all raise when the + // writer is no longer in the `.writing` state (a mid-capture failure, for + // example a full disk), which would abort the helper the same way. let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero) let endTime = resolvedCaptureEndTime(videoEndTime: videoEndTime) - assetWriter?.endSession(atSourceTime: endTime) - videoInput?.markAsFinished() - inlineAudioInput?.markAsFinished() - await assetWriter?.finishWriting() + if let assetWriter, assetWriter.status == .writing { + assetWriter.endSession(atSourceTime: endTime) + videoInput?.markAsFinished() + inlineAudioInput?.markAsFinished() + await assetWriter.finishWriting() + } - systemAudioInput?.markAsFinished() - await systemAudioWriter?.finishWriting() + if let systemAudioWriter, systemAudioWriter.status == .writing { + systemAudioInput?.markAsFinished() + await systemAudioWriter.finishWriting() + } - microphoneOnlyInput?.markAsFinished() - await microphoneOnlyWriter?.finishWriting() + if let microphoneOnlyWriter, microphoneOnlyWriter.status == .writing { + microphoneOnlyInput?.markAsFinished() + await microphoneOnlyWriter.finishWriting() + } + let finalizeFailure: Error? = assetWriter.flatMap { $0.status == .completed ? nil : ($0.error ?? unfinalizedWriterError(status: $0.status)) } let path = outputURL?.path ?? "" assetWriter = nil videoInput = nil @@ -420,9 +466,42 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { capturesMicrophone = false writesSystemAudioToSeparateTrack = false writesMicrophoneToSeparateTrack = false + + // Report a half-written file as a failure instead of handing the editor a + // path it cannot decode. + if let finalizeFailure { + throw finalizeFailure + } + return path } + /// Waits briefly for an input's encoder queue to drain. Returns false when the + /// input stays backed up or its writer is no longer accepting data, in which + /// case the caller must skip the append: `AVAssetWriterInput.append` raises an + /// uncatchable Objective-C exception in both cases. + private func waitUntilReady(_ input: AVAssetWriterInput, of writer: AVAssetWriter?) async -> Bool { + guard let writer else { return false } + + var attemptsRemaining = writerReadinessPollAttempts + while writer.status == .writing { + if input.isReadyForMoreMediaData { + return true + } + guard attemptsRemaining > 0 else { return false } + attemptsRemaining -= 1 + try? await Task.sleep(nanoseconds: writerReadinessPollInterval) + } + + return false + } + + private func unfinalizedWriterError(status: AVAssetWriter.Status) -> Error { + NSError(domain: "RecordlyCapture", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Recording could not be finalized (writer status \(status.rawValue))", + ]) + } + private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { if isPaused { return nil @@ -497,8 +576,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension) } - private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) { - guard input.isReadyForMoreMediaData else { return } + private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, presentationTime: CMTime) { + // A writer that failed mid-capture (a full disk, say) raises on every + // further append, which would abort the helper and lose the whole file. + guard writer?.status == .writing, input.isReadyForMoreMediaData else { return } if firstSampleTime == nil { firstSampleTime = presentationTime @@ -596,47 +677,60 @@ final class RecorderService { private let queue = DispatchQueue(label: "recordly.screencapturekit.commands") private let completionGroup = DispatchGroup() + private func enqueue(_ operation: @escaping () async -> Void) { + queue.async { + let semaphore = DispatchSemaphore(value: 0) + Task { + await operation() + semaphore.signal() + } + semaphore.wait() + } + } + func start(configJSON: String) { completionGroup.enter() - queue.async { - Task { - do { - try await self.recorder.startCapture(configJSON: configJSON) - } catch { - fputs("Error starting capture: \(error.localizedDescription)\n", stderr) - fflush(stderr) - self.completionGroup.leave() - } + enqueue { + do { + try await self.recorder.startCapture(configJSON: configJSON) + } catch { + fputs("Error starting capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() } } } func stop() { - queue.async { - Task { - do { - let outputPath = try await self.recorder.stopCapture() - print("Recording stopped. Output path: \(outputPath)") - fflush(stdout) - self.completionGroup.leave() - } catch { - fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) - fflush(stderr) - self.completionGroup.leave() - } + enqueue { + do { + let outputPath = try await self.recorder.stopCapture() + print("Recording stopped. Output path: \(outputPath)") + fflush(stdout) + self.completionGroup.leave() + } catch { + fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) + fflush(stderr) + self.completionGroup.leave() } } } func pause() { - queue.async { - self.recorder.pauseCapture() + enqueue { + if await self.recorder.pauseCapture() { + print("Recording paused") + fflush(stdout) + } } } func resume() { - queue.async { - self.recorder.resumeCapture() + enqueue { + if await self.recorder.resumeCapture() { + print("Recording resumed") + fflush(stdout) + } } } @@ -715,4 +809,3 @@ DispatchQueue.global(qos: .utility).async { } service.waitUntilFinished() - diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 259f1884..0c64386f 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/windows.ts b/electron/windows.ts index 55f6314c..8a10981f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -290,9 +290,7 @@ function setHudOverlayMousePassthrough(ignore: boolean) { hudOverlayIgnoringMouse = hudOverlaySourceSelectionActive && !hudOverlayRecordingActive ? true - : hudOverlayRecordingActive - ? false - : ignore; + : ignore; if (hudOverlayMouseReassertTimer) { clearTimeout(hudOverlayMouseReassertTimer); @@ -306,8 +304,6 @@ function setHudOverlayMousePassthrough(ignore: boolean) { if (hudOverlayRecordingActive) { hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); - hudOverlayWindow.setIgnoreMouseEvents(false); - return; } if (!isHudOverlayMousePassthroughSupported()) { @@ -638,11 +634,6 @@ export function reassertHudOverlayMousePassthrough(): void { return; } - if (hudOverlayRecordingActive) { - hud.setIgnoreMouseEvents(false); - return; - } - // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully // re-initialised rather than merely re-asserted in a potentially broken state. hud.setIgnoreMouseEvents(false); @@ -661,7 +652,10 @@ export function setHudOverlayRecordingActive(recording: boolean): void { hudOverlayRecordingActive = Boolean(recording); hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); - setHudOverlayMousePassthrough(!hudOverlayRecordingActive); + // Start in passthrough mode. Forwarded pointer movement lets the renderer + // make the visible HUD controls interactive when the pointer reaches them, + // while transparent parts never block the recorded application. + setHudOverlayMousePassthrough(true); } export function createUpdateToastWindow(): BrowserWindow {