Merge pull request #827 from webadderallorg/fix/windows-wgc-countdown-warm-start

Fix native capture countdown startup lag
This commit is contained in:
webadderall
2026-08-24 17:20:56 +10:00
committed by GitHub
22 changed files with 1191 additions and 1418 deletions
+16 -2
View File
@@ -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");
});
});
});
+75 -57
View File
@@ -1,21 +1,26 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
setHasLoggedInteractionHookFailure,
interactionCaptureCleanup,
isCursorCaptureActive,
lastLeftClick,
setHasLoggedInteractionHookFailure,
setInteractionCaptureCleanup,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import type {
CursorInteractionType,
HookMouseEvent,
UiohookLike,
UiohookModuleNamespace,
} from "../types";
import {
getNormalizedCursorPoint,
getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
getNormalizedCursorPoint,
isCursorCapturePaused,
pushCursorSample,
} from "./telemetry";
@@ -172,6 +177,62 @@ 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 function recordCursorMouseDown(button: 1 | 2 | 3) {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = getCursorCaptureElapsedMs();
let interactionType: CursorInteractionType = "click";
if (button === 2) {
interactionType = "right-click";
} else if (button === 3) {
interactionType = "middle-click";
} else {
const thresholdMs = 350;
const distance = lastLeftClick
? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
: Number.POSITIVE_INFINITY;
if (lastLeftClick && timeMs - lastLeftClick.timeMs <= thresholdMs && distance <= 0.04) {
interactionType = "double-click";
}
setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
}
pushCursorSample(point.cx, point.cy, timeMs, interactionType);
}
export function recordCursorMouseUp() {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "mouseup");
}
export async function startInteractionCapture() {
if (!isCursorCaptureActive) {
return;
@@ -181,6 +242,11 @@ export async function startInteractionCapture() {
return;
}
if (!shouldStartGlobalInteractionHook()) {
console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS.");
return;
}
stopInteractionCapture();
try {
@@ -203,63 +269,15 @@ export async function startInteractionCapture() {
}
const onMouseDown = (event: HookMouseEvent) => {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = getCursorCaptureElapsedMs();
const button = getHookMouseButton(event);
let interactionType: CursorInteractionType = "click";
if (button === 2) {
interactionType = "right-click";
} else if (button === 3) {
interactionType = "middle-click";
} else {
const thresholdMs = 350;
const distance = lastLeftClick
? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
: Number.POSITIVE_INFINITY;
if (
lastLeftClick &&
timeMs - lastLeftClick.timeMs <= thresholdMs &&
distance <= 0.04
) {
interactionType = "double-click";
}
setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
}
pushCursorSample(point.cx, point.cy, timeMs, interactionType);
recordCursorMouseDown(getHookMouseButton(event));
};
const onMouseUp = () => {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = getCursorCaptureElapsedMs();
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
recordCursorMouseUp();
};
const onMouseMove = (event: HookMouseEvent) => {
if (
process.platform !== "linux" ||
!isCursorCaptureActive ||
isCursorCapturePaused()
) {
if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
+14 -2
View File
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { BrowserWindow } from "electron";
import type { CursorVisualType } from "../types";
import { ensureNativeCursorMonitorBinary, getCursorMonitorExePath } from "../paths/binaries";
import {
currentCursorVisualType,
nativeCursorMonitorOutputBuffer,
@@ -11,7 +11,8 @@ import {
setNativeCursorMonitorOutputBuffer,
setNativeCursorMonitorProcess,
} from "../state";
import { getCursorMonitorExePath, ensureNativeCursorMonitorBinary } from "../paths/binaries";
import type { CursorVisualType } from "../types";
import { recordCursorMouseDown, recordCursorMouseUp } from "./interaction";
export function emitCursorStateChanged(cursorType: CursorVisualType) {
BrowserWindow.getAllWindows().forEach((window) => {
@@ -27,6 +28,17 @@ export function handleCursorMonitorStdout(chunk: Buffer) {
setNativeCursorMonitorOutputBuffer(lines.pop() ?? "");
for (const line of lines) {
const interactionMatch = line.match(/^INTERACTION:(mousedown|mouseup)(?::([123]))?$/);
if (interactionMatch) {
if (interactionMatch[1] === "mouseup") {
recordCursorMouseUp();
} else {
const button = Number(interactionMatch[2]);
recordCursorMouseDown(button === 2 || button === 3 ? button : 1);
}
continue;
}
const match = line.match(/^STATE:(.+)$/);
if (!match) continue;
const next = match[1].trim() as CursorVisualType;
+43
View File
@@ -77,6 +77,49 @@ export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStream
});
}
export function waitForNativeCaptureCommand(
process: ChildProcessWithoutNullStreams,
marker: "Recording paused" | "Recording resumed",
) {
return new Promise<void>((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<string>((resolve, reject) => {
const onClose = (code: number | null) => {
+11
View File
@@ -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) {
+70 -1
View File
@@ -406,6 +406,76 @@ if CommandLine.arguments.contains("--export-images") {
exit(0)
}
func mouseInteractionCallback(
proxy: CGEventTapProxy,
type: CGEventType,
event: CGEvent,
refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
let action: String
let button: Int
switch type {
case .leftMouseDown:
action = "mousedown"
button = 1
case .leftMouseUp:
action = "mouseup"
button = 1
case .rightMouseDown:
action = "mousedown"
button = 2
case .rightMouseUp:
action = "mouseup"
button = 2
case .otherMouseDown:
guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
return Unmanaged.passUnretained(event)
}
action = "mousedown"
button = 3
case .otherMouseUp:
guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
return Unmanaged.passUnretained(event)
}
action = "mouseup"
button = 3
default:
return Unmanaged.passUnretained(event)
}
print("INTERACTION:\(action):\(button)")
fflush(stdout)
return Unmanaged.passUnretained(event)
}
let mouseEventTypes: [CGEventType] = [
.leftMouseDown,
.leftMouseUp,
.rightMouseDown,
.rightMouseUp,
.otherMouseDown,
.otherMouseUp,
]
let mouseEventMask = mouseEventTypes.reduce(CGEventMask(0)) { mask, type in
mask | (CGEventMask(1) << type.rawValue)
}
let mouseEventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mouseEventMask,
callback: mouseInteractionCallback,
userInfo: nil
)
if let mouseEventTap,
let eventTapSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, mouseEventTap, 0) {
CFRunLoopAddSource(CFRunLoopGetMain(), eventTapSource, .commonModes)
CGEvent.tapEnable(tap: mouseEventTap, enable: true)
} else {
fputs("Mouse interaction event tap unavailable; click telemetry disabled\n", stderr)
fflush(stderr)
}
var lastState = ""
func emitStateIfNeeded() {
let state = currentSystemCursorType()
@@ -433,4 +503,3 @@ DispatchQueue.global(qos: .utility).async {
}
RunLoop.main.run()
+259 -77
View File
@@ -18,8 +18,17 @@ 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 struct CaptureFinalizationResult {
let outputResult: Result<String, Error>
let interactiveStopParticipated: Bool
}
private let queue = DispatchQueue(label: "recordly.screencapturekit.video")
private var assetWriter: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
@@ -47,6 +56,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private var microphoneOutputURL: URL?
private var trackedWindowId: UInt32?
private var windowValidationTask: Task<Void, Never>?
private var isFinalizing = false
private var interactiveStopParticipated = false
private var finalizationWaiters: [CheckedContinuation<CaptureFinalizationResult, Never>] = []
private var inlineAudioInput: AVAssetWriterInput?
private var firstInlineAudioSampleTime: CMTime?
private var capturesSystemAudio = false
@@ -271,29 +283,40 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
lastVideoPresentationTime = .zero
lastVideoDuration = .zero
startWindowValidationIfNeeded()
print("Recording started")
fflush(stdout)
}
func stopCapture() async throws -> String {
guard isRecording else {
throw NSError(domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"])
let finalization = await finalizeCapture(interactive: true)
return try finalization.outputResult.get()
}
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)
}
}
return try await finishCapture()
}
func pauseCapture() {
guard isRecording, !isPaused else { return }
isPaused = true
pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock())
pendingResumeAdjustment = false
}
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 +332,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 +343,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
}
@@ -355,10 +387,64 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
fflush(stderr)
}
/// Starts one finalization operation after all previously delivered samples on
/// the recorder queue have drained. Manual stop and automatic window-close
/// detection join the same operation instead of racing the asset writers.
private func finalizeCapture(interactive: Bool) async -> CaptureFinalizationResult {
await withCheckedContinuation { continuation in
queue.async {
if self.isFinalizing {
self.interactiveStopParticipated = self.interactiveStopParticipated || interactive
self.finalizationWaiters.append(continuation)
return
}
guard self.isRecording else {
continuation.resume(returning: CaptureFinalizationResult(
outputResult: .failure(NSError(
domain: "RecordlyCapture",
code: 9,
userInfo: [NSLocalizedDescriptionKey: "No recording in progress"]
)),
interactiveStopParticipated: interactive
))
return
}
self.isFinalizing = true
self.interactiveStopParticipated = interactive
self.isRecording = false
self.windowValidationTask = nil
self.trackedWindowId = nil
self.finalizationWaiters.append(continuation)
Task {
let outputResult: Result<String, Error>
do {
outputResult = .success(try await self.finishCapture())
} catch {
outputResult = .failure(error)
}
self.queue.async {
let finalizationResult = CaptureFinalizationResult(
outputResult: outputResult,
interactiveStopParticipated: self.interactiveStopParticipated
)
let waiters = self.finalizationWaiters
self.finalizationWaiters.removeAll()
self.isFinalizing = false
self.interactiveStopParticipated = false
for waiter in waiters {
waiter.resume(returning: finalizationResult)
}
}
}
}
}
}
private func finishCapture() async throws -> String {
windowValidationTask?.cancel()
windowValidationTask = nil
trackedWindowId = nil
if let activeStream = stream {
do {
@@ -368,9 +454,17 @@ 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 +472,36 @@ 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, systemAudioWriter, microphoneOnlyWriter]
.compactMap { $0 }
.compactMap { writer in
writer.status == .completed
? nil
: (writer.error ?? unfinalizedWriterError(status: writer.status))
}
.first
let path = outputURL?.path ?? ""
assetWriter = nil
videoInput = nil
@@ -420,9 +531,48 @@ 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
do {
try await Task.sleep(nanoseconds: writerReadinessPollInterval)
} catch is CancellationError {
return false
} catch {
return false
}
}
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 +647,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
@@ -565,19 +717,31 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
if Task.isCancelled { return }
guard self.isRecording else { return }
let availableContent: SCShareableContent
do {
let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId })
if !windowStillAvailable {
print("WINDOW_UNAVAILABLE")
fflush(stdout)
let outputPath = try await self.finishCapture()
availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
} catch {
continue
}
let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId })
if !windowStillAvailable {
print("WINDOW_UNAVAILABLE")
fflush(stdout)
let finalization = await self.finalizeCapture(interactive: false)
if finalization.interactiveStopParticipated {
return
}
do {
let outputPath = try finalization.outputResult.get()
print("Recording stopped. Output path: \(outputPath)")
fflush(stdout)
exit(0)
} catch {
fputs("Error stopping capture: \(error.localizedDescription)\n", stderr)
fflush(stderr)
exit(1)
}
} catch {
continue
}
}
}
@@ -595,53 +759,70 @@ final class RecorderService {
private let recorder = ScreenCaptureRecorder()
private let queue = DispatchQueue(label: "recordly.screencapturekit.commands")
private let completionGroup = DispatchGroup()
private var succeeded = true
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 {
self.succeeded = false
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 {
self.succeeded = false
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)
}
}
}
func waitUntilFinished() {
func waitUntilFinished() -> Bool {
completionGroup.wait()
return succeeded
}
}
@@ -714,5 +895,6 @@ DispatchQueue.global(qos: .utility).async {
}
}
service.waitUntilFinished()
if !service.waitUntilFinished() {
exit(1)
}
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const recorderSource = readFileSync(
fileURLToPath(new URL("./ScreenCaptureKitRecorder.swift", import.meta.url)),
"utf8",
);
describe("ScreenCaptureKitRecorder finalization coordination", () => {
it("marks manual stops as participants in the shared finalization", () => {
expect(recorderSource).toContain("finalizeCapture(interactive: true)");
expect(recorderSource).toContain("finalization.outputResult.get()");
expect(recorderSource).toContain(
"self.interactiveStopParticipated = self.interactiveStopParticipated || interactive",
);
});
it("does not let automatic window-close exit preempt a joined manual stop", () => {
expect(recorderSource).toContain("self.finalizeCapture(interactive: false)");
expect(recorderSource).toMatch(
/if finalization\.interactiveStopParticipated\s*\{\s*return\s*\}/,
);
});
});
Binary file not shown.
@@ -5,24 +5,24 @@
"helpers": {
"wgc-capture": {
"binaryName": "wgc-capture.exe",
"binarySha256": "298b41f371c3881046061048b466e12ed70dd93fa761bade2fd57d1ccddf3cb9",
"binarySha256": "4d89fdff8e3343998c7a3b4d75d964c1f75f93594aa097c6681e477d25ec9f01",
"sourceDir": "electron/native/wgc-capture",
"sourceFingerprint": "6ee457080c27dc939ff4b61965f86b6d73995e40200440a1dc44865f9708d39f",
"updatedAt": "2026-05-24T19:49:15.077Z"
"sourceFingerprint": "c6dac250c9d16f7aa881998353441b4ae3fb59731b802e3b8491a136e79726b0",
"updatedAt": "2026-07-11T11:58:45.856Z"
},
"cursor-monitor": {
"binaryName": "cursor-monitor.exe",
"binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12",
"binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d",
"sourceDir": "electron/native/cursor-monitor",
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
"updatedAt": "2026-05-07T15:22:18.173Z"
"sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72",
"updatedAt": "2026-07-11T11:58:56.534Z"
},
"recordly-gpu-export": {
"binaryName": "recordly-gpu-export.exe",
"binarySha256": "4cb3a293fd36f718af55906820d9b3fd78babc855888c2e248f0b918ec1aff3c",
"binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f",
"sourceDir": "electron/native/gpu-export-probe",
"sourceFingerprint": "743b386a5f1bbcc99cec5465c3de228d2b045061dead31dfcbf25cf6a1e61de5",
"updatedAt": "2026-05-07T20:13:48.585Z"
"sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1",
"updatedAt": "2026-07-11T11:58:51.659Z"
},
"recordly-nvidia-cuda-compositor": {
"binaryName": "recordly-nvidia-cuda-compositor.exe",
Binary file not shown.
+5 -11
View File
@@ -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 {
+221 -1147
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -104,7 +104,7 @@
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.10",
"vitest": "^2.1.9",
"web-demuxer": "^4.0.0"
},
"main": "dist-electron/main.cjs"
+3 -2
View File
@@ -438,6 +438,7 @@ function LaunchWindowContent() {
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
const useNativeHudBarDrag =
platform === "linux" || hudOverlayMousePassthroughSupported === false;
const shouldAnimateHudLayout = !recording && !showRecordingWebcamPreview && !isHudDragging;
return (
<HudInteractionContext.Provider
@@ -464,7 +465,7 @@ function LaunchWindowContent() {
>
<motion.div
ref={hudBarRef}
layout={!showRecordingWebcamPreview && !isHudDragging}
layout={shouldAnimateHudLayout}
transition={hudStateTransition}
className={`${styles.bar} launch-theme mb-2`}
>
@@ -487,7 +488,7 @@ function LaunchWindowContent() {
<AnimatePresence initial={false} mode="wait">
<motion.div
key={hudMode}
layout={!showRecordingWebcamPreview && !isHudDragging}
layout={shouldAnimateHudLayout}
className={styles.barState}
initial={{
opacity: 0,
+60
View File
@@ -6,6 +6,7 @@ import {
normalizeBrowserMicrophoneProfile,
resolveBrowserCaptureCursorPolicy,
shouldUseNativeWindowsCaptureForSource,
stopAndDiscardNativeCapture,
} from "./useScreenRecorder";
type RecordingState = "inactive" | "recording" | "paused";
@@ -173,6 +174,65 @@ describe("shouldUseNativeWindowsCaptureForSource", () => {
});
});
describe("stopAndDiscardNativeCapture", () => {
it("deletes the partial recording after a successful warm-start stop", async () => {
const deleteRecordingFile = vi.fn().mockResolvedValue(undefined);
await expect(
stopAndDiscardNativeCapture({
stopNativeScreenRecording: vi.fn().mockResolvedValue({
success: true,
path: "C:\\Recordly\\warm-start.mp4",
}),
deleteRecordingFile,
}),
).resolves.toEqual({
stopSucceeded: true,
deleteSucceeded: true,
path: "C:\\Recordly\\warm-start.mp4",
});
expect(deleteRecordingFile).toHaveBeenCalledWith("C:\\Recordly\\warm-start.mp4");
});
it("reports an unsuccessful stop without deleting or confirming cleanup", async () => {
const deleteRecordingFile = vi.fn();
await expect(
stopAndDiscardNativeCapture({
stopNativeScreenRecording: vi.fn().mockResolvedValue({
success: false,
error: "helper still running",
}),
deleteRecordingFile,
}),
).resolves.toEqual({
stopSucceeded: false,
deleteSucceeded: false,
error: "helper still running",
});
expect(deleteRecordingFile).not.toHaveBeenCalled();
});
it("keeps the stopped path available when deletion fails so cleanup can retry", async () => {
const deleteError = new Error("file locked");
await expect(
stopAndDiscardNativeCapture({
stopNativeScreenRecording: vi.fn().mockResolvedValue({
success: true,
path: "C:\\Recordly\\warm-start.mp4",
}),
deleteRecordingFile: vi.fn().mockRejectedValue(deleteError),
}),
).resolves.toEqual({
stopSucceeded: true,
deleteSucceeded: false,
path: "C:\\Recordly\\warm-start.mp4",
error: deleteError,
});
});
});
function stopRecording(
recorder: ReturnType<typeof createMockMediaRecorder>,
isNativeRecording: boolean,
+379 -109
View File
@@ -213,10 +213,7 @@ export function resolveBrowserCaptureCursorPolicy({
export function shouldUseNativeWindowsCaptureForSource(
source: Pick<ProcessedDesktopSource, "id"> | null | undefined,
): boolean {
return (
source?.id?.startsWith("screen:") === true ||
source?.id?.startsWith("window:") === true
);
return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true;
}
export function createProcessedMicrophoneConstraints(
@@ -265,6 +262,63 @@ export function createBrowserRecordingOptions({
return options;
}
type NativeCaptureStopResult = {
success: boolean;
path?: string;
error?: string;
message?: string;
};
export type DiscardNativeCaptureResult = {
stopSucceeded: boolean;
deleteSucceeded: boolean;
path?: string;
error?: unknown;
};
export async function stopAndDiscardNativeCapture({
stopNativeScreenRecording,
deleteRecordingFile,
}: {
stopNativeScreenRecording: () => Promise<NativeCaptureStopResult>;
deleteRecordingFile: (path: string) => Promise<unknown>;
}): Promise<DiscardNativeCaptureResult> {
let stoppedResult: NativeCaptureStopResult;
try {
stoppedResult = await stopNativeScreenRecording();
} catch (error) {
return { stopSucceeded: false, deleteSucceeded: false, error };
}
if (!stoppedResult.success) {
return {
stopSucceeded: false,
deleteSucceeded: false,
error: stoppedResult.error ?? stoppedResult.message,
};
}
if (!stoppedResult.path) {
return { stopSucceeded: true, deleteSucceeded: true };
}
try {
await deleteRecordingFile(stoppedResult.path);
return {
stopSucceeded: true,
deleteSucceeded: true,
path: stoppedResult.path,
};
} catch (error) {
return {
stopSucceeded: true,
deleteSucceeded: false,
path: stoppedResult.path,
error,
};
}
}
function createMicrophoneTrackSettingsSnapshot(
stream: MediaStream,
): MicrophoneTrackSettingsSnapshot | null {
@@ -347,6 +401,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const recordingSessionTimestamp = useRef<number | null>(null);
const nativeScreenRecording = useRef(false);
const nativeWindowsRecording = useRef(false);
const nativeWarmStartActive = useRef(false);
const pendingNativeCleanupPath = useRef<string | null>(null);
const recordingStartGeneration = useRef(0);
const nativeStopRequestInFlight = useRef(false);
const startInFlight = useRef(false);
const hasPromptedForReselect = useRef(false);
const hasShownNativeWindowsFallbackToast = useRef(false);
@@ -906,6 +964,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamStopPromise.current = null;
pendingWebcamPathPromise.current = null;
resolvedWebcamPath.current = result ?? null;
webcamRecorder.current = null;
return result ?? null;
}, []);
@@ -1066,10 +1125,165 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const stopRecording = useRef(() => {
setPaused(false);
if (nativeScreenRecording.current) {
const prepareRecordingStart = useCallback(async () => {
const platform = await window.electronAPI.getPlatform();
hideEditorOverlayCursorByDefault.current = false;
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
if (!selectedSource) {
alert("Please select a source to record");
return null;
}
if (!existingSource && selectedSource.id === "screen:linux-portal") {
try {
await window.electronAPI.selectSource(selectedSource);
} catch (err) {
console.warn("Failed to persist Linux portal sentinel source:", err);
}
}
const permissionsReady = await preparePermissions();
if (!permissionsReady) {
return null;
}
recordingSessionTimestamp.current = Date.now();
resetRecordingClock(recordingSessionTimestamp.current);
await prepareWebcamRecorder();
const useNativeMacScreenCapture =
platform === "darwin" &&
(selectedSource.id?.startsWith("screen:") ||
selectedSource.id?.startsWith("window:")) &&
typeof window.electronAPI.startNativeScreenRecording === "function";
let useNativeWindowsCapture = false;
if (
platform === "win32" &&
shouldUseNativeWindowsCaptureForSource(selectedSource) &&
typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function"
) {
try {
const nativeWindowsResult =
await window.electronAPI.isNativeWindowsCaptureAvailable();
useNativeWindowsCapture = nativeWindowsResult.available;
if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) {
void logNativeCaptureDiagnostics("is-native-windows-capture-available");
hasShownNativeWindowsFallbackToast.current = true;
toast.info(
"Native Windows capture is unavailable. Falling back to browser capture.",
);
}
} catch {
useNativeWindowsCapture = false;
if (!hasShownNativeWindowsFallbackToast.current) {
hasShownNativeWindowsFallbackToast.current = true;
toast.info(
"Unable to check native Windows capture. Falling back to browser capture.",
);
}
}
}
let micLabel: string | undefined;
if ((useNativeMacScreenCapture || useNativeWindowsCapture) && microphoneEnabled) {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const mic = devices.find(
(d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput",
);
micLabel = mic?.label || undefined;
} catch {
// Fall through - native process will use the default mic.
}
}
return {
platform,
selectedSource,
useNativeMacScreenCapture,
useNativeWindowsCapture,
micLabel,
};
}, [
logNativeCaptureDiagnostics,
microphoneDeviceId,
microphoneEnabled,
preparePermissions,
prepareWebcamRecorder,
resetRecordingClock,
]);
const discardActiveNativeCapture = useCallback(async () => {
const pendingPath = pendingNativeCleanupPath.current;
if (pendingPath) {
try {
await window.electronAPI.deleteRecordingFile(pendingPath);
pendingNativeCleanupPath.current = null;
} catch (error) {
console.warn("Failed to delete pending native capture file:", error);
}
}
if (!nativeScreenRecording.current) {
return pendingNativeCleanupPath.current === null;
}
if (nativeStopRequestInFlight.current) {
return false;
}
nativeStopRequestInFlight.current = true;
let result: DiscardNativeCaptureResult;
try {
result = await stopAndDiscardNativeCapture({
stopNativeScreenRecording: () => window.electronAPI.stopNativeScreenRecording(),
deleteRecordingFile: (path) => window.electronAPI.deleteRecordingFile(path),
});
} finally {
nativeStopRequestInFlight.current = false;
}
if (result.stopSucceeded) {
nativeScreenRecording.current = false;
nativeWindowsRecording.current = false;
nativeWarmStartActive.current = false;
}
if (!result.deleteSucceeded && result.path) {
pendingNativeCleanupPath.current = result.path;
}
if (!result.stopSucceeded || !result.deleteSucceeded) {
console.warn("Failed to fully discard native capture:", result.error);
return false;
}
return true;
}, []);
const stopRecording = useRef(() => {
recordingStartGeneration.current += 1;
setPaused(false);
if (nativeScreenRecording.current && nativeWarmStartActive.current) {
setRecording(false);
void (async () => {
await discardActiveNativeCapture();
cleanupCapturedMedia();
await Promise.allSettled([
stopMicFallbackRecorder(),
stopWebcamRecorder(),
window.electronAPI?.setRecordingState(false),
]);
})();
return;
}
if (nativeScreenRecording.current) {
if (nativeStopRequestInFlight.current) {
return;
}
nativeStopRequestInFlight.current = true;
setRecording(false);
setFinalizing(true);
@@ -1085,16 +1299,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const micFallbackBlobPromise = stopMicFallbackRecorder();
const webcamPathPromise = stopWebcamRecorder();
const isNativeWindows = nativeWindowsRecording.current;
nativeWindowsRecording.current = false;
const ipcStopStart = performance.now();
console.log("[PERF:RENDERER] IPC: stopNativeScreenRecording: STARTED");
const result = await window.electronAPI.stopNativeScreenRecording();
let result: NativeCaptureStopResult;
try {
result = await window.electronAPI.stopNativeScreenRecording();
} catch (error) {
result = { success: false, error: getErrorMessage(error) };
}
nativeStopRequestInFlight.current = false;
console.log(
`[PERF:RENDERER] IPC: stopNativeScreenRecording: COMPLETED in ${(performance.now() - ipcStopStart).toFixed(2)}ms`,
);
if (result.success) {
nativeScreenRecording.current = false;
nativeWindowsRecording.current = false;
nativeWarmStartActive.current = false;
}
await window.electronAPI?.setRecordingState(false);
try {
await window.electronAPI?.setRecordingState(false);
} catch (stateError) {
console.warn("Failed to reset main-process recording state:", stateError);
}
if (!result.success || !result.path) {
console.error(
@@ -1304,8 +1532,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.(
(state) => {
void (async () => {
recordingStartGeneration.current += 1;
setRecording(false);
nativeScreenRecording.current = false;
nativeWindowsRecording.current = false;
nativeWarmStartActive.current = false;
cleanupCapturedMedia();
await window.electronAPI.setRecordingState(false);
@@ -1336,13 +1567,33 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
return () => {
recordingStartGeneration.current += 1;
cleanup?.();
removeRecordingStateListener?.();
removeRecordingInterruptedListener?.();
if (nativeScreenRecording.current) {
nativeScreenRecording.current = false;
void window.electronAPI.stopNativeScreenRecording();
if (nativeWarmStartActive.current) {
void discardActiveNativeCapture();
} else if (!nativeStopRequestInFlight.current) {
nativeStopRequestInFlight.current = true;
void window.electronAPI
.stopNativeScreenRecording()
.then((result) => {
if (result.success) {
nativeScreenRecording.current = false;
nativeWindowsRecording.current = false;
}
})
.catch((error) => {
console.warn("Failed to stop native capture during cleanup:", error);
})
.finally(() => {
nativeStopRequestInFlight.current = false;
});
}
} else if (pendingNativeCleanupPath.current) {
void discardActiveNativeCapture();
}
const recorder = mediaRecorder.current;
@@ -1353,12 +1604,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia();
};
}, [cleanupCapturedMedia, recoverNativeRecordingSession]);
}, [cleanupCapturedMedia, discardActiveNativeCapture, recoverNativeRecordingSession]);
const startRecording = async () => {
if (startInFlight.current) {
return;
}
const startGeneration = recordingStartGeneration.current + 1;
recordingStartGeneration.current = startGeneration;
const startWasCancelled = () => recordingStartGeneration.current !== startGeneration;
let hudSourceSelectionActive = false;
const setHudSourceSelectionActive = (active: boolean) => {
@@ -1375,84 +1629,36 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setStarting(true);
try {
const platform = await window.electronAPI.getPlatform();
hideEditorOverlayCursorByDefault.current = false;
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
if (!selectedSource) {
alert("Please select a source to record");
const preparedStart = await prepareRecordingStart();
if (!preparedStart || startWasCancelled()) {
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
}
// Persist the synthetic Linux portal sentinel to main so that the
// setDisplayMediaRequestHandler can short-circuit getSources() and
// avoid triggering an extra portal dialog.
if (!existingSource && selectedSource.id === "screen:linux-portal") {
const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } =
preparedStart;
const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture;
const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0;
if (countdownDelay > 0 && !shouldWarmStartNativeCapture) {
setCountdownActive(true);
try {
await window.electronAPI.selectSource(selectedSource);
} catch (err) {
console.warn("Failed to persist Linux portal sentinel source:", err);
const result = await window.electronAPI.startCountdown(countdownDelay);
if (!result.success || result.cancelled || startWasCancelled()) {
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
}
} finally {
setCountdownActive(false);
}
recordingSessionTimestamp.current = Date.now();
resetRecordingClock(recordingSessionTimestamp.current);
}
const permissionsReady = await preparePermissions();
if (!permissionsReady) {
return;
}
recordingSessionTimestamp.current = Date.now();
resetRecordingClock(recordingSessionTimestamp.current);
await prepareWebcamRecorder();
const useNativeMacScreenCapture =
platform === "darwin" &&
(selectedSource.id?.startsWith("screen:") ||
selectedSource.id?.startsWith("window:")) &&
typeof window.electronAPI.startNativeScreenRecording === "function";
let useNativeWindowsCapture = false;
let nativeWindowsCaptureStartFailed = false;
if (
platform === "win32" &&
shouldUseNativeWindowsCaptureForSource(selectedSource) &&
typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function"
) {
try {
const nativeWindowsResult =
await window.electronAPI.isNativeWindowsCaptureAvailable();
useNativeWindowsCapture = nativeWindowsResult.available;
if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) {
void logNativeCaptureDiagnostics("is-native-windows-capture-available");
hasShownNativeWindowsFallbackToast.current = true;
toast.info(
"Native Windows capture is unavailable. Falling back to browser capture.",
);
}
} catch {
useNativeWindowsCapture = false;
if (!hasShownNativeWindowsFallbackToast.current) {
hasShownNativeWindowsFallbackToast.current = true;
toast.info(
"Unable to check native Windows capture. Falling back to browser capture.",
);
}
}
}
if (useNativeMacScreenCapture || useNativeWindowsCapture) {
// Resolve the selected mic label for native capture backends.
let micLabel: string | undefined;
if (microphoneEnabled) {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const mic = devices.find(
(d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput",
);
micLabel = mic?.label || undefined;
} catch {
// Fall through — native process will use the default mic
}
}
if (useNativeCapture) {
const nativeResult = await window.electronAPI.startNativeScreenRecording(
selectedSource,
{
@@ -1462,6 +1668,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneLabel: micLabel,
},
);
if (nativeResult.success && startWasCancelled()) {
nativeScreenRecording.current = true;
nativeWindowsRecording.current = useNativeWindowsCapture;
nativeWarmStartActive.current = shouldWarmStartNativeCapture;
await discardActiveNativeCapture();
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
}
if (!nativeResult.success) {
if (useNativeWindowsCapture) {
nativeWindowsCaptureStartFailed = true;
@@ -1491,11 +1706,62 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
if (nativeResult.success) {
nativeScreenRecording.current = true;
nativeWindowsRecording.current = useNativeWindowsCapture;
if (shouldWarmStartNativeCapture) {
nativeWarmStartActive.current = true;
const pauseResult = await window.electronAPI.pauseNativeScreenRecording();
if (startWasCancelled()) {
return;
}
if (!pauseResult.success) {
throw new Error(
pauseResult.error ??
pauseResult.message ??
"Failed to pause native capture before countdown",
);
}
setCountdownActive(true);
try {
const countdownResult =
await window.electronAPI.startCountdown(countdownDelay);
if (
!countdownResult.success ||
countdownResult.cancelled ||
startWasCancelled()
) {
if (!startWasCancelled()) {
await discardActiveNativeCapture();
}
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
}
} finally {
setCountdownActive(false);
}
const resumeResult = await window.electronAPI.resumeNativeScreenRecording();
if (startWasCancelled()) {
return;
}
if (!resumeResult.success) {
throw new Error(
resumeResult.error ??
resumeResult.message ??
"Failed to resume native capture after countdown",
);
}
nativeWarmStartActive.current = false;
}
if (startWasCancelled()) {
return;
}
const mainStartedAt = Date.now();
micFallbackStartDelayMs.current = null;
beginWebcamCapture();
nativeScreenRecording.current = true;
nativeWindowsRecording.current = useNativeWindowsCapture;
resetRecordingClock(mainStartedAt);
webcamTimeOffsetMs.current =
webcamStartTime.current === null
@@ -1566,6 +1832,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
}
}
if (startWasCancelled()) {
await stopMicFallbackRecorder();
await stopWebcamRecorder();
cleanupCapturedMedia();
return;
}
setRecording(true);
try {
@@ -1581,6 +1853,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (nativeWindowsCaptureStartFailed && countdownDelay > 0) {
setCountdownActive(true);
try {
const result = await window.electronAPI.startCountdown(countdownDelay);
if (!result.success || result.cancelled) {
cleanupCapturedMedia();
await stopWebcamRecorder();
return;
}
} finally {
setCountdownActive(false);
}
recordingSessionTimestamp.current = Date.now();
resetRecordingClock(recordingSessionTimestamp.current);
}
const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({
nativeWindowsCaptureStartFailed,
});
@@ -1921,6 +2209,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
: "Failed to start recording",
);
setRecording(false);
if (nativeScreenRecording.current) {
await discardActiveNativeCapture();
}
try {
await window.electronAPI?.setRecordingState(false);
} catch (stateError) {
@@ -2029,6 +2320,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, [markRecordingResumed, paused, recording, resumeMicFallbackRecorder]);
const cancelRecording = useCallback(() => {
recordingStartGeneration.current += 1;
if (!recording) return;
setPaused(false);
markRecordingResumed(Date.now());
@@ -2047,19 +2339,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
resolvedWebcamPath.current = null;
if (nativeScreenRecording.current) {
nativeScreenRecording.current = false;
nativeWindowsRecording.current = false;
setRecording(false);
window.electronAPI?.setRecordingState(false);
void (async () => {
try {
const result = await window.electronAPI.stopNativeScreenRecording();
if (result?.path) {
await window.electronAPI.deleteRecordingFile(result.path);
}
} catch {
// Best-effort cleanup
}
await discardActiveNativeCapture();
})();
return;
}
@@ -2073,7 +2356,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(false);
window.electronAPI?.setRecordingState(false);
}
}, [cleanupCapturedMedia, markRecordingResumed, recording]);
}, [cleanupCapturedMedia, discardActiveNativeCapture, markRecordingResumed, recording]);
const toggleRecording = async () => {
if (starting || countdownActive || finalizing) {
@@ -2085,19 +2368,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
// Start recording with optional countdown
if (countdownDelay > 0) {
setCountdownActive(true);
try {
const result = await window.electronAPI.startCountdown(countdownDelay);
if (!result.success || result.cancelled) {
return;
}
} finally {
setCountdownActive(false);
}
}
startRecording();
};