fix: address native capture review feedback

This commit is contained in:
young
2026-08-24 11:43:50 +10:00
parent 13dd5ec412
commit 54c506dac7
9 changed files with 158 additions and 68 deletions
+61 -60
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";
@@ -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;
}
+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;
+64 -1
View File
@@ -406,6 +406,70 @@ 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:
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()
+16 -3
View File
@@ -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)
}
+3 -2
View File
@@ -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 {