mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
fix: restore cursor fallback and mac mic selection
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:")))
|
||||
|
||||
Binary file not shown.
+7
-1
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user