diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 2a82d991..47c42437 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -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"; @@ -182,6 +187,52 @@ export function shouldStartGlobalInteractionHook(platform: NodeJS.Platform = pro 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; @@ -192,9 +243,7 @@ export async function startInteractionCapture() { } if (!shouldStartGlobalInteractionHook()) { - console.warn( - "[CursorTelemetry] Skipping the blocking global interaction hook on macOS.", - ); + console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS."); return; } @@ -220,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; } diff --git a/electron/ipc/cursor/monitor.ts b/electron/ipc/cursor/monitor.ts index a076713e..8a507d56 100644 --- a/electron/ipc/cursor/monitor.ts +++ b/electron/ipc/cursor/monitor.ts @@ -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; diff --git a/electron/native/NativeCursorMonitor.swift b/electron/native/NativeCursorMonitor.swift index 236f9bb9..581a595d 100644 --- a/electron/native/NativeCursorMonitor.swift +++ b/electron/native/NativeCursorMonitor.swift @@ -406,6 +406,70 @@ if CommandLine.arguments.contains("--export-images") { exit(0) } +func mouseInteractionCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + 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: + action = "mousedown" + button = 3 + case .otherMouseUp: + 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 +497,3 @@ DispatchQueue.global(qos: .utility).async { } RunLoop.main.run() - diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 7acf8e66..285f0dd1 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -436,7 +436,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { await microphoneOnlyWriter.finishWriting() } - let finalizeFailure: Error? = assetWriter.flatMap { $0.status == .completed ? nil : ($0.error ?? unfinalizedWriterError(status: $0.status)) } + 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 @@ -676,6 +683,7 @@ 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 { @@ -694,6 +702,7 @@ final class RecorderService { 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() @@ -709,6 +718,7 @@ final class RecorderService { fflush(stdout) self.completionGroup.leave() } catch { + self.succeeded = false fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) fflush(stderr) self.completionGroup.leave() @@ -734,8 +744,9 @@ final class RecorderService { } } - func waitUntilFinished() { + func waitUntilFinished() -> Bool { completionGroup.wait() + return succeeded } } @@ -808,4 +819,6 @@ DispatchQueue.global(qos: .utility).async { } } -service.waitUntilFinished() +if !service.waitUntilFinished() { + exit(1) +} diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor index e0e478f1..9e309c37 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 0c64386f..609b045d 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor index d577b1a6..9c592b78 100755 Binary files a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 09e0ed5d..08a2f749 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d8224b69..251f31b5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1470,14 +1470,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } = preparedStart; - const useNativeCapture = - useNativeMacScreenCapture || useNativeWindowsCapture; + const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { setCountdownActive(true); try { const result = await window.electronAPI.startCountdown(countdownDelay); if (!result.success || result.cancelled) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); return; } } finally {