Skip to content

Add system tray support#229

Merged
kwsantiago merged 11 commits intomainfrom
System-tray
Feb 16, 2026
Merged

Add system tray support#229
kwsantiago merged 11 commits intomainfrom
System-tray

Conversation

@wksantiago
Copy link
Contributor

@wksantiago wksantiago commented Feb 15, 2026

Summary

  • Add system tray icon with status indicator, bunker toggle, lock, and quit actions
  • Desktop notifications for signing requests and bunker approvals
  • Minimize to tray on close and start minimized settings
  • Sanitized notification content and clean shutdown on window close

Test plan

  • Verify tray icon appears with correct status color
  • Test minimize to tray on window close
  • Test start minimized setting
  • Test tray menu actions (Open, Toggle Bunker, Lock, Quit)
  • Verify notifications appear for signing requests and bunker approvals
  • Toggle minimize_to_tray off while hidden confirms window reappears

All 37 unit tests passing (23 app tests, 14 tray tests).

Summary by CodeRabbit

  • New Features

    • System tray with menu (show, toggle bunker, lock, quit), status icons, and desktop notifications for signing requests and approval prompts.
    • Settings UI additions: minimize-to-tray and start-minimized toggles; optionally hide window on close.
  • Bug Fixes

    • Safer bunker start/stop handling and synchronized UI state; notifications routed when app is hidden.
  • Chores

    • Added GTK3/XDO and desktop notification libs to Linux CI and container builds.

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a system tray module, tray-driven controls and desktop notifications, new tray-related settings (minimize_to_tray, start_minimized), startup/window-close behavior changes, CI/Docker apt installs for GTK/XDo, and new desktop dependencies (tray-icon, notify-rust).

Changes

Cohort / File(s) Summary
Infrastructure & Container
\.github/workflows/ci.yml, Dockerfile.reproducible, keep-desktop/Cargo.toml
Install libgtk-3-dev and libxdo-dev in Linux CI and Docker; add tray-icon = "0.21" and notify-rust = "4" to desktop Cargo.toml.
Tray Module
keep-desktop/src/tray.rs
New tray integration: TrayState, TrayEvent, menu building, icon generation, status/bunker label updates, notification helpers, sanitization, and unit tests.
App Core & Init
keep-desktop/src/app.rs, keep-desktop/src/main.rs
App state extended with tray, has_tray, window_visible; load_settings now returns migration flag; tray created at startup; added tray polling/sync, handlers, notification routing; exit_on_close_request(false) set.
UI Messages
keep-desktop/src/message.rs
Added message variants: SettingsMinimizeToTrayToggled(bool), SettingsStartMinimizedToggled(bool), WindowCloseRequested(iced::window::Id) and updated Debug formatting.
Settings Screen
keep-desktop/src/screen/settings.rs
SettingsScreen accepts and displays minimize_to_tray, start_minimized, and has_tray; adds tray UI card and toggle helper.
Bunker & Frost Handling
keep-desktop/src/bunker_service.rs, keep-desktop/src/frost.rs
Added handle_bunker_stop() and persisted setting updates; changed bunker event polling to try_iter(); added guards for missing bunker; call notification helpers on approval/sign events.
Minor / Tests
keep-desktop/src/screen/bunker.rs, keep-core/src/storage.rs
Removed an inline comment; adjusted a test to maintain rate-limit timing on CI.

Sequence Diagram

