From fbcff853329462d616572668535bf477516c183e Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:25:23 +0200 Subject: [PATCH 1/3] feat(ui): pulse tray icon while downloads are active (task 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tray now switches from the static window icon to an orange pulsing dot whenever ≥1 download is `Downloading`/`Resumed`/`ResumedFromWait`, and reverts to static once the active set returns to zero. The animator splits cleanly between a domain-pure state machine (`ActivityTracker` + `AnimatorCore`) and a thin Tauri-bound icon swapper, so the loop is fully unit-tested without a Tauri runtime. The interval arm of the `tokio::select!` is gated by `if core.is_animating()` so an idle tray costs zero timer wake-ups. Frames are generated procedurally (8×32×32 RGBA, triangular-wave pulse) instead of shipping binary PNG assets — easier to tweak and testable. The adapter uses only the cross-platform Tauri 2 `TrayIcon::set_icon(Option)` API; no `cfg(target_os)` paths. Refs: PRD-v2 §3 P0.18, PRD §7.5 --- CHANGELOG.md | 1 + .../adapters/driven/tray/activity_tracker.rs | 208 +++++++++ .../src/adapters/driven/tray/animator.rs | 399 ++++++++++++++++++ src-tauri/src/adapters/driven/tray/frames.rs | 140 ++++++ src-tauri/src/adapters/driven/tray/mod.rs | 8 + .../src/adapters/driven/tray/system_tray.rs | 11 +- .../src/adapters/driven/tray/tauri_swapper.rs | 61 +++ src-tauri/src/lib.rs | 29 +- 8 files changed, 849 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/adapters/driven/tray/activity_tracker.rs create mode 100644 src-tauri/src/adapters/driven/tray/animator.rs create mode 100644 src-tauri/src/adapters/driven/tray/frames.rs create mode 100644 src-tauri/src/adapters/driven/tray/tauri_swapper.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ab2fd..140eb40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Animated tray icon while at least one download is active (PRD-v2 P0.18, task 18): the system tray now pulses an orange dot whenever the active-download set is non-empty and reverts to the default static icon as soon as the set goes back to zero. Backend ships a new `adapters/driven/tray/` sub-module split into a domain-pure `ActivityTracker` (a `HashSet` consuming `DownloadStarted` / `Resumed` / `ResumedFromWait` to add and `Paused` / `Completed` / `CompletedPersisted` / `Failed` / `Cancelled` / `Removed` / `Waiting` to remove, returning `Activated` / `Deactivated` / `NoChange` transitions), a procedural `pulse_frames()` generator that renders eight 32×32 RGBA frames in pure Rust (triangular-wave radius pulse `MIN_RADIUS=3 → MAX_RADIUS=7 → MIN_RADIUS`, no binary PNG assets to commit, full unit-test coverage of shape/colors), an `IconSwapper` trait (`show_frame(usize)` / `show_static()`) so the loop is unit-testable without a Tauri runtime, an `AnimatorCore` state machine that wraps the tracker with a frame index and exposes `handle_event` (returning `StartAnimation` / `StopAnimation` / `NoOp`) and `tick`, and a `spawn_tray_animator` async wiring that subscribes to the `EventBus` (filtering out high-frequency `DownloadProgress` / segment events at the source so they never reach the channel), forwards relevant events through an mpsc to a `tokio::select!` loop that idles the interval arm with `if core.is_animating()` so a fully idle tray costs zero timer wake-ups, and calls `swapper.show_static()` once on shutdown. The Tauri-bound `TauriIconSwapper` owns the frames as `Image::new_owned` (so the underlying RGBA buffers outlive each `set_icon` call), guards on empty frame slices, and logs `set_icon` failures via `tracing::warn` instead of unwrapping. `setup_system_tray` now returns the `TrayIcon` handle so `lib.rs` can build the swapper and spawn the animator with the same `Arc` the Tauri / notification bridges already share, with a `DEFAULT_FRAME_INTERVAL` of 200 ms. The implementation is platform-agnostic (no `cfg(target_os)` in the adapter) and relies only on the cross-platform Tauri 2 `TrayIcon::set_icon(Option)` API. (task 18) - Dynamic segment splitting (PRD-v2 P0.17, task 17): when a parallel segment finishes before its peers, the engine now re-evaluates the still-running segments, picks the slowest one whose remaining range exceeds `dynamic_split_min_remaining_mb` (default 4 MiB) and shrinks it in place — a fresh worker takes the upper half so the tail of the download accelerates instead of stalling on a single slow connection. Backend ships a domain-pure `Segment::split(at_byte, new_id)` validation method (state must be `Downloading`, split point strictly inside the unfetched range, caller-provided id must differ from the original — IDs are allocated by the engine's monotonic `next_segment_id` counter, never invented inside the domain), a new `DomainEvent::SegmentSplit { download_id, original_segment_id, new_segment_id, split_at }` forwarded as the `segment-split` Tauri event and logged in the per-download log store, two new `AppConfig` / `ConfigPatch` / `SettingsDto` fields `dynamic_split_enabled` (default `true`) and `dynamic_split_min_remaining_mb` (default `4`) wired through the toml config store, the Tauri IPC `SettingsDto`/`ConfigPatchDto` (so the frontend can both read and write them) and the new `application::services::engine_config_bridge` subscriber so live `settings_update` calls reconfigure already-running engines without a restart. `SegmentedDownloadEngine` stores `dynamic_split_enabled` / `dynamic_split_min_remaining_bytes` in `Arc` / `Arc` and exposes a `set_dynamic_split(enabled, min_remaining_mb)` setter consumed by the bridge. After a split, the engine updates the original slot's `initial_end` to `split_at` immediately on successful `end_tx.send`, so a subsequent `pick_split_target` evaluation cannot expand the worker's range past the shrunk boundary and `persist_split_meta` records the post-split topology rather than the stale one (closes coderabbit P1 + greptile P1 race). Each segment task now returns `(slot_idx, Result)`; on success the engine flips a `completed: bool` flag on the slot — `pick_split_target` skips completed slots so they cannot be re-picked, and `persist_split_meta` keeps the entry with `completed: true` and a full-range `downloaded_bytes` so a crash right after a split never loses the record of byte ranges already on disk. `pick_split_target` also gates on a 500 ms / non-zero-progress sample window: a fresh split child cannot be picked again until it has actually produced a throughput sample, preventing cascading fragmentation of the newest range. The segment worker accepts the upper bound through a `tokio::sync::watch::Receiver` instead of a frozen `u64`, re-reads it before each chunk fetch and again after every successful network read so a mid-flight shrink clamps the next write to the new boundary; per-segment progress is exposed via an `Arc` so the engine can pick the slowest candidate by throughput (`downloaded / elapsed`). After every split, the engine atomically rewrites `.vortex-meta` with the updated segment topology so resume after a crash mid-split sees a consistent state. (task 17, PR #111 review) - "Report broken plugin" action (PRD-v2 P0.16, task 16): plugins listed in *Plugins → Plugin Store* now expose a *Report broken plugin* item in their kebab menu. Clicking it opens the user's default browser at a pre-filled GitHub issue on the plugin's repository, with diagnostic metadata (plugin name + version, Vortex version, OS, optional URL under test, last 50 log lines) inlined into the issue body. Backend adds a `repository_url` field to `domain::model::plugin::PluginInfo` (parsed from the new `[plugin].repository` key in `plugin.toml`), a `domain::ports::driven::UrlOpener` port plus its platform-native `SystemUrlOpener` adapter (`xdg-open` / `open` / `cmd start`, `http(s)://` only by validation), the std-only `domain::model::plugin::build_report_broken_url` URL builder (RFC 3986 unreserved-set percent encoder, last 50 log lines, GitHub-only repository hosts, accepts `.git` suffix, rejects malformed URLs with `DomainError::ValidationError`), and a `ReportBrokenPluginCommand` handler that returns `AppError::Validation` when a manifest carries no `repository_url`. New Tauri IPC `plugin_report_broken(pluginName, logLines?, testedUrl?) → string` returns the issue URL so the UI can fall back to clipboard copy if the launcher fails. i18n (en/fr): `plugins.action.reportBroken`, `plugins.toast.reportBrokenSuccess`, `plugins.toast.reportBrokenError`. (task 16) - Dynamic plugin configuration UI (PRD-v2 P0.15, task 15): plugins declaring a `[config]` block in their `plugin.toml` now expose their schema at runtime. Backend adds `ConfigField` / `ConfigFieldType` / `PluginConfigSchema` to `domain/model/plugin.rs` (typed validation, enum options, `min`/`max` bounds, regex via a std-only matcher — no external import in the domain), a `PluginConfigStore` port (`get_values` / `set_value` / `list_all` / `delete_all`) implemented by `SqlitePluginConfigRepo` backed by the new `plugin_configs (plugin_name, key, value)` table (migration `m20260425_000005_create_plugin_configs`, composite primary key). The manifest parser (`adapters/driven/plugin/manifest.rs`) now extracts `type`, `default`, `options`, `description`, `min`, `max`, `regex` on top of the existing defaults, and rejects defaults that fail their own field validation. CQRS gains `UpdatePluginConfigCommand` (validates against the schema, applies the runtime first then persists, rolls back on failure) and `GetPluginConfigQuery` (returns the schema plus persisted values, dropping any persisted entry that no longer matches the current schema and falling back to manifest defaults). `PluginLoader` is extended with `get_manifest()` and `set_runtime_config()`; `ExtismPluginLoader` implements both by reading from `PluginRegistry` and writing to `SharedHostResources::plugin_configs`, so `get_config(key)` calls from the WASM plugin observe the new value without a reload. At startup, `lib.rs` replays persisted configs onto the in-memory map before plugins are loaded. Frontend adds two components: `PluginConfigField.tsx` (dispatcher renderer: `string` → text input, `boolean` → shadcn switch, `integer`/`float` → numeric input with bounds, `url` → url input, `enum` (and `string` with options) → shadcn select; `aria-describedby` on the control points to the error message) and `PluginConfigDialog.tsx` (loads the schema via `useQuery`, validates each field on the UI side (rejects empty floats, validates JSON arrays) before sending, persists changed values sequentially, guards the schema-reset effect while a save is in flight to avoid clobbering the draft, invalidates the query on success). `PluginsView` queries `plugin_config_get` for each installed plugin (keyed off the unfiltered installed list to avoid churn while typing in search) to decide whether the *Configure* button (Settings icon, next to the *More* menu) should render: a plugin without `[config]` exposes no button. New IPC commands `plugin_config_get(name) → PluginConfigView` and `plugin_config_update(name, key, value)`. i18n (en/fr): `plugins.action.configure`, `plugins.config.{title,description,loading,error,noFields,toast.{saveSuccess,validationFailed}}`. (task 15) diff --git a/src-tauri/src/adapters/driven/tray/activity_tracker.rs b/src-tauri/src/adapters/driven/tray/activity_tracker.rs new file mode 100644 index 0000000..674e92f --- /dev/null +++ b/src-tauri/src/adapters/driven/tray/activity_tracker.rs @@ -0,0 +1,208 @@ +//! Pure state machine that tracks the set of currently-active downloads. +//! +//! Consumed by the tray animator: when the count of active downloads goes +//! 0 → ≥1 the animator starts cycling frames; when it returns to 0 it +//! restores the static icon. The tracker never touches the tray itself — +//! the adapter wires it to the EventBus and to a frame-swap callback. + +use std::collections::HashSet; + +use crate::domain::event::DomainEvent; +use crate::domain::model::download::DownloadId; + +/// State transition produced by [`ActivityTracker::apply`]. +/// +/// Lets the animator decide whether to start the interval task, stop it, +/// or do nothing on a given event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transition { + /// Active count went from 0 to ≥1. + Activated, + /// Active count returned to 0. + Deactivated, + /// Count changed but stayed in the same activity bucket, or the event + /// didn't affect the count at all. + NoChange, +} + +#[derive(Debug, Default)] +pub struct ActivityTracker { + active: HashSet, +} + +impl ActivityTracker { + pub fn new() -> Self { + Self::default() + } + + pub fn is_active(&self) -> bool { + !self.active.is_empty() + } + + #[cfg(test)] + pub(super) fn active_count(&self) -> usize { + self.active.len() + } + + /// Update internal state from a domain event and report the resulting + /// activity transition. + pub fn apply(&mut self, event: &DomainEvent) -> Transition { + let was_active = self.is_active(); + match event { + DomainEvent::DownloadStarted { id } + | DomainEvent::DownloadResumed { id } + | DomainEvent::DownloadResumedFromWait { id } => { + self.active.insert(*id); + } + DomainEvent::DownloadPaused { id } + | DomainEvent::DownloadCompleted { id } + | DomainEvent::DownloadCompletedPersisted { id } + | DomainEvent::DownloadFailed { id, .. } + | DomainEvent::DownloadCancelled { id } + | DomainEvent::DownloadRemoved { id } + | DomainEvent::DownloadWaiting { id } => { + self.active.remove(id); + } + _ => {} + } + match (was_active, self.is_active()) { + (false, true) => Transition::Activated, + (true, false) => Transition::Deactivated, + _ => Transition::NoChange, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn started(id: u64) -> DomainEvent { + DomainEvent::DownloadStarted { id: DownloadId(id) } + } + fn paused(id: u64) -> DomainEvent { + DomainEvent::DownloadPaused { id: DownloadId(id) } + } + fn completed(id: u64) -> DomainEvent { + DomainEvent::DownloadCompletedPersisted { id: DownloadId(id) } + } + + #[test] + fn test_new_tracker_is_inactive() { + let t = ActivityTracker::new(); + assert!(!t.is_active()); + assert_eq!(t.active_count(), 0); + } + + #[test] + fn test_started_event_activates_tracker() { + let mut t = ActivityTracker::new(); + assert_eq!(t.apply(&started(1)), Transition::Activated); + assert!(t.is_active()); + assert_eq!(t.active_count(), 1); + } + + #[test] + fn test_second_started_event_keeps_state_active_no_transition() { + let mut t = ActivityTracker::new(); + t.apply(&started(1)); + assert_eq!(t.apply(&started(2)), Transition::NoChange); + assert_eq!(t.active_count(), 2); + } + + #[test] + fn test_paused_when_only_active_deactivates() { + let mut t = ActivityTracker::new(); + t.apply(&started(1)); + assert_eq!(t.apply(&paused(1)), Transition::Deactivated); + assert!(!t.is_active()); + } + + #[test] + fn test_paused_with_other_active_no_transition() { + let mut t = ActivityTracker::new(); + t.apply(&started(1)); + t.apply(&started(2)); + assert_eq!(t.apply(&paused(1)), Transition::NoChange); + assert_eq!(t.active_count(), 1); + assert!(t.is_active()); + } + + #[test] + fn test_completed_persisted_deactivates() { + let mut t = ActivityTracker::new(); + t.apply(&started(7)); + assert_eq!(t.apply(&completed(7)), Transition::Deactivated); + } + + #[test] + fn test_failed_deactivates() { + let mut t = ActivityTracker::new(); + t.apply(&started(3)); + let evt = DomainEvent::DownloadFailed { + id: DownloadId(3), + error: "boom".into(), + }; + assert_eq!(t.apply(&evt), Transition::Deactivated); + } + + #[test] + fn test_cancelled_deactivates() { + let mut t = ActivityTracker::new(); + t.apply(&started(8)); + let evt = DomainEvent::DownloadCancelled { id: DownloadId(8) }; + assert_eq!(t.apply(&evt), Transition::Deactivated); + } + + #[test] + fn test_resumed_from_wait_activates() { + let mut t = ActivityTracker::new(); + let evt = DomainEvent::DownloadResumedFromWait { id: DownloadId(2) }; + assert_eq!(t.apply(&evt), Transition::Activated); + } + + #[test] + fn test_waiting_deactivates_when_last() { + let mut t = ActivityTracker::new(); + t.apply(&started(4)); + let evt = DomainEvent::DownloadWaiting { id: DownloadId(4) }; + assert_eq!(t.apply(&evt), Transition::Deactivated); + } + + #[test] + fn test_progress_event_does_not_change_state() { + let mut t = ActivityTracker::new(); + t.apply(&started(1)); + let evt = DomainEvent::DownloadProgress { + id: DownloadId(1), + downloaded_bytes: 50, + total_bytes: 100, + }; + assert_eq!(t.apply(&evt), Transition::NoChange); + assert_eq!(t.active_count(), 1); + } + + #[test] + fn test_unrelated_event_is_ignored() { + let mut t = ActivityTracker::new(); + let evt = DomainEvent::SettingsUpdated; + assert_eq!(t.apply(&evt), Transition::NoChange); + assert!(!t.is_active()); + } + + #[test] + fn test_double_pause_for_unknown_id_is_safe() { + let mut t = ActivityTracker::new(); + assert_eq!(t.apply(&paused(99)), Transition::NoChange); + assert!(!t.is_active()); + } + + #[test] + fn test_started_then_started_same_id_dedupes() { + let mut t = ActivityTracker::new(); + t.apply(&started(1)); + t.apply(&started(1)); + assert_eq!(t.active_count(), 1); + assert_eq!(t.apply(&paused(1)), Transition::Deactivated); + } +} diff --git a/src-tauri/src/adapters/driven/tray/animator.rs b/src-tauri/src/adapters/driven/tray/animator.rs new file mode 100644 index 0000000..d03a9b2 --- /dev/null +++ b/src-tauri/src/adapters/driven/tray/animator.rs @@ -0,0 +1,399 @@ +//! Drives the tray icon between a static state and the pulse animation. +//! +//! - [`AnimatorCore`] is the synchronous state machine that decides, for +//! each domain event, whether to start cycling frames, stop and restore +//! the static icon, or do nothing. It also advances the frame index on +//! each tick. Pure, fully unit-tested. +//! - [`spawn_tray_animator`] wires the core to an [`EventBus`] and a tokio +//! interval, calling an [`IconSwapper`] to actually swap the tray icon. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio::time::interval; + +use crate::adapters::driven::tray::activity_tracker::{ActivityTracker, Transition}; +use crate::domain::event::DomainEvent; +use crate::domain::ports::driven::EventBus; + +/// Default cycle period — 200ms keeps CPU low while still reading as motion. +pub const DEFAULT_FRAME_INTERVAL: Duration = Duration::from_millis(200); + +/// Action the animator wants the runtime to take. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnimatorAction { + /// Begin cycling frames (active downloads detected). + StartAnimation, + /// Stop animation and restore the static icon. + StopAnimation, + /// State unchanged — no swap needed for this event. + NoOp, +} + +/// Trait abstraction over the tray icon so the loop is testable without +/// a real Tauri runtime. +pub trait IconSwapper: Send + Sync { + /// Render the animation frame at the given index (modulo frame count). + fn show_frame(&self, frame_index: usize); + /// Restore the static (default) icon. + fn show_static(&self); +} + +/// Pure state machine: tracks active downloads and the current frame index. +#[derive(Debug)] +pub struct AnimatorCore { + tracker: ActivityTracker, + frame_count: usize, + frame_index: usize, + animating: bool, +} + +impl AnimatorCore { + pub fn new(frame_count: usize) -> Self { + assert!(frame_count > 0, "frame_count must be ≥ 1"); + Self { + tracker: ActivityTracker::new(), + frame_count, + frame_index: 0, + animating: false, + } + } + + pub fn is_animating(&self) -> bool { + self.animating + } + + pub fn current_frame(&self) -> usize { + self.frame_index + } + + /// Process a domain event, return what the runtime should do. + pub fn handle_event(&mut self, event: &DomainEvent) -> AnimatorAction { + match self.tracker.apply(event) { + Transition::Activated => { + self.animating = true; + self.frame_index = 0; + AnimatorAction::StartAnimation + } + Transition::Deactivated => { + self.animating = false; + self.frame_index = 0; + AnimatorAction::StopAnimation + } + Transition::NoChange => AnimatorAction::NoOp, + } + } + + /// Advance to the next frame and return its index, or `None` if the + /// animator is currently idle. + pub fn tick(&mut self) -> Option { + if !self.animating { + return None; + } + self.frame_index = (self.frame_index + 1) % self.frame_count; + Some(self.frame_index) + } +} + +/// Wires [`AnimatorCore`] to an [`EventBus`] and a tokio interval. +/// +/// Returns immediately. The spawned task lives for the duration of the +/// process; it stops cleanly when the EventBus is dropped (channel closes). +pub fn spawn_tray_animator( + event_bus: &dyn EventBus, + swapper: Arc, + frame_count: usize, + frame_interval: Duration, +) { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + event_bus.subscribe(Box::new(move |event: &DomainEvent| { + if !is_relevant(event) { + return; + } + let _ = tx.send(event.clone()); + })); + + tokio::spawn(async move { + let mut core = AnimatorCore::new(frame_count); + let mut tick = interval(frame_interval); + // Skip the immediate first tick so we don't redraw the static icon. + tick.tick().await; + loop { + tokio::select! { + maybe_event = rx.recv() => { + let Some(event) = maybe_event else { break }; + match core.handle_event(&event) { + AnimatorAction::StartAnimation => { + swapper.show_frame(core.current_frame()); + } + AnimatorAction::StopAnimation => { + swapper.show_static(); + } + AnimatorAction::NoOp => {} + } + } + _ = tick.tick(), if core.is_animating() => { + if let Some(idx) = core.tick() { + swapper.show_frame(idx); + } + } + } + } + // Ensure the tray returns to the static icon when shutting down. + swapper.show_static(); + }); +} + +/// Filter out high-frequency events the animator doesn't need. +/// +/// Progress events fire many times per second and would just clog the +/// channel — the tracker ignores them anyway. +fn is_relevant(event: &DomainEvent) -> bool { + !matches!( + event, + DomainEvent::DownloadProgress { .. } + | DomainEvent::SegmentStarted { .. } + | DomainEvent::SegmentCompleted { .. } + | DomainEvent::SegmentFailed { .. } + | DomainEvent::SegmentSplit { .. } + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapters::driven::event::TokioEventBus; + use crate::domain::model::download::DownloadId; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Default)] + struct RecordingSwapper { + events: Mutex>, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum SwapEvent { + Frame(usize), + Static, + } + + impl IconSwapper for RecordingSwapper { + fn show_frame(&self, frame_index: usize) { + self.events + .lock() + .unwrap() + .push(SwapEvent::Frame(frame_index)); + } + fn show_static(&self) { + self.events.lock().unwrap().push(SwapEvent::Static); + } + } + + impl RecordingSwapper { + fn snapshot(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + fn started(id: u64) -> DomainEvent { + DomainEvent::DownloadStarted { id: DownloadId(id) } + } + + fn paused(id: u64) -> DomainEvent { + DomainEvent::DownloadPaused { id: DownloadId(id) } + } + + #[test] + fn test_new_core_is_not_animating() { + let core = AnimatorCore::new(8); + assert!(!core.is_animating()); + assert_eq!(core.current_frame(), 0); + } + + #[test] + #[should_panic(expected = "frame_count must be ≥ 1")] + fn test_zero_frame_count_panics() { + let _ = AnimatorCore::new(0); + } + + #[test] + fn test_started_returns_start_action() { + let mut core = AnimatorCore::new(8); + assert_eq!( + core.handle_event(&started(1)), + AnimatorAction::StartAnimation + ); + assert!(core.is_animating()); + } + + #[test] + fn test_paused_when_only_active_returns_stop_action() { + let mut core = AnimatorCore::new(8); + core.handle_event(&started(1)); + assert_eq!(core.handle_event(&paused(1)), AnimatorAction::StopAnimation); + assert!(!core.is_animating()); + } + + #[test] + fn test_second_started_returns_noop() { + let mut core = AnimatorCore::new(8); + core.handle_event(&started(1)); + assert_eq!(core.handle_event(&started(2)), AnimatorAction::NoOp); + } + + #[test] + fn test_progress_event_is_noop_and_does_not_change_frame() { + let mut core = AnimatorCore::new(8); + core.handle_event(&started(1)); + core.tick(); + let frame_before = core.current_frame(); + let evt = DomainEvent::DownloadProgress { + id: DownloadId(1), + downloaded_bytes: 50, + total_bytes: 100, + }; + assert_eq!(core.handle_event(&evt), AnimatorAction::NoOp); + assert_eq!(core.current_frame(), frame_before); + } + + #[test] + fn test_tick_returns_none_when_idle() { + let mut core = AnimatorCore::new(8); + assert!(core.tick().is_none()); + } + + #[test] + fn test_tick_advances_frame_when_active() { + let mut core = AnimatorCore::new(8); + core.handle_event(&started(1)); + assert_eq!(core.tick(), Some(1)); + assert_eq!(core.tick(), Some(2)); + } + + #[test] + fn test_tick_wraps_around_frame_count() { + let mut core = AnimatorCore::new(3); + core.handle_event(&started(1)); + let frames: Vec<_> = (0..6).map(|_| core.tick().unwrap()).collect(); + assert_eq!(frames, vec![1, 2, 0, 1, 2, 0]); + } + + #[test] + fn test_resuming_after_stop_resets_frame_to_zero() { + let mut core = AnimatorCore::new(8); + core.handle_event(&started(1)); + core.tick(); + core.tick(); + core.handle_event(&paused(1)); + assert_eq!(core.current_frame(), 0); + core.handle_event(&started(2)); + assert_eq!(core.current_frame(), 0); + } + + #[test] + fn test_is_relevant_filters_progress_and_segments() { + assert!(!is_relevant(&DomainEvent::DownloadProgress { + id: DownloadId(1), + downloaded_bytes: 0, + total_bytes: 1, + })); + assert!(!is_relevant(&DomainEvent::SegmentStarted { + download_id: DownloadId(1), + segment_id: 0, + start_byte: 0, + end_byte: 100, + })); + assert!(is_relevant(&started(1))); + assert!(is_relevant(&paused(1))); + } + + #[tokio::test] + async fn test_spawn_animator_starts_animation_on_download_started() { + let bus = TokioEventBus::new(16); + let swapper = Arc::new(RecordingSwapper::default()); + spawn_tray_animator( + &bus, + swapper.clone() as Arc, + 4, + Duration::from_millis(50), + ); + // Give the subscription task a moment to register. + tokio::time::sleep(Duration::from_millis(10)).await; + + bus.publish(started(1)); + tokio::time::sleep(Duration::from_millis(180)).await; + + let snapshot = swapper.snapshot(); + // Must contain at least one frame swap. + assert!( + snapshot.iter().any(|e| matches!(e, SwapEvent::Frame(_))), + "expected ≥1 frame swap, got {snapshot:?}", + ); + } + + #[tokio::test] + async fn test_spawn_animator_restores_static_when_all_paused() { + let bus = TokioEventBus::new(16); + let swapper = Arc::new(RecordingSwapper::default()); + spawn_tray_animator( + &bus, + swapper.clone() as Arc, + 4, + Duration::from_millis(50), + ); + tokio::time::sleep(Duration::from_millis(10)).await; + + bus.publish(started(1)); + tokio::time::sleep(Duration::from_millis(80)).await; + bus.publish(paused(1)); + tokio::time::sleep(Duration::from_millis(120)).await; + + let snapshot = swapper.snapshot(); + assert!( + snapshot.contains(&SwapEvent::Static), + "expected Static at end of cycle, got {snapshot:?}", + ); + // The last call must be Static — once paused, no more frames. + let last_frame_idx = snapshot + .iter() + .rposition(|e| matches!(e, SwapEvent::Frame(_))); + let last_static_idx = snapshot + .iter() + .rposition(|e| matches!(e, SwapEvent::Static)); + assert!( + last_static_idx > last_frame_idx, + "Static must come after the last frame, snapshot={snapshot:?}", + ); + } + + #[tokio::test] + async fn test_spawn_animator_ignores_progress_events() { + let bus = TokioEventBus::new(64); + let swapper = Arc::new(RecordingSwapper::default()); + spawn_tray_animator( + &bus, + swapper.clone() as Arc, + 4, + Duration::from_millis(500), // slow enough that ticks won't fire + ); + tokio::time::sleep(Duration::from_millis(10)).await; + + for _ in 0..50 { + bus.publish(DomainEvent::DownloadProgress { + id: DownloadId(1), + downloaded_bytes: 0, + total_bytes: 100, + }); + } + tokio::time::sleep(Duration::from_millis(50)).await; + + let snapshot = swapper.snapshot(); + assert!( + snapshot.is_empty(), + "progress should not produce swaps, got {snapshot:?}", + ); + } +} diff --git a/src-tauri/src/adapters/driven/tray/frames.rs b/src-tauri/src/adapters/driven/tray/frames.rs new file mode 100644 index 0000000..5f84a73 --- /dev/null +++ b/src-tauri/src/adapters/driven/tray/frames.rs @@ -0,0 +1,140 @@ +//! Pre-rendered RGBA frames for the tray pulse animation. +//! +//! Stays in code rather than shipping eight binary PNG assets — the design +//! ("petit dot orange qui pulse") is small enough that a procedural generator +//! is easier to tweak, has no asset-checksum churn, and lets us cover the +//! shape with regular unit tests. + +const FRAME_SIZE: u32 = 32; +const FRAME_COUNT: usize = 8; + +const DOT_CX: i32 = 16; +const DOT_CY: i32 = 16; +const MIN_RADIUS: f32 = 3.0; +const MAX_RADIUS: f32 = 7.0; + +const ORANGE_R: u8 = 0xF5; +const ORANGE_G: u8 = 0x9E; +const ORANGE_B: u8 = 0x0B; + +/// One frame of the tray animation: row-major RGBA buffer. +#[derive(Debug, Clone)] +pub struct TrayFrame { + pub rgba: Vec, + pub width: u32, + pub height: u32, +} + +/// Generates the `FRAME_COUNT` pulse frames used by the animator. +/// +/// The dot grows from `MIN_RADIUS` to `MAX_RADIUS` and back over the cycle, +/// so the animation reads as a slow pulse rather than a rotating spinner. +pub fn pulse_frames() -> Vec { + (0..FRAME_COUNT).map(render_frame).collect() +} + +fn render_frame(index: usize) -> TrayFrame { + let phase = index as f32 / FRAME_COUNT as f32; + let radius = pulse_radius(phase); + let mut rgba = vec![0u8; (FRAME_SIZE * FRAME_SIZE * 4) as usize]; + for y in 0..FRAME_SIZE as i32 { + for x in 0..FRAME_SIZE as i32 { + let dx = (x - DOT_CX) as f32; + let dy = (y - DOT_CY) as f32; + let dist = (dx * dx + dy * dy).sqrt(); + let alpha = pixel_alpha(dist, radius); + if alpha == 0 { + continue; + } + let offset = ((y as u32 * FRAME_SIZE + x as u32) * 4) as usize; + rgba[offset] = ORANGE_R; + rgba[offset + 1] = ORANGE_G; + rgba[offset + 2] = ORANGE_B; + rgba[offset + 3] = alpha; + } + } + TrayFrame { + rgba, + width: FRAME_SIZE, + height: FRAME_SIZE, + } +} + +/// Triangular wave: phase 0→0.5 grows the dot, 0.5→1 shrinks it back. +fn pulse_radius(phase: f32) -> f32 { + let triangular = if phase < 0.5 { + phase * 2.0 + } else { + 2.0 - phase * 2.0 + }; + MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * triangular +} + +fn pixel_alpha(dist: f32, radius: f32) -> u8 { + if dist <= radius - 1.0 { + 255 + } else if dist >= radius { + 0 + } else { + let t = radius - dist; + (t.clamp(0.0, 1.0) * 255.0) as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pulse_frames_returns_eight_frames() { + assert_eq!(pulse_frames().len(), FRAME_COUNT); + } + + #[test] + fn test_each_frame_is_32x32_rgba() { + for frame in pulse_frames() { + assert_eq!(frame.width, FRAME_SIZE); + assert_eq!(frame.height, FRAME_SIZE); + assert_eq!(frame.rgba.len(), (FRAME_SIZE * FRAME_SIZE * 4) as usize); + } + } + + #[test] + fn test_center_pixel_is_orange_opaque_in_every_frame() { + for (i, frame) in pulse_frames().iter().enumerate() { + let center = ((DOT_CY as u32 * FRAME_SIZE + DOT_CX as u32) * 4) as usize; + assert_eq!(frame.rgba[center], ORANGE_R, "frame {i} R"); + assert_eq!(frame.rgba[center + 1], ORANGE_G, "frame {i} G"); + assert_eq!(frame.rgba[center + 2], ORANGE_B, "frame {i} B"); + assert_eq!(frame.rgba[center + 3], 255, "frame {i} alpha"); + } + } + + #[test] + fn test_corner_pixel_is_transparent() { + for frame in pulse_frames() { + assert_eq!(frame.rgba[3], 0, "top-left alpha should be 0"); + let last = frame.rgba.len() - 1; + assert_eq!(frame.rgba[last], 0, "bottom-right alpha should be 0"); + } + } + + #[test] + fn test_frames_differ_across_cycle() { + let frames = pulse_frames(); + // Smallest (frame 0) and largest (frame at peak) must differ. + let first = &frames[0]; + let peak = &frames[FRAME_COUNT / 2]; + assert_ne!(first.rgba, peak.rgba); + } + + #[test] + fn test_pulse_radius_is_min_at_phase_zero() { + assert!((pulse_radius(0.0) - MIN_RADIUS).abs() < 1e-4); + } + + #[test] + fn test_pulse_radius_is_max_at_phase_half() { + assert!((pulse_radius(0.5) - MAX_RADIUS).abs() < 1e-4); + } +} diff --git a/src-tauri/src/adapters/driven/tray/mod.rs b/src-tauri/src/adapters/driven/tray/mod.rs index 30b0636..bb720f6 100644 --- a/src-tauri/src/adapters/driven/tray/mod.rs +++ b/src-tauri/src/adapters/driven/tray/mod.rs @@ -1,2 +1,10 @@ +mod activity_tracker; +mod animator; +mod frames; mod system_tray; +mod tauri_swapper; + +pub use animator::{DEFAULT_FRAME_INTERVAL, IconSwapper, spawn_tray_animator}; +pub use frames::pulse_frames; pub use system_tray::setup_system_tray; +pub use tauri_swapper::TauriIconSwapper; diff --git a/src-tauri/src/adapters/driven/tray/system_tray.rs b/src-tauri/src/adapters/driven/tray/system_tray.rs index c2649c3..e8ba6ac 100644 --- a/src-tauri/src/adapters/driven/tray/system_tray.rs +++ b/src-tauri/src/adapters/driven/tray/system_tray.rs @@ -1,20 +1,21 @@ use tauri::{ AppHandle, Emitter, Manager, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, - tray::TrayIconBuilder, + tray::{TrayIcon, TrayIconBuilder}, }; use tracing::{info, warn}; use crate::adapters::driving::tauri_ipc::AppState; -/// Initializes the system tray with menu items. +/// Initializes the system tray with menu items and returns the +/// [`TrayIcon`] handle so callers can drive the animated icon. /// /// `clipboard_enabled` controls the initial checked state of the Clipboard /// Monitoring checkbox. Pass the value from persisted config when available. pub fn setup_system_tray( app: &tauri::App, clipboard_enabled: bool, -) -> Result<(), Box> { +) -> Result> { let pause_all = MenuItem::with_id(app, "pause-all", "Pause All", true, None::<&str>)?; let resume_all = MenuItem::with_id(app, "resume-all", "Resume All", true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; @@ -43,7 +44,7 @@ pub fn setup_system_tray( ], )?; - let _tray = TrayIconBuilder::new() + let tray = TrayIconBuilder::new() .icon( app.default_window_icon() .cloned() @@ -74,7 +75,7 @@ pub fn setup_system_tray( .build(app)?; info!("System tray initialized"); - Ok(()) + Ok(tray) } fn handle_tray_menu_event(app: &AppHandle, menu_id: &str) { diff --git a/src-tauri/src/adapters/driven/tray/tauri_swapper.rs b/src-tauri/src/adapters/driven/tray/tauri_swapper.rs new file mode 100644 index 0000000..2c2ecbf --- /dev/null +++ b/src-tauri/src/adapters/driven/tray/tauri_swapper.rs @@ -0,0 +1,61 @@ +//! [`IconSwapper`] implementation backed by a real Tauri [`TrayIcon`]. +//! +//! Owns the pre-rendered pulse frames as `Image<'static>` (so the underlying +//! RGBA buffers live as long as the swapper) plus the static fallback icon, +//! and forwards each request from the animator to `TrayIcon::set_icon`. + +use tauri::image::Image; +use tauri::tray::TrayIcon; +use tracing::warn; + +use crate::adapters::driven::tray::animator::IconSwapper; +use crate::adapters::driven::tray::frames::TrayFrame; + +pub struct TauriIconSwapper { + tray: TrayIcon, + frames: Vec>, + static_icon: Image<'static>, +} + +impl TauriIconSwapper { + /// Builds a swapper from a tray handle, the static fallback icon, and + /// the animation frames. Returns `None` when no frames are supplied — + /// the animator should not be spawned in that case. + pub fn new( + tray: TrayIcon, + static_icon: Image<'static>, + frames: Vec, + ) -> Option { + if frames.is_empty() { + return None; + } + let frames = frames + .into_iter() + .map(|f| Image::new_owned(f.rgba, f.width, f.height)) + .collect(); + Some(Self { + tray, + frames, + static_icon, + }) + } + + pub fn frame_count(&self) -> usize { + self.frames.len() + } +} + +impl IconSwapper for TauriIconSwapper { + fn show_frame(&self, frame_index: usize) { + let frame = &self.frames[frame_index % self.frames.len()]; + if let Err(e) = self.tray.set_icon(Some(frame.clone())) { + warn!(error = %e, "tray set_icon (animation frame) failed"); + } + } + + fn show_static(&self) { + if let Err(e) = self.tray.set_icon(Some(self.static_icon.clone())) { + warn!(error = %e, "tray set_icon (static) failed"); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c74fcb..e22a0e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,7 +40,10 @@ pub use adapters::driven::sqlite::download_repo::SqliteDownloadRepo; pub use adapters::driven::sqlite::history_repo::SqliteHistoryRepo; pub use adapters::driven::sqlite::progress_bridge::spawn_sqlite_progress_bridge; pub use adapters::driven::sqlite::stats_repo::SqliteStatsRepo; -pub use adapters::driven::tray::setup_system_tray; +pub use adapters::driven::tray::{ + DEFAULT_FRAME_INTERVAL, IconSwapper, TauriIconSwapper, pulse_frames, setup_system_tray, + spawn_tray_animator, +}; pub use application::command_bus::CommandBus; pub use application::commands::store_refresh::{read_cache, write_cache}; pub use application::error::AppError; @@ -364,8 +367,28 @@ pub fn run() { }); // ── System tray ───────────────────────────────────────── - if let Err(e) = setup_system_tray(app, false) { - tracing::error!("Failed to setup system tray: {e}"); + match setup_system_tray(app, false) { + Ok(tray) => { + let static_icon = app + .default_window_icon() + .cloned() + .map(|i| i.to_owned()) + .ok_or("default window icon missing")?; + let frames = pulse_frames(); + if let Some(swapper) = TauriIconSwapper::new(tray, static_icon, frames) { + let frame_count = swapper.frame_count(); + let swapper: Arc = Arc::new(swapper); + spawn_tray_animator( + event_bus.as_ref(), + swapper, + frame_count, + DEFAULT_FRAME_INTERVAL, + ); + } + } + Err(e) => { + tracing::error!("Failed to setup system tray: {e}"); + } } // ── Event bridges (domain events → frontend + desktop) ── From 97bb03a9d81b6ad59f1ae26b6840f4e8bed1e8d6 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:45:58 +0200 Subject: [PATCH 2/3] fix(ui): degrade tray animator gracefully + bound event channel - lib.rs: missing default window icon now logs a warning and skips the animator instead of aborting Tauri setup. Matches the logging-only fallback used when system tray init fails. - animator.rs: replace unbounded mpsc channel with a 64-slot bounded channel + try_send. ActivityTracker is idempotent, so dropping events under burst load is safe and the memory ceiling is now explicit. --- .../src/adapters/driven/tray/animator.rs | 6 ++-- src-tauri/src/lib.rs | 31 ++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/adapters/driven/tray/animator.rs b/src-tauri/src/adapters/driven/tray/animator.rs index d03a9b2..5ebd4c9 100644 --- a/src-tauri/src/adapters/driven/tray/animator.rs +++ b/src-tauri/src/adapters/driven/tray/animator.rs @@ -106,13 +106,15 @@ pub fn spawn_tray_animator( frame_count: usize, frame_interval: Duration, ) { - let (tx, mut rx) = mpsc::unbounded_channel::(); + // Bounded channel: ActivityTracker is idempotent, so dropping events under + // burst load is safe — only the final active/idle state matters. + let (tx, mut rx) = mpsc::channel::(64); event_bus.subscribe(Box::new(move |event: &DomainEvent| { if !is_relevant(event) { return; } - let _ = tx.send(event.clone()); + let _ = tx.try_send(event.clone()); })); tokio::spawn(async move { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e22a0e8..a1c03b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -369,20 +369,23 @@ pub fn run() { // ── System tray ───────────────────────────────────────── match setup_system_tray(app, false) { Ok(tray) => { - let static_icon = app - .default_window_icon() - .cloned() - .map(|i| i.to_owned()) - .ok_or("default window icon missing")?; - let frames = pulse_frames(); - if let Some(swapper) = TauriIconSwapper::new(tray, static_icon, frames) { - let frame_count = swapper.frame_count(); - let swapper: Arc = Arc::new(swapper); - spawn_tray_animator( - event_bus.as_ref(), - swapper, - frame_count, - DEFAULT_FRAME_INTERVAL, + if let Some(static_icon) = + app.default_window_icon().cloned().map(|i| i.to_owned()) + { + let frames = pulse_frames(); + if let Some(swapper) = TauriIconSwapper::new(tray, static_icon, frames) { + let frame_count = swapper.frame_count(); + let swapper: Arc = Arc::new(swapper); + spawn_tray_animator( + event_bus.as_ref(), + swapper, + frame_count, + DEFAULT_FRAME_INTERVAL, + ); + } + } else { + tracing::warn!( + "default window icon missing; tray animation disabled" ); } } From 5786020397c8b8f942f643c18d8bf3d00d11fe22 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:57:05 +0200 Subject: [PATCH 3/3] fix(ui): revert tray animator to lossless channel + delay missed ticks - animator.rs: revert to mpsc::unbounded_channel. ActivityTracker tracks per-download add/remove transitions, so dropping a Started(B) followed by a delivered Paused(A) would leave the tracker thinking we are idle while B is still downloading. The channel only carries already-filtered lifecycle events and is drained immediately, so unbounded growth is not a practical concern. - animator.rs: set MissedTickBehavior::Delay on the frame interval. With the default Burst behavior, ticks accumulated while is_animating() was false would all fire as soon as animation restarts, causing frame skip-ahead. Delay reschedules the next tick relative to when the loop observes it, so frames advance at a clean periodic cadence. --- src-tauri/src/adapters/driven/tray/animator.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/adapters/driven/tray/animator.rs b/src-tauri/src/adapters/driven/tray/animator.rs index 5ebd4c9..15ff533 100644 --- a/src-tauri/src/adapters/driven/tray/animator.rs +++ b/src-tauri/src/adapters/driven/tray/animator.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; -use tokio::time::interval; +use tokio::time::{MissedTickBehavior, interval}; use crate::adapters::driven::tray::activity_tracker::{ActivityTracker, Transition}; use crate::domain::event::DomainEvent; @@ -106,20 +106,28 @@ pub fn spawn_tray_animator( frame_count: usize, frame_interval: Duration, ) { - // Bounded channel: ActivityTracker is idempotent, so dropping events under - // burst load is safe — only the final active/idle state matters. - let (tx, mut rx) = mpsc::channel::(64); + // Lossless channel: ActivityTracker tracks per-download add/remove events, + // so dropping a Started(B) followed by a delivered Paused(A) would leave + // the tracker thinking we are idle while B is still active. The channel + // is drained immediately by the spawned task and only carries lifecycle + // events (high-frequency progress/segment events are filtered out before + // send), so unbounded growth is not a practical concern. + let (tx, mut rx) = mpsc::unbounded_channel::(); event_bus.subscribe(Box::new(move |event: &DomainEvent| { if !is_relevant(event) { return; } - let _ = tx.try_send(event.clone()); + let _ = tx.send(event.clone()); })); tokio::spawn(async move { let mut core = AnimatorCore::new(frame_count); let mut tick = interval(frame_interval); + // Delay missed ticks instead of bursting them — when animation + // restarts after a long idle, we want clean periodic frames, not + // a flurry of catch-up ticks. + tick.set_missed_tick_behavior(MissedTickBehavior::Delay); // Skip the immediate first tick so we don't redraw the static icon. tick.tick().await; loop {