Skip to content

Add native StatusNotifierItem system tray support#291

Open
materemias wants to merge 3 commits intomainfrom
feature/system-tray-sni
Open

Add native StatusNotifierItem system tray support#291
materemias wants to merge 3 commits intomainfrom
feature/system-tray-sni

Conversation

@materemias
Copy link
Copy Markdown
Collaborator

Note: Reopened from #264, which was closed when the fork was deleted. This PR contains the same changes, rebased onto current main.

Summary

  • Add opt-in system tray icon via StatusNotifierItem (SNI) DBus protocol using ksni crate
  • Tray shows daemon state (idle/recording/transcribing) with XDG named icons, left-click toggles recording, right-click context menu with toggle/cancel/quit
  • New tray Cargo feature flag, [tray] enabled config option, --tray CLI flag, VOXTYPE_TRAY_ENABLED env var — disabled by default, gracefully degrades without DBus
  • Upgrades whisper-rs 0.15.1 → 0.16.0 to fix bindgen failures with clang 22 / glibc 2.43
  • All 7 release Dockerfiles updated with libdbus-1-dev and --features tray
  • Documentation added to CONFIGURATION.md and USER_MANUAL.md

Test plan

  • cargo build (without tray feature) compiles clean
  • cargo build --features tray compiles clean
  • cargo test --features tray passes all tests
  • Run with --tray -vv on KDE/GNOME/Waybar — verify tray icon appears
  • Left-click toggles recording, icon changes between states
  • Right-click menu: Toggle Recording, Cancel (grayed unless transcribing), Quit
  • Quit from tray shuts down daemon cleanly
  • DBUS_SESSION_BUS_ADDRESS="" — logs warning, runs without tray
  • Without [tray] in config — no tray icon appears
  • --tray without feature compiled — logs warning

🤖 Generated with Claude Code

materemias and others added 2 commits April 1, 2026 16:53
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>
Copilot AI review requested due to automatic review settings April 1, 2026 14:54
@materemias materemias requested a review from peteonrails as a code owner April 1, 2026 14:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tray Cargo feature and src/tray/* module implementing an SNI tray via ksni.
  • 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
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// Bridge channel: ksni thread (std::sync) -> tokio task -> tokio mpsc
// Bridge channel: ksni thread (std::sync) -> bridge thread -> tokio mpsc

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bbff8e0 — updated comment to say "bridge thread" instead of "tokio task".

Comment on lines +627 to 643
/// 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);
}
}

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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);
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1498 to +1513
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;
}

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2147 to +2150
// Tray
if let Ok(val) = std::env::var("VOXTYPE_TRAY_ENABLED") {
config.tray.enabled = parse_bool_env(&val);
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in bbff8e0 — tests cover default (disabled), TOML deserialization, and env var override with both true/false values.

Comment on lines +265 to +269
// -- Tray --

/// Enable system tray icon (StatusNotifierItem)
#[arg(long, help_heading = "Tray")]
pub tray: bool,
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants