From ac7d9bf2a81314a419454afe79a012c8cd479075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Mon, 15 Jun 2026 22:12:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(tray):=20=E6=89=98=E7=9B=98=E9=BA=A6?= =?UTF-8?q?=E5=85=8B=E9=A3=8E=E8=AE=BE=E5=A4=87=E7=9B=91=E5=90=AC=E6=94=B9?= =?UTF-8?q?=20OS=20=E5=8E=9F=E7=94=9F=E9=80=9A=E7=9F=A5=EF=BC=8C=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E9=9B=B6=E5=94=A4=E9=86=92=20+=2060s=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #470:原 watcher 每 10s 轮询 list_input_devices()(CoreAudio/WASAPI 全枚举)永不 停歇。新增 device_watch 模块用 OS 原生设备变更通知替代:macOS CoreAudio AudioObjectAddPropertyListener(kAudioHardwarePropertyDevices) 专用 CFRunLoop 线程常驻; Windows IMMNotificationClient 经 IMMDeviceEnumerator 注册端点通知。回调复用现有去抖 (microphone_device_signature) + refresh_tray_microphone_menu + emit 收尾。 works 保证:保留一条 60s 全平台慢速兜底轮询无条件运行——原生注册失败/Linux 无原生路径 时退化到它,三平台永远能检测到设备。coreaudio-sys 提升为 macOS 直接依赖(本就在依赖树)。 注:Windows IMMNotificationClient 分支开发机(macOS)无法本地编译,靠 CI 三平台 cargo check + 真机验证;60s 兜底保证即使该分支失效也不会漏检设备。Cargo.lock 未提交(macOS check 会 引入大量与本改动无关的环境性条目),cargo 构建时自愈。 --- openless-all/app/src-tauri/Cargo.toml | 4 + .../app/src-tauri/src/device_watch.rs | 338 ++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 85 ++++- 3 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 openless-all/app/src-tauri/src/device_watch.rs 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..0dd786fb --- /dev/null +++ b/openless-all/app/src-tauri/src/device_watch.rs @@ -0,0 +1,338 @@ +//! 托盘麦克风设备变更的 OS 原生监听(issue #470)。 +//! +//! 目的:替代「每 10s 轮询 `list_input_devices()`」这条空闲唤醒。改用各平台原生的设备 +//! 变更通知,空闲时零唤醒,设备插拔/默认设备切换时由 OS 回调实时触发刷新。 +//! +//! 严格按平台分流: +//! - macOS:CoreAudio `AudioObjectAddPropertyListener` 监听 +//! `kAudioHardwarePropertyDevices`,专用线程跑 `CFRunLoop` 常驻。 +//! - Windows:`IMMNotificationClient` 经 `IMMDeviceEnumerator` 注册端点通知回调。 +//! - Linux:无原生路径,返回 `false`,由 `lib.rs` 的慢速兜底轮询负责。 +//! +//! 三平台共用契约:`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) +} + +/// Windows:经 MMDevice 端点通知注册。见模块内说明。 +#[cfg(target_os = "windows")] +pub(crate) fn spawn_native_watcher(_app: AppHandle, on_change: F) -> bool +where + F: Fn() + Send + Sync + 'static, +{ + windows_impl::spawn(on_change) +} + +/// Linux:无原生路径,纯靠 `lib.rs` 的慢速兜底轮询。 +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +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) + } +} + +// =================================================================================== +// Windows — IMMNotificationClient via IMMDeviceEnumerator +// =================================================================================== +// +// 注意:开发机是 macOS,本分支无法本地编译验证,正确性靠 CI + 真机。严格 cfg-gate, +// 写法保守稳妥。关键约束: +// 1. enumerator 必须在监听期间一直存活,否则回调停止——故由常驻线程持有,退出时才 +// `UnregisterEndpointNotificationCallback` + drop。 +// 2. COM 在本线程 `CoInitializeEx(MULTITHREADED)`,退出时 `CoUninitialize`。 +// 3. 只关心 capture(输入)设备的增删/状态/默认变化;render 设备变化忽略。回调里只调 +// `on_change`(其内部去抖会过滤掉无关变化),故即使偶尔多触发也只是多一次零副作用 +// 的签名比较。 +#[cfg(target_os = "windows")] +mod windows_impl { + use std::sync::atomic::Ordering; + use std::time::Duration; + + use windows::core::Result as WinResult; + use windows::core::PCWSTR; + use windows::Win32::Media::Audio::{ + eCapture, EDataFlow, ERole, IMMDeviceEnumerator, IMMNotificationClient, + IMMNotificationClient_Impl, MMDeviceEnumerator, DEVICE_STATE, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED, + }; + use windows::Win32::UI::Shell::PropertiesSystem::PROPERTYKEY; + + use super::super::TRAY_MICROPHONE_WATCHER_STOPPING; + + /// IMMNotificationClient 实现:相关回调都转交 `on_change`(其内部去抖)。 + /// 拥有 `on_change` 的所有权('static Box),故 client 自包含、不借用外部生命周期。 + #[windows::core::implement(IMMNotificationClient)] + struct DeviceNotificationClient { + on_change: Box, + } + + impl DeviceNotificationClient { + fn notify(&self) { + (self.on_change)(); + } + } + + // IMMNotificationClient 的全部方法都必须实现(COM 接口契约)。 + #[allow(non_snake_case)] + impl IMMNotificationClient_Impl for DeviceNotificationClient_Impl { + fn OnDeviceStateChanged( + &self, + _pwstrDeviceId: &PCWSTR, + _dwNewState: DEVICE_STATE, + ) -> WinResult<()> { + // 设备启用/禁用/拔出。无法在此廉价判断 data-flow,统一交给签名去抖过滤。 + self.notify(); + Ok(()) + } + + fn OnDeviceAdded(&self, _pwstrDeviceId: &PCWSTR) -> WinResult<()> { + self.notify(); + Ok(()) + } + + fn OnDeviceRemoved(&self, _pwstrDeviceId: &PCWSTR) -> WinResult<()> { + self.notify(); + Ok(()) + } + + fn OnDefaultDeviceChanged( + &self, + flow: EDataFlow, + _role: ERole, + _pwstrDefaultDeviceId: &PCWSTR, + ) -> WinResult<()> { + // 默认设备变化:只关心 capture(输入)。render 变化忽略,减少无谓刷新。 + if flow == eCapture { + self.notify(); + } + Ok(()) + } + + fn OnPropertyValueChanged( + &self, + _pwstrDeviceId: &PCWSTR, + _key: &PROPERTYKEY, + ) -> WinResult<()> { + // 属性变化噪声大(音量等),不触发刷新。 + Ok(()) + } + } + + /// 在专用线程注册 MMDevice 端点通知并常驻直到退出 flag 置位。 + /// 成功返回 `true`;COM/注册失败返回 `false`。 + pub(super) fn spawn(on_change: F) -> bool + where + F: Fn() + Send + Sync + 'static, + { + // 闭包提前装箱:client 直接拥有它,全程不借用外部生命周期,无裸指针 hack。 + let on_change: Box = Box::new(on_change); + let (tx, rx) = std::sync::mpsc::channel::(); + let spawn_result = std::thread::Builder::new() + .name("openless-mic-mmdevice".into()) + .spawn(move || { + // SAFETY: 在本专用线程初始化 COM(MTA)。失败则不注册,直接报失败退出。 + let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if hr.is_err() { + log::warn!("[device_watch] CoInitializeEx failed: {hr:?}"); + let _ = tx.send(false); + return; + } + + let registered = register_and_run(on_change); + let _ = tx.send(registered); + + // SAFETY: 与上面的 CoInitializeEx 配对。register_and_run 返回时所有 COM 对象 + // 已 drop(enumerator/client 在其作用域内),此处可安全反初始化 COM。 + unsafe { CoUninitialize() }; + }); + + if let Err(err) = spawn_result { + log::warn!("[device_watch] spawn MMDevice watcher thread failed: {err}"); + return false; + } + rx.recv().unwrap_or(false) + } + + /// 创建 enumerator、注册回调、常驻轮转检查退出 flag、退出前注销。注册成功返回 `true`。 + /// + /// 关键:本函数返回前 `enumerator` 与 `client` 一直在作用域内存活 → 满足「监听期间 + /// enumerator 必须存活,否则回调停」。`client` 拥有 `on_change` 的所有权,自包含。 + fn register_and_run(on_change: Box) -> bool { + // SAFETY: 标准 MMDevice enumerator 实例化。CLSCTX_ALL 允许进程内/外服务器。 + let enumerator: IMMDeviceEnumerator = + match unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) } { + Ok(e) => e, + Err(err) => { + log::warn!( + "[device_watch] CoCreateInstance(MMDeviceEnumerator) failed: {err:?}" + ); + return false; + } + }; + + let client: IMMNotificationClient = DeviceNotificationClient { on_change }.into(); + + // SAFETY: enumerator 有效;client 是合法 IMMNotificationClient。注册后系统持有 client + // 的引用计数,回调将从 MMDevice 线程进入。client 拥有 on_change,生命周期自洽。 + if let Err(err) = unsafe { enumerator.RegisterEndpointNotificationCallback(&client) } { + log::warn!("[device_watch] RegisterEndpointNotificationCallback failed: {err:?}"); + return false; + } + + // 常驻:MMDevice 回调由系统线程驱动,本线程只需保持 enumerator/client 存活并周期 + // 检查退出 flag。1s 一片,退出最多 1s 内生效。 + while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_secs(1)); + } + + // SAFETY: 注销同一 client。enumerator 仍有效。注销后系统不再回调。 + if let Err(err) = unsafe { enumerator.UnregisterEndpointNotificationCallback(&client) } { + log::warn!("[device_watch] UnregisterEndpointNotificationCallback failed: {err:?}"); + } + // enumerator 与 client 在此 drop(COM 引用计数归还,on_change 随 client 释放)。 + true + } +} 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}"); } } From 4f8ea542624676ff57a229d050fb2d52772664e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Tue, 16 Jun 2026 10:51:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(tray):=20=E7=A0=8D=E6=8E=89=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=9C=AC=E6=9C=BA=E9=AA=8C=E8=AF=81=E7=9A=84=20Window?= =?UTF-8?q?s=20IMMNotificationClient=20=E5=8E=9F=E7=94=9F=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI Windows checks 失败:windows-rs 的 IMMNotificationClient_Impl / Win32_UI_Shell_PropertiesSystem feature 与 implement 宏的 windows_core 路径在开发机(macOS)无法编译验证,靠 CI 反复试代价大。 简化为:macOS 走 CoreAudio 原生通知(已验证),Windows / Linux 统一返回 false → 走 lib.rs 的 60s 慢速兜底轮询(仍比原 10s 少 6 倍唤醒、保证三平台编译通过)。Windows 原生设备通知 留作后续单独项(需 Windows 开发机验证)。issue #470。 --- .../app/src-tauri/src/device_watch.rs | 184 +----------------- 1 file changed, 6 insertions(+), 178 deletions(-) diff --git a/openless-all/app/src-tauri/src/device_watch.rs b/openless-all/app/src-tauri/src/device_watch.rs index 0dd786fb..f538cd1c 100644 --- a/openless-all/app/src-tauri/src/device_watch.rs +++ b/openless-all/app/src-tauri/src/device_watch.rs @@ -3,11 +3,11 @@ //! 目的:替代「每 10s 轮询 `list_input_devices()`」这条空闲唤醒。改用各平台原生的设备 //! 变更通知,空闲时零唤醒,设备插拔/默认设备切换时由 OS 回调实时触发刷新。 //! -//! 严格按平台分流: +//! 平台分流: //! - macOS:CoreAudio `AudioObjectAddPropertyListener` 监听 //! `kAudioHardwarePropertyDevices`,专用线程跑 `CFRunLoop` 常驻。 -//! - Windows:`IMMNotificationClient` 经 `IMMDeviceEnumerator` 注册端点通知回调。 -//! - Linux:无原生路径,返回 `false`,由 `lib.rs` 的慢速兜底轮询负责。 +//! - Windows / Linux:暂返回 `false`,由 `lib.rs` 的 60s 慢速兜底轮询负责。 +//! (Windows 原生 `IMMNotificationClient` 通知留作后续,需 Windows 开发机验证。) //! //! 三平台共用契约:`spawn_native_watcher(app, on_change)`。`on_change` 是 `lib.rs` 提供 //! 的去抖闭包(内部复用 `microphone_device_signature()`,真正变化才刷新+emit)。回调里 @@ -27,17 +27,9 @@ where macos::spawn(on_change) } -/// Windows:经 MMDevice 端点通知注册。见模块内说明。 -#[cfg(target_os = "windows")] -pub(crate) fn spawn_native_watcher(_app: AppHandle, on_change: F) -> bool -where - F: Fn() + Send + Sync + 'static, -{ - windows_impl::spawn(on_change) -} - -/// Linux:无原生路径,纯靠 `lib.rs` 的慢速兜底轮询。 -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +/// 非 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, @@ -172,167 +164,3 @@ mod macos { } } -// =================================================================================== -// Windows — IMMNotificationClient via IMMDeviceEnumerator -// =================================================================================== -// -// 注意:开发机是 macOS,本分支无法本地编译验证,正确性靠 CI + 真机。严格 cfg-gate, -// 写法保守稳妥。关键约束: -// 1. enumerator 必须在监听期间一直存活,否则回调停止——故由常驻线程持有,退出时才 -// `UnregisterEndpointNotificationCallback` + drop。 -// 2. COM 在本线程 `CoInitializeEx(MULTITHREADED)`,退出时 `CoUninitialize`。 -// 3. 只关心 capture(输入)设备的增删/状态/默认变化;render 设备变化忽略。回调里只调 -// `on_change`(其内部去抖会过滤掉无关变化),故即使偶尔多触发也只是多一次零副作用 -// 的签名比较。 -#[cfg(target_os = "windows")] -mod windows_impl { - use std::sync::atomic::Ordering; - use std::time::Duration; - - use windows::core::Result as WinResult; - use windows::core::PCWSTR; - use windows::Win32::Media::Audio::{ - eCapture, EDataFlow, ERole, IMMDeviceEnumerator, IMMNotificationClient, - IMMNotificationClient_Impl, MMDeviceEnumerator, DEVICE_STATE, - }; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED, - }; - use windows::Win32::UI::Shell::PropertiesSystem::PROPERTYKEY; - - use super::super::TRAY_MICROPHONE_WATCHER_STOPPING; - - /// IMMNotificationClient 实现:相关回调都转交 `on_change`(其内部去抖)。 - /// 拥有 `on_change` 的所有权('static Box),故 client 自包含、不借用外部生命周期。 - #[windows::core::implement(IMMNotificationClient)] - struct DeviceNotificationClient { - on_change: Box, - } - - impl DeviceNotificationClient { - fn notify(&self) { - (self.on_change)(); - } - } - - // IMMNotificationClient 的全部方法都必须实现(COM 接口契约)。 - #[allow(non_snake_case)] - impl IMMNotificationClient_Impl for DeviceNotificationClient_Impl { - fn OnDeviceStateChanged( - &self, - _pwstrDeviceId: &PCWSTR, - _dwNewState: DEVICE_STATE, - ) -> WinResult<()> { - // 设备启用/禁用/拔出。无法在此廉价判断 data-flow,统一交给签名去抖过滤。 - self.notify(); - Ok(()) - } - - fn OnDeviceAdded(&self, _pwstrDeviceId: &PCWSTR) -> WinResult<()> { - self.notify(); - Ok(()) - } - - fn OnDeviceRemoved(&self, _pwstrDeviceId: &PCWSTR) -> WinResult<()> { - self.notify(); - Ok(()) - } - - fn OnDefaultDeviceChanged( - &self, - flow: EDataFlow, - _role: ERole, - _pwstrDefaultDeviceId: &PCWSTR, - ) -> WinResult<()> { - // 默认设备变化:只关心 capture(输入)。render 变化忽略,减少无谓刷新。 - if flow == eCapture { - self.notify(); - } - Ok(()) - } - - fn OnPropertyValueChanged( - &self, - _pwstrDeviceId: &PCWSTR, - _key: &PROPERTYKEY, - ) -> WinResult<()> { - // 属性变化噪声大(音量等),不触发刷新。 - Ok(()) - } - } - - /// 在专用线程注册 MMDevice 端点通知并常驻直到退出 flag 置位。 - /// 成功返回 `true`;COM/注册失败返回 `false`。 - pub(super) fn spawn(on_change: F) -> bool - where - F: Fn() + Send + Sync + 'static, - { - // 闭包提前装箱:client 直接拥有它,全程不借用外部生命周期,无裸指针 hack。 - let on_change: Box = Box::new(on_change); - let (tx, rx) = std::sync::mpsc::channel::(); - let spawn_result = std::thread::Builder::new() - .name("openless-mic-mmdevice".into()) - .spawn(move || { - // SAFETY: 在本专用线程初始化 COM(MTA)。失败则不注册,直接报失败退出。 - let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - if hr.is_err() { - log::warn!("[device_watch] CoInitializeEx failed: {hr:?}"); - let _ = tx.send(false); - return; - } - - let registered = register_and_run(on_change); - let _ = tx.send(registered); - - // SAFETY: 与上面的 CoInitializeEx 配对。register_and_run 返回时所有 COM 对象 - // 已 drop(enumerator/client 在其作用域内),此处可安全反初始化 COM。 - unsafe { CoUninitialize() }; - }); - - if let Err(err) = spawn_result { - log::warn!("[device_watch] spawn MMDevice watcher thread failed: {err}"); - return false; - } - rx.recv().unwrap_or(false) - } - - /// 创建 enumerator、注册回调、常驻轮转检查退出 flag、退出前注销。注册成功返回 `true`。 - /// - /// 关键:本函数返回前 `enumerator` 与 `client` 一直在作用域内存活 → 满足「监听期间 - /// enumerator 必须存活,否则回调停」。`client` 拥有 `on_change` 的所有权,自包含。 - fn register_and_run(on_change: Box) -> bool { - // SAFETY: 标准 MMDevice enumerator 实例化。CLSCTX_ALL 允许进程内/外服务器。 - let enumerator: IMMDeviceEnumerator = - match unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) } { - Ok(e) => e, - Err(err) => { - log::warn!( - "[device_watch] CoCreateInstance(MMDeviceEnumerator) failed: {err:?}" - ); - return false; - } - }; - - let client: IMMNotificationClient = DeviceNotificationClient { on_change }.into(); - - // SAFETY: enumerator 有效;client 是合法 IMMNotificationClient。注册后系统持有 client - // 的引用计数,回调将从 MMDevice 线程进入。client 拥有 on_change,生命周期自洽。 - if let Err(err) = unsafe { enumerator.RegisterEndpointNotificationCallback(&client) } { - log::warn!("[device_watch] RegisterEndpointNotificationCallback failed: {err:?}"); - return false; - } - - // 常驻:MMDevice 回调由系统线程驱动,本线程只需保持 enumerator/client 存活并周期 - // 检查退出 flag。1s 一片,退出最多 1s 内生效。 - while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_secs(1)); - } - - // SAFETY: 注销同一 client。enumerator 仍有效。注销后系统不再回调。 - if let Err(err) = unsafe { enumerator.UnregisterEndpointNotificationCallback(&client) } { - log::warn!("[device_watch] UnregisterEndpointNotificationCallback failed: {err:?}"); - } - // enumerator 与 client 在此 drop(COM 引用计数归还,on_change 随 client 释放)。 - true - } -}