Skip to content

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

Merged
appergb merged 2 commits into
betafrom
fix/470-tray-mic-os-notify
Jun 16, 2026
Merged

perf(tray): 托盘麦克风设备监听改 OS 原生通知,空闲零唤醒 + 60s 兜底#670
appergb merged 2 commits into
betafrom
fix/470-tray-mic-os-notify

Conversation

@appergb

@appergb appergb commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

User description

源自 #470 Cloud 性能审查 P0。原 watcher 每 10s 轮询 list_input_devices()(CoreAudio/WASAPI 全枚举)永不停歇。

改动:新增 device_watch 模块用 OS 原生设备变更通知替代轮询——macOS AudioObjectAddPropertyListener(kAudioHardwarePropertyDevices) 专用 CFRunLoop 线程常驻;Windows IMMNotificationClientIMMDeviceEnumerator 注册端点通知。回调复用现有去抖 + refresh_tray_microphone_menu + emit。

works 保证:保留一条 60s 全平台慢速兜底轮询无条件运行——原生注册失败 / Linux 无原生路径时退化到它,三平台永远能检测到设备。coreaudio-sys 提升为 macOS 直接依赖(本就在依赖树)。

验证:macOS cargo check 通过。⚠️ Windows IMMNotificationClient 分支开发机(macOS)无法本地编译,请 CI 三平台 cargo check + Windows 真机验证;60s 兜底保证即使该分支失效也不漏检设备。Cargo.lock 未含本次 dep(macOS check 会引入大量无关环境性条目),cargo 构建自愈。


PR Type

Enhancement, Bug fix


Description

  • Replace 10s polling with OS-native device notifications (macOS CoreAudio) for zero idle wake-ups

  • Fallback 60s slow poll for Windows/Linux (6× lower wake-up rate than original)

  • Extract common refresh and dedup logic for reusable device-change handling


Diagram Walkthrough

flowchart LR
  subgraph start_tray_microphone_watcher
    A["macOS?"] -- yes --> B["spawn_native_watcher (CoreAudio)"]
    A -- "no (Windows / Linux)" --> C["return false"]
  end
  B -- "success?" --> D["native_registered = true"]
  B -- fail --> D
  D --> E["60s slow poll fallback unconditionally (1s sleep loop)"]
  E --> F["Check signature dedup (microphone_device_signature)"]
  F -- changed --> G["refresh_microphone_on_main (menu + emit)"]
  F -- unchanged --> E
Loading

File Walkthrough

Relevant files
Enhancement
device_watch.rs
Add device_watch module with macOS CoreAudio native listener

openless-all/app/src-tauri/src/device_watch.rs

  • New module for OS-native microphone device watching
  • macOS: spawn CoreAudio AudioObjectAddPropertyListener with dedicated
    CFRunLoop thread
  • Windows/Linux: return false (delegate to 60s fallback)
  • Handles registration failure gracefully with warning logs
+166/-0 
lib.rs
Integrate native watcher, reduce fallback poll to 60s, extract refresh
helper

openless-all/app/src-tauri/src/lib.rs

  • Integrate device_watch module via #[cfg(not(mobile))]
  • Extract refresh_microphone_on_main helper for menu refresh & event
    emit
  • Implement make_microphone_change_handler with signature dedup (Mutex)
  • Modify start_tray_microphone_watcher: attempt native watcher first,
    then 60s poll (1s sleep slices)
  • Reduce poll interval from 10s to 60s, making it true fallback
+67/-18 
Dependencies
Cargo.toml
Add coreaudio-sys dependency for macOS native device watch

openless-all/app/src-tauri/Cargo.toml

  • Add coreaudio-sys as direct macOS dependency for CoreAudio API access
+4/-0     

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

appergb commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

@claude 请审查本 PR(#470 性能修复 P0:托盘麦克风轮询→OS 原生通知 + 60s 兜底)。重点核查:(1) macOS CoreAudio listener 的 unsafe FFI——注册/注销配对、回调线程模型、CFRunLoop 退出无线程泄漏、SAFETY 注释;(2) Windows IMMNotificationClient COM 实现(开发机 macOS 无法本地编译,请你重点静态审 implement 宏用法、enumerator 生命周期、CoInitialize/Uninitialize 配对);(3) 60s 兜底是否无条件启动、注册失败是否 fail-safe 退化不 panic。

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 4f8ea54)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