sequenceDiagram
    participant User
    participant Tray as "System Tray"
    participant App
    participant Bunker as "BunkerService"
    participant Notify as "Notification System"

    User->>Tray: click menu (Show / Toggle Bunker / Quit)
    Tray->>App: TrayEvent (ShowWindow / ToggleBunker / Quit)
    App->>App: handle_tray_event (show/hide/toggle/quit)
    App->>Bunker: request start/stop/toggle
    Bunker->>App: emit events (Connected / Approval / Log)
    App->>Notify: send_sign_request_notification / send_bunker_approval_notification
    Notify->>User: display notification

    alt Window close & minimize_to_tray enabled and tray present
        User->>App: WindowCloseRequested
        App->>App: hide window (keep running)
    else
        User->>App: WindowCloseRequested
        App->>App: exit application
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰
I nibble keys and wiggle a tray,
A ping, a blink, a tiny desktop bray,
Bunker whispers, menu hops to play,
Window tucks away while toasts relay,
Hooray — I hop, the app and I both stay!

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add system tray support' directly and clearly summarizes the main change—adding system tray functionality—which is the central focus of the pull request.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch System-tray

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@wksantiago wksantiago self-assigned this Feb 15, 2026
@wksantiago wksantiago linked an issue Feb 15, 2026 that may be closed by this pull request
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@keep-desktop/src/app.rs`:
- Around line 234-237: The new serde default for minimize_to_tray (serde(default
= "default_true")) will enable minimize-to-tray for existing users; change this
to opt-in by switching the serde default to false (e.g., use a default_false
helper or serde(default) with a false-returning function) for the
minimize_to_tray field and keep start_minimized as-is, and add a one-time
migration/notification path in the settings load code (where you deserialize
settings) to either preserve prior behavior or show a notice explaining the new
minimize-to-tray option so existing users are not surprised; update references
to minimize_to_tray and default_true accordingly.
- Around line 1514-1526: The early return inside
Message::SettingsMinimizeToTrayToggled currently updates
self.settings.minimize_to_tray and returns before the code that syncs the
Settings screen fields, causing the UI to show stale state when re-displaying
the window; to fix, perform the same screen-field synchronization (the code that
updates the Settings screen's fields) immediately after setting
self.settings.minimize_to_tray and calling save_settings(&self.keep_path,
&self.settings) and only then run the window-visibility logic that sets
self.window_visible, calls iced::window::oldest() and Task::batch with
iced::window::set_mode and iced::window::gain_focus; ensure the settings-screen
sync runs in both the early-return branch and the normal path so the UI always
reflects the new minimize_to_tray value.
🧹 Nitpick comments (5)
keep-desktop/src/app.rs (3)

1612-1622: notify_sign_request ignores the _req parameter — is this intentional?

The _req: &PendingSignRequest parameter is unused. If the intent is to include request details in the notification later, consider adding a TODO comment. If it's intentional to keep notifications generic, the parameter could be removed to avoid confusion.


1672-1686: Test name is misleading — it actually verifies that close does not hide when tray is absent.

window_close_with_minimize_to_tray_hides_window uses has_tray: false, so handle_window_close takes the exit path. The assertion confirms window_visible stays true. Consider renaming to something like window_close_minimize_to_tray_without_tray_exits.


506-517: Tray events are not processed when handle_tick returns early.

Multiple early returns (auto-lock at line 482, clipboard clear at 487, reconnect at 502) skip tray event polling. This means tray actions (Show, Quit, etc.) may be delayed by up to one tick (1 second). This is likely acceptable for UX, but worth being aware of — a queued Quit event won't be processed until after a reconnect completes, for example.

keep-desktop/src/tray.rs (2)

88-106: Background thread for menu event dispatch relies on global MenuEvent::receiver().

MenuEvent::receiver() returns a global static &Receiver. If TrayState::new() were called more than once (e.g., in tests or after a retry), multiple threads would race on the same receiver. This is safe in production since App::init creates exactly one TrayState, but it would be a footgun if tray re-initialization is ever added.

Consider adding a doc comment on TrayState::new noting the singleton constraint.


210-274: Icon pixel tests duplicate build_icon logic instead of calling it.

These tests reimplement the icon buffer generation rather than calling build_icon() and inspecting the result. If build_icon is refactored (e.g., different radius or color), these tests will still pass with stale values, giving false confidence.

Consider extracting the raw RGBA buffer generation into a helper (e.g., build_icon_rgba) that both build_icon and the tests can use:

Proposed refactor
+fn build_icon_rgba(connected: bool) -> Vec<u8> {
+    let mut rgba = vec![0u8; (ICON_SIZE * ICON_SIZE * 4) as usize];
+    let color: [u8; 4] = if connected {
+        [77, 166, 115, 255]
+    } else {
+        [140, 140, 148, 255]
+    };
+    let center = ICON_SIZE as f32 / 2.0;
+    let r = center - 2.0;
+    let r_sq = r * r;
+    for y in 0..ICON_SIZE {
+        for x in 0..ICON_SIZE {
+            let dx = x as f32 - center;
+            let dy = y as f32 - center;
+            if dx * dx + dy * dy <= r_sq {
+                let idx = ((y * ICON_SIZE + x) * 4) as usize;
+                rgba[idx..idx + 4].copy_from_slice(&color);
+            }
+        }
+    }
+    rgba
+}
+
 fn build_icon(connected: bool) -> Result<Icon, String> {
-    let mut rgba = vec![0u8; (ICON_SIZE * ICON_SIZE * 4) as usize];
-    ...
-    Icon::from_rgba(rgba, ICON_SIZE, ICON_SIZE).map_err(|e| format!("icon build failed: {e}"))
+    let rgba = build_icon_rgba(connected);
+    Icon::from_rgba(rgba, ICON_SIZE, ICON_SIZE).map_err(|e| format!("icon build failed: {e}"))
 }

Then tests can call build_icon_rgba(true) directly and assert on the returned buffer.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (6)
keep-desktop/src/tray.rs (3)

198-208: Test names suggest color verification but only check construction.

build_icon_connected_uses_green and build_icon_disconnected_uses_gray imply the tests verify the icon color, but they only assert that build_icon succeeds. Consider either renaming (e.g., build_icon_connected_succeeds) or adding an assertion that inspects the RGBA data.


223-229: Test uses byte length but sanitize_notification_field counts chars.

Line 227 uses result.len() (byte length) which happens to match MAX_NOTIFICATION_FIELD_LEN + 3 for ASCII input. For consistency with the function's char-based logic, prefer result.chars().count() — as you already do in the Unicode test on line 260.

Proposed fix
-        assert_eq!(result.len(), MAX_NOTIFICATION_FIELD_LEN + 3);
+        assert_eq!(result.chars().count(), MAX_NOTIFICATION_FIELD_LEN + 3);

88-106: Spawned thread has no name and no logging on shutdown.

Consider naming the thread (e.g., .name("tray-events")) for easier debugging, and optionally logging when the event loop exits.

keep-desktop/src/app.rs (3)

282-296: Double-parsing of settings JSON for migration detection.

The settings file is deserialized twice: once into Settings (line 287) and once into serde_json::Value (line 288) just to check if minimize_to_tray was absent. This is minor, but you could parse to Value first, check the field, then deserialize from the same Value.

Suggested optimization
-    let settings: Settings = serde_json::from_str(&contents).unwrap_or_default();
-    let migrated = serde_json::from_str::<serde_json::Value>(&contents)
-        .ok()
-        .map(|v| v.get("minimize_to_tray").is_none())
-        .unwrap_or(false);
+    let value: serde_json::Value = match serde_json::from_str(&contents) {
+        Ok(v) => v,
+        Err(_) => return (Settings::default(), false),
+    };
+    let migrated = value.get("minimize_to_tray").is_none();
+    let settings: Settings = serde_json::from_value(value).unwrap_or_default();

537-548: Tray events processed during handle_tick may accumulate multiple actions.

poll_tray_events collects all pending events and processes them in a single tick. If multiple events queue up (e.g., rapid menu clicks), they'll all execute. The Quit event in particular could race with other actions. Since handle_tray_quit calls iced::exit(), subsequent tasks in the batch may not matter, but it's worth being aware that earlier events in the batch could mutate state before the quit.


1750-1758: Re-entrant self.update() call for bunker toggle.

handle_tray_toggle_bunker calls self.update(Message::BunkerStop) and self.update(Message::BunkerStart), which re-enter the top-level update dispatch. This works but makes the control flow less obvious. Consider calling the underlying handler methods directly (e.g., self.handle_bunker_message(Message::BunkerStart)) to avoid the full dispatch overhead.

@wksantiago
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@keep-desktop/src/app.rs`:
- Around line 352-357: The init() state sets window_visible using tray.is_none()
|| !settings.start_minimized which doesn't consider settings.minimize_to_tray
and can desync with new(); update the window_visible initialization to mirror
new()'s hide guard so window_visible is false only when settings.start_minimized
&& has_tray && settings.minimize_to_tray (otherwise true). Locate the
window_visible field in init() and change its expression to use the same
combined condition (referencing init(), new(), window_visible,
settings.start_minimized, settings.minimize_to_tray, and has_tray) so initial
state matches the hide task behavior.

