diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b12a08a..009ade6 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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((resolve, reject) => { const timer = setTimeout(() => { @@ -1003,25 +1042,19 @@ 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; - if (event.event === "recording-started") { - cleanup(); - resolve(); - return; - } - if (event.event === "error") { - cleanup(); - reject(new Error(event.message ?? event.code ?? "Native macOS capture failed")); - return; - } + const inspect = (event: Record) => { + if (event.event === "recording-started") { + cleanup(); + resolve(); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); } }; - const onOutput = (chunk: Buffer) => inspect(chunk); + const onOutput = (event: Record) => 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; - if (event.event === "recording-stopped") { - cleanup(); - resolve(event.screenPath ?? nativeMacCaptureTargetPath ?? ""); - return; - } - if (event.event === "error") { - cleanup(); - reject(new Error(event.message ?? event.code ?? "Native macOS capture failed")); - return; - } + const inspect = (event: Record) => { + if (event.event === "recording-stopped") { + cleanup(); + resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? "")); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); } }; - const onOutput = (chunk: Buffer) => inspect(chunk); + const onOutput = (event: Record) => 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(); diff --git a/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift index 830f692..bbe49f8 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift @@ -101,6 +101,10 @@ func parentElement(_ element: AXUIElement) -> AXUIElement? { return nil } + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + return (value as! AXUIElement) } diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift index 6b71d75..14860b0 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -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: diff --git a/package.json b/package.json index 12d02d7..fd0c4cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs index 6a575d8..8e7c973 100644 --- a/scripts/build-macos-screencapturekit-helper.mjs +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -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); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 5d394f2..2da3b47 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -282,21 +282,24 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); + const hudMouseEventsEnabledRef = useRef(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 ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2b70ad7..9941dc4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -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 ( diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a246f01..1eec625 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.", "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", - "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة." + "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", + "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي." } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 13e2e13..aad3700 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -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." } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 8f6ad13..27e0cae 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -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": { diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6380c6b..7195aed 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -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..." } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 051335f..d37e132 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。", "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", - "cameraNotFound": "カメラが見つかりません。" + "cameraNotFound": "カメラが見つかりません。", + "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ce16244..13c8bfd 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.", "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", - "cameraNotFound": "카메라를 찾을 수 없습니다." + "cameraNotFound": "카메라를 찾을 수 없습니다.", + "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요." } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c5616d2..5452124 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.", "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", - "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана." + "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", + "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет." } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 0aece8a..b50630a 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -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": { diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 37944aa..39a91be 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -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", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 2013f81..0eab90f 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -194,7 +194,5 @@ "language": { "title": "Dil" }, - "audio": { - "nrLevel": {} - } + "audio": {} } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index a45bf3e..03e909f 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -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." } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index f6c02d4..56a36f8 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", - "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" + "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", + "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 21f3ba6..d4ad23f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -40,6 +40,7 @@ "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。", "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", - "cameraNotFound": "找不到攝影機。" + "cameraNotFound": "找不到攝影機。", + "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。" } }