diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index e675dd0f..3b11532d 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -104,6 +104,10 @@ minisign-verify = "0.2" block2 = "0.5" core-foundation = "0.10" core-graphics = "0.24" +# issue #470:托盘麦克风设备变更改用 CoreAudio AudioObjectAddPropertyListener 原生通知 +# (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为 +# 直接依赖零额外编译成本。 +coreaudio-sys = "0.2" objc2 = "0.5" objc2-foundation = "0.2" objc2-app-kit = "0.2" diff --git a/openless-all/app/src-tauri/src/device_watch.rs b/openless-all/app/src-tauri/src/device_watch.rs new file mode 100644 index 00000000..f538cd1c --- /dev/null +++ b/openless-all/app/src-tauri/src/device_watch.rs @@ -0,0 +1,166 @@ +//! 托盘麦克风设备变更的 OS 原生监听(issue #470)。 +//! +//! 目的:替代「每 10s 轮询 `list_input_devices()`」这条空闲唤醒。改用各平台原生的设备 +//! 变更通知,空闲时零唤醒,设备插拔/默认设备切换时由 OS 回调实时触发刷新。 +//! +//! 平台分流: +//! - macOS:CoreAudio `AudioObjectAddPropertyListener` 监听 +//! `kAudioHardwarePropertyDevices`,专用线程跑 `CFRunLoop` 常驻。 +//! - Windows / Linux:暂返回 `false`,由 `lib.rs` 的 60s 慢速兜底轮询负责。 +//! (Windows 原生 `IMMNotificationClient` 通知留作后续,需 Windows 开发机验证。) +//! +//! 三平台共用契约:`spawn_native_watcher(app, on_change)`。`on_change` 是 `lib.rs` 提供 +//! 的去抖闭包(内部复用 `microphone_device_signature()`,真正变化才刷新+emit)。回调里 +//! 只调用 `on_change`,不做别的。注册失败一律返回 `false`(只 warn 不 panic),交由 +//! 兜底轮询兜底,保证三平台都「永远能检测到设备」。 + +use tauri::AppHandle; + +/// 注册 OS 原生设备变更监听。成功返回 `true`,平台不支持或注册失败返回 `false`。 +/// +/// `on_change` 在 OS 回调线程上被调用(可能并发/重复),其内部负责去抖与线程派发。 +#[cfg(target_os = "macos")] +pub(crate) fn spawn_native_watcher(_app: AppHandle, on_change: F) -> bool +where + F: Fn() + Send + Sync + 'static, +{ + macos::spawn(on_change) +} + +/// 非 macOS(Windows / Linux):暂无本地验证过的原生路径,返回 `false`,纯靠 `lib.rs` +/// 的 60s 慢速兜底轮询。Windows 原生 `IMMNotificationClient` 留作后续(需 Windows 开发机验证)。 +#[cfg(not(target_os = "macos"))] +pub(crate) fn spawn_native_watcher(_app: AppHandle, _on_change: F) -> bool +where + F: Fn() + Send + Sync + 'static, +{ + false +} + +// =================================================================================== +// macOS — CoreAudio AudioObjectAddPropertyListener +// =================================================================================== +#[cfg(target_os = "macos")] +mod macos { + use coreaudio_sys::{ + kAudioHardwarePropertyDevices, kAudioObjectPropertyElementMain, + kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, AudioObjectAddPropertyListener, + AudioObjectID, AudioObjectPropertyAddress, AudioObjectRemovePropertyListener, OSStatus, + UInt32, + }; + use std::ffi::c_void; + use std::sync::atomic::Ordering; + use std::time::Duration; + + use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoop, CFRunLoopRunResult}; + + use super::super::TRAY_MICROPHONE_WATCHER_STOPPING; + + /// 把用户闭包(胖指针)经单个 `*mut c_void` 传进 C 回调的双重间接封装。 + /// 照抄 cpal 的 `PropertyListenerCallbackWrapper` 模式 + /// (cpal-0.15.3/src/host/coreaudio/macos/property_listener.rs)。 + struct ListenerWrapper(Box); + + /// CoreAudio 属性监听回调 shim:把 `*mut c_void` 还原成用户闭包并调用。 + /// 照抄 cpal 的 `property_listener_handler_shim`。 + /// + /// # Safety + /// `user_data` 必须是 `spawn` 里 `AudioObjectAddPropertyListener` 注册时传入、且在监听 + /// 存活期间一直有效的 `*const ListenerWrapper`(由常驻线程持有,不会提前释放)。 + unsafe extern "C" fn listener_shim( + _object: AudioObjectID, + _num_addresses: UInt32, + _addresses: *const AudioObjectPropertyAddress, + user_data: *mut c_void, + ) -> OSStatus { + // SAFETY: user_data 是注册时传入的 &ListenerWrapper(见下方 SAFETY 注释), + // 监听存活期间常驻线程一直持有它,故此处解引用有效。 + let wrapper = &*(user_data as *const ListenerWrapper); + (wrapper.0)(); + 0 + } + + /// 在专用线程注册 CoreAudio 设备变更监听并跑 CFRunLoop 常驻。 + /// 成功返回 `true`(线程已起且监听已注册);注册失败返回 `false`。 + pub(super) fn spawn(on_change: F) -> bool + where + F: Fn() + Send + Sync + 'static, + { + let (tx, rx) = std::sync::mpsc::channel::(); + let spawn_result = std::thread::Builder::new() + .name("openless-mic-coreaudio".into()) + .spawn(move || { + // wrapper 必须活过整个监听期,故 leak/常驻在本线程栈上直到 runloop 退出。 + let wrapper = ListenerWrapper(Box::new(on_change)); + let address = AudioObjectPropertyAddress { + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + // SAFETY: kAudioObjectSystemObject 是合法的系统级 AudioObjectID;address 指向 + // 本栈上有效结构;listener_shim 是 'static extern "C" 回调;&wrapper 在整个 + // runloop 期间存活(直到本线程退出),满足 CoreAudio 对 user_data 生命周期的 + // 要求。返回值是 OSStatus,0 表示成功。 + let status: OSStatus = unsafe { + AudioObjectAddPropertyListener( + kAudioObjectSystemObject as AudioObjectID, + &address as *const _, + Some(listener_shim), + &wrapper as *const _ as *mut c_void, + ) + }; + + if status != 0 { + log::warn!( + "[device_watch] AudioObjectAddPropertyListener failed: OSStatus={status}" + ); + let _ = tx.send(false); + return; + } + let _ = tx.send(true); + + // CFRunLoop 常驻。用 `run_in_mode` 短超时轮转代替 `CFRunLoopRun()`, + // 每 1s 醒来一次检查退出 flag——避免跨线程 CFRunLoopStop 的竞态与线程泄漏。 + // CoreAudio 回调照常在 run_in_mode 内被派发(属于 default mode)。 + while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + // SAFETY: kCFRunLoopDefaultMode 是 CoreFoundation 提供的 'static 常量字符串。 + let mode = unsafe { kCFRunLoopDefaultMode }; + let result = CFRunLoop::run_in_mode(mode, Duration::from_secs(1), false); + // Finished 表示 runloop 立即返回(没有任何 input source)。CoreAudio 监听 + // 本身会给 default mode 装上 source,正常不会走到这里;但极端情况下用一小段 + // sleep 避免空转 busy loop,再回到顶部按退出 flag 判断。 + if matches!(result, CFRunLoopRunResult::Finished) { + std::thread::sleep(Duration::from_millis(200)); + } + } + + // SAFETY: 与注册时同一组 (object, address, shim, user_data),且 wrapper 仍存活。 + // 退出前移除监听,避免 CoreAudio 持有悬垂指针。 + let remove_status: OSStatus = unsafe { + AudioObjectRemovePropertyListener( + kAudioObjectSystemObject as AudioObjectID, + &address as *const _, + Some(listener_shim), + &wrapper as *const _ as *mut c_void, + ) + }; + if remove_status != 0 { + log::warn!( + "[device_watch] AudioObjectRemovePropertyListener failed: OSStatus={remove_status}" + ); + } + // wrapper 在此 drop——此时监听已移除,C 侧不再回调,安全。 + let _ = &wrapper; + }); + + if let Err(err) = spawn_result { + log::warn!("[device_watch] spawn CoreAudio watcher thread failed: {err}"); + return false; + } + + // 等线程报告注册结果(注册是同步的、瞬时的)。线程崩溃/通道断开按失败处理。 + rx.recv().unwrap_or(false) + } +} + diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index f358e62b..86dc5599 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -28,6 +28,10 @@ mod commands; mod coordinator; mod coordinator_state; mod correction; +// 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), +// Linux 退化为纯轮询兜底。仅桌面端。详见 issue #470。 +#[cfg(not(mobile))] +mod device_watch; mod external_url; #[cfg(not(mobile))] mod global_hotkey_runtime; @@ -902,21 +906,74 @@ fn microphone_device_signature() -> Option> { } } +/// 在主线程上刷新托盘麦克风子菜单并通知前端。供 OS 原生设备变更回调与慢速兜底轮询 +/// 共用同一条收尾路径。已在主线程或被 `run_on_main_thread` 派发后调用。 +#[cfg(not(mobile))] +fn refresh_microphone_on_main(app: &AppHandle) { + if let Err(err) = refresh_tray_microphone_menu(app) { + log::warn!("[tray] refresh microphone menu after device change failed: {err}"); + } + let _ = app.emit("microphone:devices-changed", serde_json::json!({})); +} + +/// 设备变更去抖闭包:被 OS 原生通知回调(macOS CoreAudio / Windows MMDevice)调用。 +/// 复用 `microphone_device_signature()` 去抖——签名没变就零副作用直接返回;变了才 +/// `run_on_main_thread` 派发刷新+emit。OS 通知可能合并/重复触发,去抖确保只在真正 +/// 变化时刷新。`last_signature` 用 `Mutex` 保护,因为回调可能从不同的 CoreAudio/COM +/// 线程并发进入。 +#[cfg(not(mobile))] +fn make_microphone_change_handler(app: AppHandle) -> impl Fn() + Send + Sync + 'static { + let last_signature = parking_lot::Mutex::new(microphone_device_signature()); + move || { + let signature = microphone_device_signature(); + { + let mut guard = last_signature.lock(); + if signature == *guard { + return; + } + *guard = signature; + } + let refresh_app = app.clone(); + let _ = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app)); + } +} + #[cfg(not(mobile))] fn start_tray_microphone_watcher(app: AppHandle) { TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed); + + // 1) OS 原生设备变更通知(issue #470 的最优方案):空闲零唤醒。 + // macOS → CoreAudio AudioObjectAddPropertyListener;Windows → IMMNotificationClient。 + // Linux 无原生路径,返回 false,纯靠下面的慢速兜底。 + // 注册失败(OSStatus≠0 / RegisterEndpoint Err)只 warn,不 panic——兜底轮询保证 + // 三平台都「永远能检测到设备」。 + let native_registered = + device_watch::spawn_native_watcher(app.clone(), make_microphone_change_handler(app.clone())); + if native_registered { + log::info!("[tray] OS native microphone device watcher registered"); + } else { + log::info!( + "[tray] no OS native microphone device watcher (unsupported platform or registration failed); relying on slow poll fallback" + ); + } + + // 2) 全平台慢速兜底:60s 无条件轮询,复用 signature 去抖(签名没变就 continue,零 + // 副作用)。原生通知失败时由它保证设备变更最终被检测到;原生通知正常时它只是 + // 极低频的安全网,几乎从不真正刷新。 if let Err(err) = std::thread::Builder::new() - .name("openless-tray-mic-watch".into()) + .name("openless-tray-mic-poll".into()) .spawn(move || { let mut last_signature = microphone_device_signature(); while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { - // 10s, not 1.5s. `list_input_devices()` is a relatively costly - // CoreAudio/WASAPI enumeration and this ran every 1.5s forever — - // the single biggest idle wakeup. The tray menu refreshes on hover - // and the settings page reacts to `microphone:devices-changed`, so - // ~10s detection latency is fine. (Proper fix: subscribe to an OS - // device-change notification instead of polling.) - std::thread::sleep(Duration::from_millis(10_000)); + // 60s(而非 10s):原生通知承担实时检测,这条线程只是兜底,把它拉到 60s + // 进一步压低空闲唤醒。1s 一片的睡眠让退出 flag 最多 1s 内生效,避免退出时 + // 长时间挂起线程。 + for _ in 0..60 { + if TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(Duration::from_secs(1)); + } if TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { break; } @@ -925,20 +982,12 @@ fn start_tray_microphone_watcher(app: AppHandle) { continue; } last_signature = signature; - let app = app.clone(); let refresh_app = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = refresh_tray_microphone_menu(&refresh_app) { - log::warn!( - "[tray] refresh microphone menu after device change failed: {err}" - ); - } - let _ = refresh_app.emit("microphone:devices-changed", serde_json::json!({})); - }); + let _ = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app)); } }) { - log::warn!("[tray] start microphone watcher failed: {err}"); + log::warn!("[tray] start microphone poll fallback failed: {err}"); } }