In `@keep-desktop/src/tray.rs`:
- Around line 176-190: In sanitize_notification_field, the character filter
currently removes control chars and '<' and '>' but not '&', which causes Pango
parse errors; update the filter in sanitize_notification_field so it also
excludes '&' (i.e., add *c != '&' to the .filter closure) and ensure truncation
logic (MAX_NOTIFICATION_FIELD_LEN and the truncated branch) remains unchanged.
🧹 Nitpick comments (3)
keep-desktop/src/screen/settings.rs (1)

23-33: Nice DRY improvement with the toggle_button helper.

Consider also refactoring the similar button-building patterns in auto_lock_card and clipboard_card to use this helper or a similar one, as they share the same styling logic.

keep-desktop/src/tray.rs (1)

230-236: Test assertion uses byte length (len()) but the function operates on char count.

This test passes because the input is ASCII, but it's inconsistent with the function's semantics. The Unicode test at line 275 correctly uses chars().count(). Consider using chars().count() here too for consistency.

Suggested fix
-        assert_eq!(result.len(), MAX_NOTIFICATION_FIELD_LEN + 3);
+        assert_eq!(result.chars().count(), MAX_NOTIFICATION_FIELD_LEN + 3);
keep-desktop/src/app.rs (1)

539-551: Processing tray events during handle_tick by collecting then iterating is sound.

One consideration: multiple TrayEvent::ToggleBunker events could queue up between ticks, causing rapid start/stop toggling. In practice this is unlikely since menu clicks are rate-limited by user interaction, but if it becomes an issue, consider deduplicating or taking only the last event of each type.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
keep-desktop/src/tray.rs (1)

163-185: Rate-limit tests share mutable static state — may flake under parallel execution.

NOTIFICATION_RATE is a process-wide static. The tests on lines 348–366 reset it directly, but since Rust's test runner executes tests in parallel by default within the same process, concurrent mutations can cause spurious failures.

Consider using serial_test or restructuring the rate limiter to accept injectable state for testability. This isn't a production issue, just a test reliability concern.

@kwsantiago kwsantiago merged commit 78f07c4 into main Feb 16, 2026
9 checks passed
@kwsantiago kwsantiago deleted the System-tray branch February 16, 2026 01:49
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.

System tray with sign request notifications (desktop)

2 participants

Comments