Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DownloadId>` 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<dyn EventBus>` 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<Image>)` 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<AtomicBool>` / `Arc<AtomicU64>` 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<u64>)`; 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<u64>` 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<AtomicU64>` 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)
Expand Down
208 changes: 208 additions & 0 deletions src-tauri/src/adapters/driven/tray/activity_tracker.rs
Original file line number Diff line number Diff line change
@@ -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<DownloadId>,
}

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);
}
}
Loading
Loading