Skip to content

feat(tray): cross-platform system-tray controller (resonance-tray)#64

Merged
ealtun21 merged 29 commits into
masterfrom
feat/tray-icon
Jul 3, 2026
Merged

feat(tray): cross-platform system-tray controller (resonance-tray)#64
ealtun21 merged 29 commits into
masterfrom
feat/tray-icon

Conversation

@ealtun21

@ealtun21 ealtun21 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an optional, cross-platform, low-resource system-tray controller (resonance-tray) as a standalone process, plus a modular component graph and full tray-control parity across CLI/TUI/GUI.

The tray controls the daemon (power toggle, quick preset switch, start/stop/restart) and opens/quits a UI. It is a separate process — never embedded in the daemon, and the daemon runs fine without it.

Component graph

resonanced (daemon)        REQUIRED — headless core
── ≥1 interface required ──
resonance (cli) · resonance-tui · resonance-gui   (each = full tray-control parity)
── optional add-on ──
resonance-tray   optional — needs daemon + ≥1 interface; refuses to run standalone

Enforced by a runtime guard (tray exits if no interface is installed) and packaging deps.

What's included

  • Shared control in resonance-ipc::{singleton, tray} (dep-light, std-only): single-instance guard + cross-process raise flag, TrayConfig (~/.config/resonance/tray.toml), UI discovery, per-OS autostart, process start/stop, UI launch/focus. Every client uses this — no interface is privileged.
  • resonance-tray binary — the only crate pulling tray backends: ksni on Linux (StatusNotifierItem over D-Bus, no GTK), tray-icon + winit on Windows/macOS. Shared menu model; slow status poll; graceful when the daemon is down; embedded PNG icon with derived bypassed variant (Linux/Windows) and native template image (macOS).
  • Full parity: resonance tray … (start/stop/restart/enable/disable/install/uninstall/status/config), GUI Settings → Tray section, TUI Settings → Tray tab.
  • GUI: single-instance + raise-on-second-launch; close-to-tray (hide, keep resident) when enabled.
  • Packaging: AUR PKGBUILDs, release tarball, RPM, and macOS/Windows/Flatpak artifact lists updated to ship the tray binary.

Menu

Power (bypass toggle) · Presets ▸ (active preset checked) · Open/Quit UI (adapts to installed UIs) · Daemon ▸ Start/Stop/Restart/Autostart · Tray autostart at login · Quit tray.

Verification

  • make check green (fmt + clippy -D warnings pedantic + cargo test --all, all suites pass).
  • Cross-target clippy -D warnings clean on x86_64-pc-windows-msvc and x86_64-apple-darwin.
  • Linux runtime: tray registers a live StatusNotifierItem with the desktop's tray host (icon shown); title/status/tooltip/icon/menu exposed; singleton rejects a second instance; clean SIGTERM exit; graceful degradation when the daemon is unreachable.
  • macOS runtime: native aarch64 (Apple Silicon) build links cleanly (tray-icon/muda/objc2-app-kit/core-graphics); not-standalone guard verified.
  • ksni/tray-icon/winit API usage cross-checked against the crates' source.

Notes / follow-ups

  • Interactive menu-bar/notification-area icon display on Windows and macOS needs a logged-in GUI session (out of scope for automated CI verification).
  • Tray↔daemon control (menu-click → daemon action) uses the existing IPC path (covered by unit tests + the shared client layer); a full live check requires a wire-compatible daemon restart.
  • poll_secs unified to 1–60 across clients; the Presets submenu lists presets in name order (not MRU).
  • No GTK/libappindicator is compiled on Linux (ksni over D-Bus).

Design + plan: docs/superpowers/specs/2026-07-03-tray-icon-design.md, docs/superpowers/plans/2026-07-03-tray-icon.md.

ealtun21 added 29 commits July 3, 2026 11:50
The Windows acquire() read back its own pidfile-recorded PID and treated
that as "lock free", so a second same-process acquire() incorrectly
returned Ok(Some(..)) instead of Ok(None). Add an in-process held-set
(HashSet guarded by a Mutex behind OnceLock) that acquire() checks first
and the guard's Drop clears, so same-process reentrancy now matches the
Unix flock contract. Also adds the missing one-line summary to the
Windows acquire() rustdoc.
cargo test runs a crate's tests on parallel threads, and XDG_CONFIG_HOME/
RESONANCE_SOCKET/PATH mutation in resonance-ipc's paths/tray tests raced:
one test's set_var/remove_var could fire mid-flight in another, leaking a
stray ~/.config/autostart/resonance-tray.desktop or flaking assertions.
Add a crate-wide test-only mutex (lib.rs::test_env_lock) and hold it for
the full body of every test that touches process env vars.

