diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f9391..9869ac1 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 +- "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) - History retention with automatic daily purge (PRD-v2 P0.14, task 14): new `history_retention_days` setting (default 30, presets 7 / 30 / 90 / 365 / `0 = unlimited`) exposed in the *General* Settings tab as a `Select` dropdown wired to `settings_update`. Backend ships a `Clock` domain port (`SystemClock` adapter under `adapters/driven/scheduler/`) and a `HistoryPurgeWorker` daemon spawned during Tauri setup that hard-deletes `history` rows where `completed_at < now - retention_days * 86_400`. The worker persists its last run as a Unix-epoch timestamp inside `/.history_purge_state` (sentinel filename `HISTORY_PURGE_STATE_FILE`). On startup, the daemon reads the sentinel and either runs immediately (missing/stale) or sleeps for `SECS_PER_DAY - elapsed` so the first post-launch purge stays anchored to the previous successful run instead of drifting up to ~47h after a restart; the recurring loop then ticks every 24h via `tokio::time::interval` with `MissedTickBehavior::Skip`. `retention_days <= 0` is a no-op that does not write the sentinel, so the next run re-fires the moment the user re-enables retention; corrupt sentinels are treated as "never ran" so a stuck file never blocks the scheduler. The worker shares the same `Arc` and `Arc` the IPC layer already mutates, so a settings change is observed without restart. Domain helper `normalize_history_retention_days` clamps negatives back to `0` and is now applied at every write boundary — `apply_patch` (so a crafted `settings_update` payload cannot persist a negative) and `From for AppConfig` (so a hand-edited `config.toml` is normalized at load) — plus the worker itself for defense-in-depth. (task 14) - Change-directory action that moves a download's on-disk file (and its `.vortex-meta` sidecar when present) into a new destination folder (PRD-v2 P0.13, task 13). New Tauri IPC commands `download_change_directory(id, newDestinationDir)` and `download_change_directory_bulk(ids, newDestinationDir)` are backed by `ChangeDirectoryCommand` / `ChangeDirectoryBulkCommand` in the application layer; the bulk variant returns a structured `{ moved: number[], failed: { id, message }[] }` outcome so the UI can keep failed rows selected for retry instead of swallowing partial errors. The handler pauses the download engine for `Downloading` items, relocates the body and the `.vortex-meta` sidecar, persists the new path, then resumes — segments survive the move so the engine picks up exactly where it left off. `Extracting` and `Checking` downloads are rejected because another worker is actively reading the file. The `FileStorage` port grows `move_file`, `move_meta` and `file_exists`; the production `FsFileStorage` adapter prefers `fs::rename` for same-filesystem moves and falls back to copy + size-verify + delete-source for cross-device cases (EXDEV / `ErrorKind::CrossesDevices`), with rollback on any partial failure so the source file always stays intact. New `DomainEvent::DownloadDirectoryChanged { id, newDestinationPath }` is forwarded to the frontend as the `download-directory-changed` event. Frontend ships a reusable `` (folder picker via `useBrowseFolder`, current path + selected path preview, confirm disabled until a folder is picked) and a `Move to...` action in the downloads `ActionsBar` selection toolbar that wires the bulk IPC, surfaces success / partial-failure / error toasts and clears or re-narrows the selection accordingly. New i18n keys `downloads.actions.moveSelected`, `downloads.moveDialog.*` and `downloads.toast.{moveSucceeded,movePartial,moveError}` (en/fr). (task 13) diff --git a/src-tauri/src/adapters/driven/filesystem/mod.rs b/src-tauri/src/adapters/driven/filesystem/mod.rs index 801c775..6b33e8d 100644 --- a/src-tauri/src/adapters/driven/filesystem/mod.rs +++ b/src-tauri/src/adapters/driven/filesystem/mod.rs @@ -4,7 +4,9 @@ mod download_dir; mod file_opener; mod file_storage; mod meta_storage; +mod url_opener; pub use download_dir::resolve_system_download_dir; pub use file_opener::SystemFileOpener; pub use file_storage::FsFileStorage; +pub use url_opener::SystemUrlOpener; diff --git a/src-tauri/src/adapters/driven/filesystem/url_opener.rs b/src-tauri/src/adapters/driven/filesystem/url_opener.rs new file mode 100644 index 0000000..a88ab99 --- /dev/null +++ b/src-tauri/src/adapters/driven/filesystem/url_opener.rs @@ -0,0 +1,259 @@ +//! Platform-backed [`UrlOpener`] implementation. +//! +//! Mirrors [`super::SystemFileOpener`] but takes a URL string instead of a +//! filesystem path. The validation rule is conservative: only `http://` +//! and `https://` are accepted so the OS launcher never receives +//! `javascript:`, `file://`, or `data:` payloads from a rogue caller. + +use std::process::Command; + +use crate::domain::error::DomainError; +use crate::domain::ports::driven::UrlOpener; + +pub struct SystemUrlOpener; + +impl Default for SystemUrlOpener { + fn default() -> Self { + Self + } +} + +impl SystemUrlOpener { + pub fn new() -> Self { + Self + } +} + +impl UrlOpener for SystemUrlOpener { + fn open_url(&self, url: &str) -> Result<(), DomainError> { + validate_http_url(url)?; + + #[cfg(target_os = "linux")] + let (program, args): (&str, Vec) = + ("xdg-open", vec![std::ffi::OsString::from(url)]); + #[cfg(target_os = "macos")] + let (program, args): (&str, Vec) = + ("open", vec![std::ffi::OsString::from(url)]); + // `rundll32 url.dll,FileProtocolHandler ` is the canonical + // Windows shortcut for "open this URL in the default browser". + // Unlike `cmd /C start`, it does NOT pass the URL through the + // command interpreter, so query strings containing `&` (issue + // body separators) or `%` (percent-encoded characters) reach the + // shell-execute call intact. + #[cfg(target_os = "windows")] + let (program, args): (&str, Vec) = ( + "rundll32", + vec![ + std::ffi::OsString::from("url.dll,FileProtocolHandler"), + std::ffi::OsString::from(url), + ], + ); + + run_launcher(program, &args) + } +} + +fn validate_http_url(url: &str) -> Result<(), DomainError> { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .ok_or_else(|| { + DomainError::ValidationError(format!("URL must start with http(s)://, got '{url}'")) + })?; + + // Reject scheme-only inputs (`https://`), missing-authority shapes + // (`https:///foo`, `https://?x`, `https://#x`) and any whitespace, + // which would derail the OS launcher even though the prefix check + // passed. + if rest.is_empty() + || rest.starts_with('/') + || rest.starts_with('?') + || rest.starts_with('#') + || url.chars().any(char::is_whitespace) + { + return Err(DomainError::ValidationError(format!( + "invalid http(s) URL: '{url}'" + ))); + } + + // Authority MUST carry a non-empty host. RFC 3986 leaves the door + // open for `https://:443/x` (port-only) and `https://user@/x` + // (userinfo without host) — both are accepted by the prefix check + // but mean nothing to a browser and just produce a launcher error. + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let host_port = authority.rsplit('@').next().unwrap_or_default(); + let (host, port) = if let Some(rest) = host_port.strip_prefix('[') { + // Bracketed IPv6 host: require `]`, non-empty literal, and an + // optional `:port` tail — anything else (`[::1]oops`) is junk. + let end = rest + .find(']') + .ok_or_else(|| DomainError::ValidationError(format!("invalid http(s) URL: '{url}'")))?; + if end == 0 { + return Err(DomainError::ValidationError(format!( + "http(s) URL has empty host: '{url}'" + ))); + } + let tail = &rest[end + 1..]; + if !tail.is_empty() && !tail.starts_with(':') { + return Err(DomainError::ValidationError(format!( + "invalid http(s) URL: '{url}'" + ))); + } + (&rest[..end], tail.strip_prefix(':')) + } else { + match host_port.split_once(':') { + Some((h, p)) => (h, Some(p)), + None => (host_port, None), + } + }; + if host.is_empty() { + return Err(DomainError::ValidationError(format!( + "http(s) URL has empty host: '{url}'" + ))); + } + if let Some(p) = port + && (p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit())) + { + return Err(DomainError::ValidationError(format!( + "http(s) URL has non-numeric port: '{url}'" + ))); + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn run_launcher(program: &str, args: &[std::ffi::OsString]) -> Result<(), DomainError> { + let status = Command::new(program) + .args(args) + .status() + .map_err(|e| DomainError::StorageError(format!("failed to launch {program}: {e}")))?; + if !status.success() { + return Err(DomainError::StorageError(format!( + "{program} exited with status {status}" + ))); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn run_launcher(program: &str, args: &[std::ffi::OsString]) -> Result<(), DomainError> { + // `rundll32` returns 0 even when the user has no default browser, so + // the exit code carries no signal worth checking. We only surface + // process-spawn failures (missing binary, sandboxing) — those are + // the cases where the URL really did not reach Windows. This mirrors + // the rationale documented next to `SystemFileOpener` for the same + // reason. + let _status = Command::new(program) + .args(args) + .status() + .map_err(|e| DomainError::StorageError(format!("failed to launch {program}: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_url_rejects_non_http_scheme() { + let opener = SystemUrlOpener::new(); + for bad in [ + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,