Add native StatusNotifierItem system tray support#291
Add native StatusNotifierItem system tray support#291materemias wants to merge 3 commits intomainfrom
Conversation
Add opt-in system tray icon via the SNI DBus protocol using the ksni crate. The tray shows daemon state (idle/recording/transcribing), supports left-click to toggle recording, and provides a right-click context menu with toggle, cancel, and quit actions. Disabled by default — enable via [tray] enabled = true, --tray flag, or VOXTYPE_TRAY_ENABLED=true. Gracefully degrades when DBus session bus is unavailable. Also upgrades whisper-rs from 0.15.1 to 0.16.0 to fix bindgen struct generation failures with clang 22 / glibc 2.43. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rder - Handle None from tray event channel by setting rx to None, preventing tight loop when channel closes - Treat empty DBUS_SESSION_BUS_ADDRESS as unavailable, not just unset - Spawn bridge thread before ksni service to avoid orphaned tray icon if thread creation fails Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds optional native system tray integration using the StatusNotifierItem (SNI) DBus protocol, enabling basic daemon control and status display via a tray icon.
Changes:
- Introduces a new
trayCargo feature andsrc/tray/*module implementing an SNI tray viaksni. - Wires tray enablement through config/env/CLI (
[tray].enabled,VOXTYPE_TRAY_ENABLED,--tray) and integrates tray events/state updates into the daemon loop. - Updates docs and build artifacts (Dockerfiles, Cargo deps/lock) to support building with tray support.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tray/sni.rs | Implements ksni-based StatusNotifierItem tray service, event bridging, and state watching. |
| src/tray/mod.rs | Exposes tray API and defines TrayState/TrayEvent. |
| src/main.rs | Applies --tray CLI override (feature-gated) to config. |
| src/lib.rs | Feature-gates exporting the new tray module. |
| src/daemon.rs | Adds tray state propagation + event handling integrated into the main select loop. |
| src/config.rs | Adds [tray] config section, TrayConfig, and env override VOXTYPE_TRAY_ENABLED. |
| src/cli.rs | Adds --tray flag. |
| docs/USER_MANUAL.md | Documents tray setup, requirements, usage, and icons. |
| docs/CONFIGURATION.md | Documents [tray] configuration, env var, and CLI flag. |
| Dockerfile.build | Adds DBus dev dependency and builds with --features tray. |
| Dockerfile.avx512 | Adds DBus dev dependency and builds with --features tray. |
| Dockerfile.vulkan | Adds DBus dev dependency and builds with --features ... ,tray. |
| Dockerfile.onnx | Adds DBus dev dependency and builds with --features ... ,tray. |
| Dockerfile.onnx-avx512 | Adds DBus dev dependency and builds with --features ... ,tray. |
| Dockerfile.onnx-cuda | Adds DBus dev dependency and builds with --features ... ,tray. |
| Dockerfile.onnx-rocm | Adds DBus dev dependency and builds with --features ... ,tray. |
| Cargo.toml | Adds optional ksni dependency and tray feature. |
| Cargo.lock | Locks new transitive dependencies introduced by ksni. |
src/tray/sni.rs
Outdated
| let (state_tx, mut state_rx) = tokio::sync::watch::channel(TrayState::Idle); | ||
| let (event_tx, event_rx) = tokio::sync::mpsc::channel::<TrayEvent>(8); | ||
|
|
||
| // Bridge channel: ksni thread (std::sync) -> tokio task -> tokio mpsc |
There was a problem hiding this comment.
The comment describing the event “bridge channel” is inaccurate: the code uses a dedicated std::thread that calls blocking_send into the Tokio mpsc, not a Tokio task. Please update the comment to match the implementation (or change the implementation to actually use a Tokio task).
| // Bridge channel: ksni thread (std::sync) -> tokio task -> tokio mpsc | |
| // Bridge channel: ksni thread (std::sync) -> bridge thread -> tokio mpsc |
There was a problem hiding this comment.
Fixed in bbff8e0 — updated comment to say "bridge thread" instead of "tokio task".
| /// Update the state file if configured, and notify tray | ||
| fn update_state(&self, state_name: &str) { | ||
| if let Some(ref path) = self.state_file_path { | ||
| write_state_file(path, state_name); | ||
| } | ||
|
|
||
| #[cfg(feature = "tray")] | ||
| if let Some(ref tx) = self.tray_state_tx { | ||
| let tray_state = match state_name { | ||
| "recording" => crate::tray::TrayState::Recording, | ||
| "transcribing" => crate::tray::TrayState::Transcribing, | ||
| _ => crate::tray::TrayState::Idle, | ||
| }; | ||
| let _ = tx.send(tray_state); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
update_state now maps state_name string values to TrayState. This is brittle (any new/typo’d state string will silently show as Idle). Consider switching update_state to take an enum (or a dedicated internal state type) and derive both the state-file string and TrayState from that, so the tray can’t drift from the daemon’s real state.
| /// Update the state file if configured, and notify tray | |
| fn update_state(&self, state_name: &str) { | |
| if let Some(ref path) = self.state_file_path { | |
| write_state_file(path, state_name); | |
| } | |
| #[cfg(feature = "tray")] | |
| if let Some(ref tx) = self.tray_state_tx { | |
| let tray_state = match state_name { | |
| "recording" => crate::tray::TrayState::Recording, | |
| "transcribing" => crate::tray::TrayState::Transcribing, | |
| _ => crate::tray::TrayState::Idle, | |
| }; | |
| let _ = tx.send(tray_state); | |
| } | |
| } | |
| /// Internal representation of daemon state, used to keep state-file | |
| /// strings and tray states in sync. | |
| #[derive(Copy, Clone, Debug)] | |
| enum DaemonState { | |
| Idle, | |
| Recording, | |
| Transcribing, | |
| } | |
| impl DaemonState { | |
| fn as_str(&self) -> &'static str { | |
| match self { | |
| DaemonState::Idle => "idle", | |
| DaemonState::Recording => "recording", | |
| DaemonState::Transcribing => "transcribing", | |
| } | |
| } | |
| #[cfg(feature = "tray")] | |
| fn as_tray_state(&self) -> crate::tray::TrayState { | |
| match self { | |
| DaemonState::Idle => crate::tray::TrayState::Idle, | |
| DaemonState::Recording => crate::tray::TrayState::Recording, | |
| DaemonState::Transcribing => crate::tray::TrayState::Transcribing, | |
| } | |
| } | |
| } | |
| /// Update the state file and notify the tray using an internal state enum. | |
| fn update_state_internal(&self, state: DaemonState) { | |
| if let Some(ref path) = self.state_file_path { | |
| write_state_file(path, state.as_str()); | |
| } | |
| #[cfg(feature = "tray")] | |
| if let Some(ref tx) = self.tray_state_tx { | |
| let tray_state = state.as_tray_state(); | |
| let _ = tx.send(tray_state); | |
| } | |
| } | |
| /// Update the state file if configured, and notify tray | |
| /// | |
| /// This function accepts a string for compatibility with existing | |
| /// callers, but it maps that string to the internal `DaemonState` | |
| /// enum to keep the state-file and tray state consistent. Unknown | |
| /// strings are written to the state file but do not trigger a tray | |
| /// update, to avoid silently showing an incorrect tray state. | |
| fn update_state(&self, state_name: &str) { | |
| let state = match state_name { | |
| "recording" => DaemonState::Recording, | |
| "transcribing" => DaemonState::Transcribing, | |
| "idle" => DaemonState::Idle, | |
| other => { | |
| if let Some(ref path) = self.state_file_path { | |
| write_state_file(path, other); | |
| } | |
| tracing::warn!("Unknown daemon state '{}', not updating tray", other); | |
| return; | |
| } | |
| }; | |
| self.update_state_internal(state); | |
| } |
There was a problem hiding this comment.
Added a tracing::warn! for unknown state strings and an explicit "idle" match arm instead of the catch-all, so unrecognized states are no longer silent. Skipping the full DaemonState enum refactor — only known state strings are ever passed here, and the warning covers the safety net case.
| match event { | ||
| TrayEvent::ToggleRecording => { | ||
| tracing::debug!("Tray: toggle recording"); | ||
| if state.is_idle() { | ||
| tracing::info!("Recording started (tray toggle)"); | ||
|
|
||
| if self.config.output.notification.on_recording_start { | ||
| send_notification( | ||
| "Recording Started", | ||
| "Tray toggle", | ||
| self.config.output.notification.show_engine_icon, | ||
| self.config.engine, | ||
| ) | ||
| .await; | ||
| } | ||
|
|
There was a problem hiding this comment.
handle_tray_event duplicates substantial start/stop-recording logic that already exists in the hotkey/SIGUSR2 paths in this file. This makes future behavior changes (model loading, notifications, hooks, eager vs normal recording) easy to apply inconsistently across entrypoints. Consider extracting shared helpers (e.g., start_recording / stop_recording_and_transcribe / stop_eager_recording) and call them from hotkey, signal, and tray handlers.
There was a problem hiding this comment.
Known issue — the recording start/stop logic is duplicated across 4 paths (hotkey push-to-talk, hotkey toggle, SIGUSR1/2, and tray). Extracting shared helpers is blocked by tokio::select! borrow constraints on the mutable state references. Tracked internally; not addressing in this PR.
| // Tray | ||
| if let Ok(val) = std::env::var("VOXTYPE_TRAY_ENABLED") { | ||
| config.tray.enabled = parse_bool_env(&val); | ||
| } |
There was a problem hiding this comment.
New behavior: VOXTYPE_TRAY_ENABLED overrides config.tray.enabled, but there’s no unit test covering this env var parsing/precedence. Since this module already has extensive config parsing tests, please add a small test that sets VOXTYPE_TRAY_ENABLED to true/false and asserts load_config(None) reflects it (and clean up the env var after the test).
There was a problem hiding this comment.
Added in bbff8e0 — tests cover default (disabled), TOML deserialization, and env var override with both true/false values.
| // -- Tray -- | ||
|
|
||
| /// Enable system tray icon (StatusNotifierItem) | ||
| #[arg(long, help_heading = "Tray")] | ||
| pub tray: bool, |
There was a problem hiding this comment.
The new --tray CLI flag isn’t covered by the existing CLI parsing tests in this file. Please add a small test that parses voxtype --tray and asserts cli.tray is set, to prevent accidental regressions in clap arg wiring.
There was a problem hiding this comment.
Added in bbff8e0 — tests for --tray flag parsing and default (false).
- Fix inaccurate comment in sni.rs: bridge uses std::thread, not tokio task - Add warning log for unknown daemon states in tray state mapping - Add unit tests for --tray CLI flag parsing - Add unit tests for tray config: default, TOML, and env var override Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
ksnicratetrayCargo feature flag,[tray] enabledconfig option,--trayCLI flag,VOXTYPE_TRAY_ENABLEDenv var — disabled by default, gracefully degrades without DBuslibdbus-1-devand--features trayTest plan
cargo build(without tray feature) compiles cleancargo build --features traycompiles cleancargo test --features traypasses all tests--tray -vvon KDE/GNOME/Waybar — verify tray icon appearsDBUS_SESSION_BUS_ADDRESS=""— logs warning, runs without tray[tray]in config — no tray icon appears--traywithout feature compiled — logs warning🤖 Generated with Claude Code