diff --git a/electron/cursorHider.ts b/electron/cursorHider.ts new file mode 100644 index 00000000..228aa7dc --- /dev/null +++ b/electron/cursorHider.ts @@ -0,0 +1,141 @@ +import { spawnSync } from 'node:child_process' + +const PY_HIDE_WIN = ` +import ctypes, sys + +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + +class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("flags", ctypes.c_uint), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + +user32 = ctypes.windll.user32 +CURSOR_SHOWING = 0x00000001 + +for _ in range(32): + info = CURSORINFO() + info.cbSize = ctypes.sizeof(CURSORINFO) + if user32.GetCursorInfo(ctypes.byref(info)) and not (info.flags & CURSOR_SHOWING): + sys.exit(0) + user32.ShowCursor(False) + +sys.exit(0) +`.trim() + +const PY_SHOW_WIN = ` +import ctypes, sys + +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + +class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("flags", ctypes.c_uint), + ("hCursor", ctypes.c_void_p), + ("ptScreenPos", POINT), + ] + +user32 = ctypes.windll.user32 +CURSOR_SHOWING = 0x00000001 + +for _ in range(32): + info = CURSORINFO() + info.cbSize = ctypes.sizeof(CURSORINFO) + if user32.GetCursorInfo(ctypes.byref(info)) and (info.flags & CURSOR_SHOWING): + sys.exit(0) + user32.ShowCursor(True) + +sys.exit(0) +`.trim() + +function getPowerShellCommand(show: boolean) { + const desiredFlag = show ? 1 : 0 + const showLiteral = show ? '$true' : '$false' + + return [ + '$signature = @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public struct POINT { public int X; public int Y; }', + 'public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; }', + 'public static class CursorNative {', + ' [DllImport("user32.dll")] public static extern int ShowCursor(bool show);', + ' [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);', + '}', + '"@;', + 'Add-Type -TypeDefinition $signature -Language CSharp -ErrorAction SilentlyContinue | Out-Null;', + '$info = New-Object CURSORINFO;', + '$info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type]CURSORINFO);', + 'for ($i = 0; $i -lt 32; $i++) {', + ' if ([CursorNative]::GetCursorInfo([ref]$info) -and (($info.flags -band 1) -eq ' + desiredFlag + ')) { exit 0 }', + ' [CursorNative]::ShowCursor(' + showLiteral + ') | Out-Null;', + '}', + 'exit 0', + ].join(' ') +} + +function runPythonSnippet(code: string) { + for (const executable of ['python', 'python3', 'py']) { + const result = spawnSync(executable, ['-c', code], { timeout: 5000 }) + if (!result.error && result.status === 0) { + return true + } + } + + return false +} + +function runPowerShellSnippet(command: string) { + const result = spawnSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', command], + { timeout: 8000 }, + ) + + return !result.error && result.status === 0 +} + +let cursorHidden = false + +export function hideCursor() { + if (process.platform !== 'win32' || cursorHidden) { + return false + } + + try { + const didHide = runPythonSnippet(PY_HIDE_WIN) + || runPowerShellSnippet(getPowerShellCommand(false)) + + if (didHide) { + cursorHidden = true + } + + return didHide + } catch (error) { + console.error('[cursorHider] Failed to hide Windows cursor:', error) + return false + } +} + +export function showCursor() { + if (process.platform !== 'win32' || !cursorHidden) { + return false + } + + try { + const didShow = runPythonSnippet(PY_SHOW_WIN) + || runPowerShellSnippet(getPowerShellCommand(true)) + return didShow + } catch (error) { + console.error('[cursorHider] Failed to show Windows cursor:', error) + return false + } finally { + cursorHidden = false + } +} \ No newline at end of file diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8cce8eb4..5f5e4c83 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -10,6 +10,7 @@ import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { RECORDINGS_DIR } from '../main' +import { hideCursor, showCursor } from '../cursorHider' import { createCountdownWindow, getCountdownWindow, closeCountdownWindow } from '../windows' const execFileAsync = promisify(execFile) @@ -2264,6 +2265,10 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} config.microphoneDeviceId = options.microphoneDeviceId } + if (options?.microphoneLabel) { + config.microphoneLabel = options.microphoneLabel + } + if (microphoneOutputPath) { config.microphoneOutputPath = microphoneOutputPath } @@ -2705,6 +2710,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} stopInteractionCapture() stopWindowBoundsCapture() stopNativeCursorMonitor() + showCursor() linuxCursorScreenPoint = null snapshotCursorTelemetryForPersistence() activeCursorSamples = [] @@ -3296,12 +3302,11 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} // The IPC promise resolves only after the cursor hide attempt completes. // --------------------------------------------------------------------------- ipcMain.handle('hide-cursor', () => { - // No-op: macOS excludes the cursor at the ScreenCaptureKit capture level. - // Windows excludes the cursor via IsCursorCaptureEnabled(false) in wgc_session.cpp. - // Linux uses Electron desktopCapturer which does not support cursor hiding; - // if WGC is unavailable on Windows the call also falls back to browser capture - // where cursor hiding is unsupported — those users may see the real cursor. - return { success: true } + if (process.platform !== 'win32') { + return { success: true } + } + + return { success: hideCursor() } }) ipcMain.handle('get-shortcuts', async () => { diff --git a/electron/main.ts b/electron/main.ts index 163a6507..ff3f51cd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import fs from 'node:fs/promises' import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' +import { showCursor } from './cursorHider' import { registerIpcHandlers, getSelectedSourceId, killWgcCaptureProcess } from './ipc/handlers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -347,6 +348,7 @@ function createSourceSelectorWindowWrapper() { // explicitly with Cmd + Q. app.on('before-quit', () => { killWgcCaptureProcess() + showCursor() }) app.on('window-all-closed', () => { diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 0b2c521e..274b3562 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -11,6 +11,7 @@ struct CaptureConfig: Codable { let capturesSystemAudio: Bool? let capturesMicrophone: Bool? let microphoneDeviceId: String? + let microphoneLabel: String? let microphoneOutputPath: String? } @@ -89,7 +90,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { if capturesMicrophone { streamConfig.setValue(true, forKey: "captureMicrophone") - if let microphoneDeviceId = config.microphoneDeviceId, !microphoneDeviceId.isEmpty { + if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) { streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID") } } @@ -442,6 +443,24 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { ] } + private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { + let audioDevices = AVCaptureDevice.devices(for: .audio) + + if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { + if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { + return matchedDevice.uniqueID + } + } + + if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { + if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { + return microphoneDeviceId + } + } + + return nil + } + private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) diff --git a/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper b/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper index 54c53d95..9bdbccc5 100755 Binary files a/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper and b/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper differ diff --git a/src/App.tsx b/src/App.tsx index 332df09e..bd5812b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; +import { Toaster } from "./components/ui/sonner"; import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; import VideoEditor from "./components/video-editor/VideoEditor"; import { useI18n } from "./contexts/I18nContext"; @@ -41,7 +42,12 @@ export default function App() { switch (windowType) { case "hud-overlay": - return ; + return ( + <> + + + + ); case "source-selector": return ; case "countdown": diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 41ac4a7b..493457f7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -397,6 +397,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void window.electronAPI.openSourceSelector(); } else { console.error(state.message); + toast.error(state.message); } }); @@ -476,9 +477,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (useNativeMacScreenCapture || useWgcCapture) { - // WGC: resolve mic device label for native WASAPI capture + // Resolve the selected mic label for native capture backends. let micLabel: string | undefined; - if (useWgcCapture && microphoneEnabled) { + if (microphoneEnabled) { try { const devices = await navigator.mediaDevices.enumerateDevices(); const mic = devices.find( @@ -486,7 +487,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); micLabel = mic?.label || undefined; } catch { - // Fall through — native process will use default mic + // Fall through — native process will use the default mic } }