From 5e9cd18cf0e33e3c08c2c05622c98fa836a4012c Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:31:57 +0200 Subject: [PATCH 1/5] feat(plugin): add report-broken-plugin action with GitHub issue pre-fill (task 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins now expose a "Report broken plugin" item in the kebab menu of the Plugin Store row. 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. Domain - `PluginInfo` gains `repository_url: Option` (parsed from a new `[plugin].repository` key in `plugin.toml`). - New `UrlOpener` driven port with platform-native `SystemUrlOpener` (xdg-open / open / cmd start), `http(s)://` only by validation so the OS launcher never receives `javascript:` / `file://` payloads. - `build_report_broken_url` is std-only: RFC 3986 unreserved-set percent encoder, last 50 log lines kept, GitHub-only repository hosts, accepts `.git` suffix, rejects malformed URLs with `DomainError::ValidationError`. Application - `ReportBrokenPluginCommand` handler returns `AppError::Validation` when the manifest carries no `repository_url` and `AppError::NotFound` when the plugin is not loaded; the URL is opened off the async runtime via `tokio::task::spawn_blocking`. Adapters - `manifest.rs` parses `[plugin].repository` (optional, backward compatible — plugins without the field still install). - 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. Frontend - `PluginStoreRow` renders an extra menu item when `onReportBroken` is wired (hidden otherwise), and the parent `PluginsView` invokes the IPC through `useTauriMutation` with success / error toasts. - i18n (en/fr): `plugins.action.reportBroken`, `plugins.toast.reportBroken{Success,Error}`. Coverage - 11 new Rust tests (URL builder edge cases + handler) and 2 new Vitest cases for the row's menu entry. cargo test --lib reports 909 passed, npx vitest run reports 582 passed. --- CHANGELOG.md | 1 + .../src/adapters/driven/filesystem/mod.rs | 2 + .../adapters/driven/filesystem/url_opener.rs | 113 +++++ .../src/adapters/driven/plugin/manifest.rs | 56 ++- src-tauri/src/adapters/driving/tauri_ipc.rs | 35 +- src-tauri/src/application/command_bus.rs | 20 +- src-tauri/src/application/commands/mod.rs | 24 + .../commands/report_broken_plugin.rs | 446 ++++++++++++++++++ src-tauri/src/domain/model/plugin.rs | 284 +++++++++++ src-tauri/src/domain/ports/driven/mod.rs | 2 + .../src/domain/ports/driven/url_opener.rs | 18 + src-tauri/src/lib.rs | 12 +- src/i18n/locales/en.json | 7 +- src/i18n/locales/fr.json | 7 +- src/views/PluginsView.tsx | 21 +- src/views/PluginsView/PluginStoreRow.tsx | 24 +- .../__tests__/PluginStoreRow.test.tsx | 39 +- 17 files changed, 1075 insertions(+), 36 deletions(-) create mode 100644 src-tauri/src/adapters/driven/filesystem/url_opener.rs create mode 100644 src-tauri/src/application/commands/report_broken_plugin.rs create mode 100644 src-tauri/src/domain/ports/driven/url_opener.rs 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..5b81a31 --- /dev/null +++ b/src-tauri/src/adapters/driven/filesystem/url_opener.rs @@ -0,0 +1,113 @@ +//! 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)]); + #[cfg(target_os = "windows")] + let (program, args): (&str, Vec) = ( + "cmd", + vec![ + std::ffi::OsString::from("/C"), + std::ffi::OsString::from("start"), + std::ffi::OsString::from(""), + std::ffi::OsString::from(url), + ], + ); + + run_launcher(program, &args) + } +} + +fn validate_http_url(url: &str) -> Result<(), DomainError> { + if url.starts_with("http://") || url.starts_with("https://") { + Ok(()) + } else { + Err(DomainError::ValidationError(format!( + "URL must start with http(s)://, got '{url}'" + ))) + } +} + +#[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> { + 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,