fix: address native mac review feedback

This commit is contained in:
Etienne Lescot
2026-05-13 16:18:19 +02:00
parent 179047b834
commit e708ae973e
20 changed files with 149 additions and 69 deletions
+53 -27
View File
@@ -1,4 +1,5 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
@@ -47,6 +48,7 @@ const RECORDING_FILE_PREFIX = "recording-";
const RECORDING_SESSION_SUFFIX = ".session.json";
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
const nativeMacCaptureEvents = new EventEmitter();
/**
* Paths explicitly approved by the user via file picker dialogs or project loads.
@@ -996,6 +998,43 @@ function tryParseNativeHelperEvent(line: string) {
}
}
function inspectNativeMacCaptureOutput() {
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (event) {
nativeMacCaptureEvents.emit("helper-event", event);
}
}
}
function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) {
let lineBuffer = "";
const drain = (chunk: Buffer) => {
const text = chunk.toString();
nativeMacCaptureOutput += text;
lineBuffer += text;
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const event = tryParseNativeHelperEvent(line.trim());
if (event) {
nativeMacCaptureEvents.emit("helper-event", event);
}
}
};
const cleanup = () => {
proc.stdout.off("data", drain);
proc.stderr.off("data", drain);
proc.off("close", cleanup);
proc.off("error", cleanup);
};
proc.stdout.on("data", drain);
proc.stderr.on("data", drain);
proc.once("close", cleanup);
proc.once("error", cleanup);
}
function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -1003,11 +1042,7 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
reject(new Error("Timed out waiting for native macOS capture to start"));
}, 10_000);
const inspect = (chunk: Buffer) => {
nativeMacCaptureOutput += chunk.toString();
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (!event) continue;
const inspect = (event: Record<string, unknown>) => {
if (event.event === "recording-started") {
cleanup();
resolve();
@@ -1015,13 +1050,11 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
}
if (event.event === "error") {
cleanup();
reject(new Error(event.message ?? event.code ?? "Native macOS capture failed"));
return;
}
reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed")));
}
};
const onOutput = (chunk: Buffer) => inspect(chunk);
const onOutput = (event: Record<string, unknown>) => inspect(event);
const onClose = (code: number | null) => {
cleanup();
reject(
@@ -1037,16 +1070,15 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
};
const cleanup = () => {
clearTimeout(timer);
proc.stdout.off("data", onOutput);
proc.stderr.off("data", onOutput);
nativeMacCaptureEvents.off("helper-event", onOutput);
proc.off("close", onClose);
proc.off("error", onError);
};
proc.stdout.on("data", onOutput);
proc.stderr.on("data", onOutput);
nativeMacCaptureEvents.on("helper-event", onOutput);
proc.once("close", onClose);
proc.once("error", onError);
inspectNativeMacCaptureOutput();
});
}
@@ -1063,25 +1095,19 @@ function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) {
);
}, 30_000);
const inspect = (chunk: Buffer) => {
nativeMacCaptureOutput += chunk.toString();
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (!event) continue;
const inspect = (event: Record<string, unknown>) => {
if (event.event === "recording-stopped") {
cleanup();
resolve(event.screenPath ?? nativeMacCaptureTargetPath ?? "");
resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? ""));
return;
}
if (event.event === "error") {
cleanup();
reject(new Error(event.message ?? event.code ?? "Native macOS capture failed"));
return;
}
reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed")));
}
};
const onOutput = (chunk: Buffer) => inspect(chunk);
const onOutput = (event: Record<string, unknown>) => inspect(event);
const onClose = (code: number | null) => {
if (code === 0 && nativeMacCaptureTargetPath) {
cleanup();
@@ -1102,16 +1128,15 @@ function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) {
};
const cleanup = () => {
clearTimeout(timer);
proc.stdout.off("data", onOutput);
proc.stderr.off("data", onOutput);
nativeMacCaptureEvents.off("helper-event", onOutput);
proc.off("close", onClose);
proc.off("error", onError);
};
proc.stdout.on("data", onOutput);
proc.stderr.on("data", onOutput);
nativeMacCaptureEvents.on("helper-event", onOutput);
proc.once("close", onClose);
proc.once("error", onError);
inspectNativeMacCaptureOutput();
});
}
@@ -1722,6 +1747,7 @@ export function registerIpcHandlers(
stdio: ["pipe", "pipe", "pipe"],
});
nativeMacCaptureProcess = proc;
attachNativeMacCaptureOutputDrain(proc);
await waitForNativeMacCaptureStart(proc);
const captureStartedAtMs = Date.now();
@@ -101,6 +101,10 @@ func parentElement(_ element: AXUIElement) -> AXUIElement? {
return nil
}
guard CFGetTypeID(value) == AXUIElementGetTypeID() else {
return nil
}
return (value as! AXUIElement)
}
@@ -327,8 +327,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
AVCaptureDevice.requestAccess(for: .audio) { _ in
semaphore.signal()
}
semaphore.wait()
if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
let waitResult = semaphore.wait(timeout: .now() + 30)
if waitResult == .timedOut || AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
throw HelperError.permissionDenied("Microphone permission is required for native microphone capture.")
}
default:
+1 -1
View File
@@ -21,7 +21,7 @@
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac",
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
@@ -70,6 +70,12 @@ if (result.status !== 0) {
fs.mkdirSync(buildDir, { recursive: true });
fs.mkdirSync(distributableDir, { recursive: true });
for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) {
if (!fs.existsSync(artifactPath)) {
console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`);
process.exit(1);
}
}
fs.copyFileSync(builtHelperPath, localHelperPath);
fs.copyFileSync(builtHelperPath, distributablePath);
fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath);
+9 -6
View File
@@ -282,21 +282,24 @@ export function LaunchWindow() {
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
const hudMouseEventsEnabledRef = useRef<boolean | undefined>(undefined);
const setHudMouseEventsEnabled = useCallback((enabled: boolean) => {
if (hudMouseEventsEnabledRef.current === enabled) {
return;
}
hudMouseEventsEnabledRef.current = enabled;
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled);
}, []);
useEffect(() => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
setHudMouseEventsEnabled(false);
return () => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
};
}, []);
}, [setHudMouseEventsEnabled]);
useEffect(() => {
if (isLanguageMenuOpen) {
setHudMouseEventsEnabled(true);
}
setHudMouseEventsEnabled(isLanguageMenuOpen);
}, [isLanguageMenuOpen, setHudMouseEventsEnabled]);
const [selectedSource, setSelectedSource] = useState("Screen");
@@ -389,7 +392,7 @@ export function LaunchWindow() {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setHudMouseEventsEnabled(true);
setHudMouseEventsEnabled(false);
};
return (
+37 -4
View File
@@ -775,7 +775,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
},
},
webcam: {
enabled: false,
enabled: webcamEnabled,
deviceId: webcamDeviceId,
deviceName: webcamDeviceName,
width: WEBCAM_TARGET_WIDTH,
@@ -857,6 +857,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, 5000);
});
}
if (!isCountdownRunActive(countdownRunToken)) {
return true;
}
if (webcamStream.current) {
nativeWebcamRecorder = createRecorderHandle(webcamStream.current, {
mimeType: selectMimeType(),
@@ -867,6 +870,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setWebcamEnabledState(false);
}
}
if (!isCountdownRunActive(countdownRunToken)) {
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
nativeWebcamRecorder.recorder.stop();
}
return true;
}
const request: NativeMacRecordingRequest = {
schemaVersion: 1,
recordingId: activeRecordingId,
@@ -916,6 +925,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
throw new Error(result.error ?? "Native macOS capture failed.");
}
if (!isCountdownRunActive(countdownRunToken)) {
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
nativeWebcamRecorder.recorder.stop();
}
await window.electronAPI.stopNativeMacRecording(true);
return true;
}
recordingId.current = result.recordingId;
nativeMacRecording.current = {
@@ -969,9 +985,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (platform === "darwin" && cursorCaptureMode === "editable-overlay") {
const access = await window.electronAPI.requestNativeMacCursorAccess();
if (!access.granted) {
toast.info(
"Allow Accessibility access for OpenScreen, then press record again to start the countdown.",
);
toast.info(t("recording.accessibilityAllowAndRetry"));
return;
}
}
@@ -1415,6 +1429,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
return;
}
if (nativeMacRecording.current) {
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
try {
await finalizeNativeMacRecording(true);
await startRecording();
} finally {
restarting.current = false;
}
return;
}
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
@@ -1480,6 +1506,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
void finalizeNativeWindowsRecording(true);
return;
}
if (nativeMacRecording.current) {
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
void finalizeNativeMacRecording(true);
return;
}
const activeScreenRecorder = screenRecorder.current;
if (
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
"cameraDisconnected": "تم فصل كاميرا الويب.",
"cameraNotFound": "لم يتم العثور على كاميرا.",
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.",
"accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي."
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
"cameraDisconnected": "Webcam disconnected.",
"cameraNotFound": "Camera not found.",
"permissionDenied": "Recording permission denied. Please allow screen recording."
"permissionDenied": "Recording permission denied. Please allow screen recording.",
"accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown."
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
"cameraDisconnected": "Cámara web desconectada.",
"cameraNotFound": "Cámara no encontrada.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.",
"accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás."
},
"loadingVideo": "Cargando video...",
"newRecording": {
+2 -1
View File
@@ -39,7 +39,8 @@
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
"cameraDisconnected": "Webcam déconnectée.",
"cameraNotFound": "Caméra introuvable.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.",
"accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours."
},
"loadingVideo": "Chargement de la vidéo..."
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
"cameraDisconnected": "ウェブカメラが切断されました。",
"cameraNotFound": "カメラが見つかりません。"
"cameraNotFound": "カメラが見つかりません。",
"accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。"
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.",
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
"cameraNotFound": "카메라를 찾을 수 없습니다."
"cameraNotFound": "카메라를 찾을 수 없습니다.",
"accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요."
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
"cameraDisconnected": "Веб-камера отключена.",
"cameraNotFound": "Камера не найдена.",
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.",
"accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет."
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.",
"cameraDisconnected": "Webcam bağlantısı kesildi.",
"cameraNotFound": "Kamera bulunamadı."
"cameraNotFound": "Kamera bulunamadı.",
"accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın."
},
"loadingVideo": "Video yükleniyor...",
"newRecording": {
+1 -2
View File
@@ -14,8 +14,7 @@
"disableSystemAudio": "Sistem sesini devre dışı bırak",
"enableMicrophone": "Mikrofonu etkinleştir",
"disableMicrophone": "Mikrofonu devre dışı bırak",
"defaultMicrophone": "Varsayılan Mikrofon",
"nrLevel": {}
"defaultMicrophone": "Varsayılan Mikrofon"
},
"webcam": {
"enableWebcam": "Kamerayı etkinleştir",
+1 -3
View File
@@ -194,7 +194,5 @@
"language": {
"title": "Dil"
},
"audio": {
"nrLevel": {}
}
"audio": {}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.",
"cameraDisconnected": "Webcam bị ngắt kết nối.",
"cameraNotFound": "Không tìm thấy máy ảnh.",
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình."
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.",
"accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược."
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
"cameraDisconnected": "摄像头已断开连接。",
"cameraNotFound": "未找到摄像头。",
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。",
"accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。"
}
}
+2 -1
View File
@@ -40,6 +40,7 @@
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。",
"cameraDisconnected": "網路攝影機已中斷連線。",
"cameraNotFound": "找不到攝影機。"
"cameraNotFound": "找不到攝影機。",
"accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。"
}
}