perf(tray): 托盘麦克风设备监听改 OS 原生通知,空闲零唤醒 + 60s 兜底 (#670)

* perf(tray): 托盘麦克风设备监听改 OS 原生通知,空闲零唤醒 + 60s 兜底

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 构建时自愈。

* fix(tray): 砍掉无法本机验证的 Windows IMMNotificationClient 原生路径

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。

---------

Co-authored-by: 吕柏青 <lubaiqing9@gmail.com>
This commit is contained in:
TRIPand吕柏青 authored and GitHub committed 2026-06-16 11:10:22 +08:00
1 parent 2d783ce337
commit 49a6dc800f
3 files changed
+237 -18

No files matched your search

+4
View File
@@ -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"
@@ -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<F>(_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<F>(_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<dyn Fn() + Send + Sync>);
/// 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<F>(on_change: F) -> bool
where
F: Fn() + Send + Sync + 'static,
{
let (tx, rx) = std::sync::mpsc::channel::<bool>();
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)
}
}
+67 -18
View File
@@ -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<Vec<(String, bool)>> {
}
}
/// 在主线程上刷新托盘麦克风子菜单并通知前端。供 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}");
}
}