feat(tray): cross-platform system-tray controller (resonance-tray)#64
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Enforced by a runtime guard (tray exits if no interface is installed) and packaging deps.
What's included
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-traybinary — 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).resonance tray …(start/stop/restart/enable/disable/install/uninstall/status/config), GUI Settings → Tray section, TUI Settings → Tray tab.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 checkgreen (fmt + clippy-D warningspedantic +cargo test --all, all suites pass).-D warningsclean onx86_64-pc-windows-msvcandx86_64-apple-darwin.Notes / follow-ups
poll_secsunified to 1–60 across clients; the Presets submenu lists presets in name order (not MRU).Design + plan:
docs/superpowers/specs/2026-07-03-tray-icon-design.md,docs/superpowers/plans/2026-07-03-tray-icon.md.