470 - Partially compliant

Compliant requirements:

(none)

Non-compliant requirements:

  • The PR does not implement a fix for the missing capsule on Windows. It only adds macOS native notification and increases the polling interval to 60s, which may worsen detection latency on Windows.
  • The Windows specific issue (no capsule displayed) is not addressed.

Requires further human verification:

  • The impact of the increased polling interval (60s) on the Windows capsule display is unknown and needs verification.
  • The Windows native notification path remains unimplemented, so the fix for [Windows] [bug] 语音输入不显示录音胶囊 #470 on Windows is incomplete.
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Increased device detection latency on Windows/Linux

The previous 10s polling has been replaced with a 60s slow poll fallback on platforms without native notification support (Windows and Linux). This increases the maximum detection delay for device changes (e.g., plugging a microphone) from ~10s to ~60s. On Windows, where the original issue #470 reports missing capsule UI, this could make the problem worse by delaying the detection of device changes. While the PR aims to reduce idle wake-ups, the latency regression on Windows is significant and should be considered a trade-off that may contradict the user expectation of a "must fix" for UI feedback.

if let Err(err) = std::thread::Builder::new()
    .name("openless-tray-mic-poll".into())
    .spawn(move || {
        let mut last_signature = microphone_device_signature();
        while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) {
            // 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;
            }
            let signature = microphone_device_signature();
            if signature == last_signature {
                continue;
            }
            last_signature = signature;
            let refresh_app = app.clone();
            let _ = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app));
        }
    })
{
    log::warn!("[tray] start microphone poll fallback failed: {err}");

@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown

Claude finished @appergb's task in 5m 20s —— View job


PR #670 代码审查

审查任务列表:

  • 读取 PR 上下文和变更说明
  • 阅读 device_watch.rs(新模块,+338 行)
  • 阅读 lib.rs 变更(+67/-18)
  • 阅读 Cargo.toml 变更
  • 审查 macOS CoreAudio unsafe FFI(注册/注销配对、回调线程、CFRunLoop 退出、SAFETY 注释)
  • 审查 Windows IMMNotificationClient COM 实现(implement 宏、enumerator 生命周期、CoInitialize/Uninitialize)
  • 审查 60s 兜底是否无条件启动 + 注册失败 fail-safe 退化

总体评估

整体设计正确,fail-safe 机制到位,unsafe 代码有理有据。 以下按审查重点分项说明,包含 2 个需 CI 验证的潜在问题和若干低危 nitpick。


(1) macOS CoreAudio unsafe FFI 审查

✅ 注册/注销配对(device_watch.rs:113-155

四元组 (kAudioObjectSystemObject, &address, Some(listener_shim), &wrapper as *mut c_void) 在注册与注销时完全一致,满足 CoreAudio 的配对要求。注销在 TRAY_MICROPHONE_WATCHER_STOPPING flag 置位后、线程函数返回前调用,wrapper 此时仍在栈上,指针有效。✓

wrapper 生命周期与回调线程模型

wrapperspawn 闭包(即专用线程)的栈上变量,地址 &wrapper as *const _ as *mut c_void 作为 user_data 传入。CoreAudio 在本线程的 CFRunLoop 上派发回调(AudioObjectAddPropertyListener 注册后,回调由注册线程的 run loop 驱动),而该线程在 while !TRAY_MICROPHONE_WATCHER_STOPPING 期间一直持有闭包的栈帧,故 wrapper 的生命周期覆盖整个监听期。listener_shim 的 SAFETY 注释 (device_watch.rs:75-89) 正确描述了这一约束。✓

✅ CFRunLoop 退出无线程泄漏

使用 CFRunLoop::run_in_mode(mode, Duration::from_secs(1), false) 短超时轮转(device_watch.rs:137),每 1s 检查退出 flag,避免了 CFRunLoopRun() 的跨线程 CFRunLoopStop 竞态。Finished 情况下的 200ms sleep 防止空转。线程函数正常返回,无 std::mem::forgetBox::leak,无线程泄漏。✓

⚠️ 低危 nitpick:let _ = &wrapper 语义误导(device_watch.rs:162

// wrapper 在此 drop——此时监听已移除,C 侧不再回调,安全。
let _ = &wrapper;

let _ = expr立即 drop 生成的临时引用,并不延长 wrapper 的生命周期。wrapper 的实际生命周期由 Rust 的 drop 顺序保证(闭包作用域末尾),和这行代码无关。该行只是文档意图,但写法可能让读者误以为它有 "keep alive" 的效果。

Fix this →


(2) Windows IMMNotificationClient COM 实现静态审查

#[implement] 宏用法

#[windows::core::implement(IMMNotificationClient)]
struct DeviceNotificationClient { on_change: Box<dyn Fn() + Send + Sync> }

windows-rs 0.58 的 #[implement(IFoo)] 宏在 struct Bar 上生成 struct Bar_Impl,并生成 impl Deref for Bar_Impl { type Target = Bar; ... },因此 impl IMMNotificationClient_Impl for DeviceNotificationClient_Impl 中的 self.notify() 能正确通过 Deref 到 DeviceNotificationClient::notify()。所有 5 个必需方法(OnDeviceStateChanged / OnDeviceAdded / OnDeviceRemoved / OnDefaultDeviceChanged / OnPropertyValueChanged)均已实现,满足 COM 接口契约。✓

⚠️ 中危(需 CI 验证):PROPERTYKEY feature flag

// device_watch.rs:201
use windows::Win32::UI::Shell::PropertiesSystem::PROPERTYKEY;

Cargo.toml 中 windows 的 features 列出了 "Win32_UI_Shell"没有显式列出 "Win32_UI_Shell_PropertiesSystem"。在 windows-rs 0.58 中,features 不是自动递归启用的(父 feature 不隐式启用子 feature)。

不过 IMMNotificationClient_Impl::OnPropertyValueChanged 的签名本身就依赖 PROPERTYKEY,windows-rs 在 Win32_Media_Audio_Endpoints 中会为这个依赖补充声明,所以 PROPERTYKEY 通常通过 audio feature 传递可用——但 use windows::Win32::UI::Shell::PropertiesSystem::PROPERTYKEY 这条 import 路径能否通过编译取决于 Windows CI 目标的 feature 解析结果。若编译失败,修复方式是在 Cargo.toml 补加 "Win32_UI_Shell_PropertiesSystem",或将 import 改为 windows-rs 在 audio feature 下的再导出路径。

需要 Windows CI cargo check 确认。

✅ Enumerator 生命周期

enumeratorclient 均在 register_and_run 函数作用域内,while !TRAY_MICROPHONE_WATCHER_STOPPING 循环保持它们存活整个监听期。UnregisterEndpointNotificationCallback 在循环退出后、函数返回前调用,注销后 COM 对象自然 drop,引用计数归还。满足「enumerator 在监听期间必须存活,否则回调停」的约束。✓

CoInitialize / CoUninitialize 配对(device_watch.rs:277-289

let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
if hr.is_err() {          // S_FALSE (已初始化) 不触发 is_err(),与 S_OK 等同处理 ✓
    let _ = tx.send(false);
    return;               // CoInitialize 失败,不调 CoUninitialize ✓
}
// ... register_and_run() ...
unsafe { CoUninitialize() };  // 仅在 CoInitialize 成功时到达 ✓

HRESULT::is_err() 仅对负值(真正失败码)返回 true,S_FALSE (0x00000001,COM 已在本线程初始化) 被视为成功,最终仍会调 CoUninitialize 保持配对计数平衡。本线程由代码自身创建,不应出现 S_FALSE,但逻辑处理正确。✓

⚠️ 低危 nitpick:CLSCTX_ALL 建议改为 CLSCTX_INPROC_SERVERdevice_watch.rs:306

MMDeviceEnumerator 是进程内 COM 对象,CLSCTX_INPROC_SERVER 是惯用写法,CLSCTX_ALL 语义上允许进程外激活,范围略宽。功能上无差异,但代码清晰度略差。

Fix this →


(3) 60s 兜底无条件启动 + fail-safe 退化审查

✅ 60s 兜底无条件启动

start_tray_microphone_watcherlib.rs:942)的结构:

1) device_watch::spawn_native_watcher(...)  → native_registered (true/false)
2) thread::Builder::new().spawn(...)        // 60s 轮询,无条件启动,与 native_registered 无关

60s 兜底线程在 if let Err(err) = ... spawn(...) 之外,不受 native_registered 值控制,无条件启动。✓

✅ 注册失败 fail-safe 退化不 panic

失败点 行为
CoreAudio AudioObjectAddPropertyListener 返回非零 tx.send(false) + returnspawn_native_watcher 返回 false,仅 log::warn
Windows CoInitializeEx 失败 tx.send(false) + return,返回 false
Windows CoCreateInstance / RegisterEndpointNotificationCallback 失败 register_and_run 返回 falsetx.send(false)
线程 spawn 失败 rx.recv().unwrap_or(false)Err(RecvError)unwrap_or 吞掉
60s 轮询线程 spawn 失败 log::warn!,程序不 panic

全链路无 unwrap() / expect() / panic!,全部退化到仅打 warn 日志。✓

⚠️ 低危:双重 emit(原生通知 + 60s 兜底各自维护独立 last_signature

make_microphone_change_handler 在其 parking_lot::Mutex 中维护一份 last_signature,60s 轮询线程在 lib.rs:966 维护另一份独立的 last_signature。当两者都在运行时,一次设备变更会:

  • 原生通知回调 → 检测到变化 → emit microphone:devices-changed
  • 60s 后轮询 → 自己的 last_signature 仍是旧值 → 再次 emit

这会导致设备变更后 60s 内有两次 microphone:devices-changed 事件。由于前端处理该事件是刷新菜单(幂等),功能上无影响。但若需严格去重,可在 60s 轮询路径复用 make_microphone_change_handler 的同一个 last_signature 状态(或将 60s 轮询也用同一个 last_signature Mutex 保护)。当前实现可接受。


CI 验证清单(开发机无法本地验证)

优先级 项目 预期结论
必须 Windows cargo check / 真机运行 验证 PROPERTYKEY import 路径是否编译通过;若失败加 "Win32_UI_Shell_PropertiesSystem" feature
必须 Windows 真机插拔麦克风 IMMNotificationClient 回调是否触发 + 去抖是否正常
建议 macOS 插拔麦克风 CoreAudio listener 实时触发,60s 无额外刷新
建议 Linux smoke test 60s 轮询唯一路径,日志确认无原生注册

结论:代码设计合理,macOS 部分无需改动可放心合并(除 nitpick);Windows 分支需 CI 确认 PROPERTYKEY feature 是否编译通过。两个 nitpick(let _ = &wrapperCLSCTX_ALL)均可 follow-up 修复,不阻塞合并。

@HKLHaoBin

Copy link
Copy Markdown
Contributor

需要先解决 CI 问题才能合并。

@HKLHaoBin

Copy link
Copy Markdown
Contributor

CI 这边 Windows checks 挂了(macOS / Linux / Android cargo check 都过了),和 PR 描述里「Windows IMMNotificationClient 分支需 CI 验证」对上了。

失败点(device_watch.rs

error[E0432]: unresolved import `windows::Win32::Media::Audio::IMMNotificationClient_Impl`
error[E0432]: unresolved import `windows::Win32::UI::Shell::PropertiesSystem`
error[E0433]: cannot find `windows_core` in the crate root   (#[windows::core::implement(...)] 两处)

修复方向

  1. Cargo.toml windows features:当前只有 "Win32_UI_Shell",建议补上 "Win32_UI_Shell_PropertiesSystem"PROPERTYKEY / OnPropertyValueChanged 签名依赖它)。
  2. windows-core 依赖#[windows::core::implement(IMMNotificationClient)] 需要 crate 根能解析 windows_core,Windows target 下加 windows-core = "0.58"(与现有 windows = "0.58" 对齐)通常就能过。
  3. IMMNotificationClient_Impl import:windows-rs 0.58 里一般不需要手动 use ...IMMNotificationClient_Impl#[implement(IMMNotificationClient)] 会生成 DeviceNotificationClient_Impl,直接 impl IMMNotificationClient_Impl for DeviceNotificationClient_Impl 即可——若仍报错,对照同版本 windows-rs 示例调整 import 路径。

macOS CoreAudio 路径和 60s 兜底设计看起来 OK;Windows 编译修好后我再看 CI。Thanks!

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
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4f8ea54

@appergb appergb merged commit 49a6dc8 into beta Jun 16, 2026
5 checks passed
@appergb appergb deleted the fix/470-tray-mic-os-notify branch June 16, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants