mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Merge pull request #827 from webadderallorg/fix/windows-wgc-countdown-warm-start
Fix native capture countdown startup lag
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
+5
-11
@@ -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 {
|
||||
|
||||
Generated
+221
-1147
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user