fix: restore cursor fallback and mac mic selection

This commit is contained in:
webadderall
2026-03-18 21:19:00 +11:00
parent ab1a163281
commit 26a8e069e8
7 changed files with 185 additions and 11 deletions
+141
View File
@@ -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
}
}
+11 -6
View File
@@ -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 () => {
+2
View File
@@ -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', () => {
+20 -1
View File
@@ -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:")))
+7 -1
View File
@@ -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 <LaunchWindow />;
return (
<>
<LaunchWindow />
<Toaster theme="dark" className="pointer-events-auto" />
</>
);
case "source-selector":
return <SourceSelector />;
case "countdown":
+4 -3
View File
@@ -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
}
}