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
}
}