Also route tray::autostart's windows reg.exe calls through
service::windows::hidden() (now pub(crate)) so the windows_subsystem =
"windows" tray doesn't flash an empty console on every autostart check,
matching the daemon/GUI fix already in service/windows.rs. And make the
unreachable-platform stub's disable() return an error like its enable(),
avoiding an unnecessary-wraps lint if that stub is ever compiled.
spawn_detached had no windows creation flags, so a tray/GUI/TUI spawned
from a console caller died when that console closed. Apply the same
DETACHED_PROCESS | CREATE_NO_WINDOW flags service::windows::spawn_daemon
uses. Also drop the redundant installed_uis() call in open_ui — it
resolved each UI's binary a second time right before ui_bin() did it
again; resolve once per candidate instead.
execute() polled daemon state unconditionally before every command, even
though only TogglePower needs it to invert the current value; that meant
a LoadPreset click opened two sockets (state poll + send), doubling
latency and the chance of a spurious "daemon not reachable". Now state
is only fetched when the action is TogglePower.

Also strengthen preset_list_is_limited_and_extracts_paths to pass a
non-empty list so it actually exercises take(limit) and the path
extraction, and drop a stale comment on preset_path.
Left-click on Windows/macOS always popped the context menu regardless
of tray.toml's left_click setting, since tray-icon's default
menu-on-left-click is true and our own LeftClick action fired on top
of it (double action). Wire with_menu_on_left_click at build time and
set_show_menu_on_left_click on every refresh so a config reload takes
effect live, leaving MenuAction::LeftClick as the single action in
ToggleUi mode. Also borrow the tooltip directly instead of cloning it.

Fixes cross-target clippy failures on the windows-msvc target: gate
singleton::lock_path to #[cfg(unix)] (unused on windows, where acquire
never calls it) and replace map(..).unwrap_or(false) with is_ok_and in
the windows autostart imp.
…s a no-op there)

tray-icon 0.19.3's set_icon_with_as_template discards both arguments and
never applies the icon on non-macOS platforms, so refresh_tray silently
stopped updating the tray icon on Windows after startup (the active/
bypassed power-state color swap broke while menu/tooltip kept working).
Use the plain set_icon on Windows and keep the template-aware call on
macOS.
A second GUI launch now raises the running window and exits instead of
opening a duplicate against the same daemon (resonance_ipc::singleton).
Closing the window hides it to the tray (keeping state alive) when
"close to tray" is on and the tray is running; the tray's Open UI (or
another launch) un-hides and focuses it. Settings gains a Tray section
to control the tray process, autostart, close-to-tray, left-click
behaviour, poll interval, and recent-preset count.
Self-review follow-up on the close-to-tray change: read the
viewport's close_requested() once instead of twice per frame.
GUI/TUI hard-coded their singleton instance-name string literals
instead of the constants already defined in resonance-ipc's tray
control module, so a rename of GUI_INSTANCE/TRAY_INSTANCE would
silently break Open UI/Quit UI with no compile error. Add
TUI_INSTANCE alongside GUI_INSTANCE/TRAY_INSTANCE and route every
caller through the shared constants.

Also unify the tray poll-interval bound to 1..=60 everywhere (GUI
slider and TUI clamp were 1..=30, CLI was unbounded) to match the
runtime's own clamp, so a value set from one client can't silently
mismatch what another client displays or the runtime enforces.
…rtifacts

The Linux tarball, AUR PKGBUILD, and RPM already install resonance-tray
alongside the daemon/CLI/TUI/GUI, but the macOS .app, Windows installer,
and Flatpak did not build or bundle it — so "Start tray"/`tray start`
on those platforms would try to spawn a missing binary.

Mirror the existing per-binary handling in each packaging script:
- macOS build-app.sh: add resonance-tray to the cargo build -p list,
  the post-build existence check, the Contents/MacOS co-location loop,
  and the no-entitlements codesign loop (same treatment as the CLI/TUI,
  since the tray needs no TCC entitlements).
- Windows: build -p resonance-tray in windows.yml, stage
  resonance-tray.exe alongside the other exes, and add its [Files]
  entry to resonance.iss.
- Flatpak: install resonance-tray into /app/bin (the `cargo build --all`
  step already compiles it).
The resonance-tray crate pulls in ksni on Linux, which links libdbus-sys;
without libdbus-1-dev the build-script's pkg-config lookup for dbus-1 fails
and 'cargo build --all' aborts (broke the Linux CI check + release build).

Add the dev package to both CI workflows, declare 'dbus' as a runtime
dependency in the from-source and -bin PKGBUILDs (they ship resonance-tray),
and grant the flatpak session-bus access so the tray can register its
StatusNotifier item.
Replace the close-to-tray toggle (which kept the GUI resident) with a
quit-stops-daemon setting, on by default, honored by both quit paths: the
GUI window close and the tray's quit item. The GUI no longer hides to the
tray — closing it always exits the process, so nothing resource-hungry is
left running. The tray quit item's label adapts to the setting: 'Quit
Resonance' when it tears down the daemon too, else 'Quit tray'.

Also allow unlimited recent presets: the count control's top position reads
'All' and stores a usize::MAX sentinel (truncate/take treat it as
unbounded). Wired through GUI, TUI, and CLI (config key renamed
close-to-tray -> quit-stops-daemon; 'recent all' accepted). TrayConfig
ignores the stale close_gui_to_tray field on load.
@ealtun21 ealtun21 merged commit 99ff525 into master Jul 3, 2026
3 checks passed
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.

1 participant