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

- 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 `<app_data_dir>/.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<dyn HistoryRepository>` and `Arc<dyn ConfigStore>` 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<ConfigDto> 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 `<MoveDialog>` (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)
- Queue reordering via drag & drop and Move-to-Top / Move-to-Bottom (PRD-v2 P0.12, task 12): new Tauri IPC commands `download_move_to_top(id)`, `download_move_to_bottom(id)`, `download_reorder_queue(orderedIds)` backed by `MoveToTopCommand` / `MoveToBottomCommand` / `ReorderQueueCommand` in the application layer. A new `queue_position` column (migration `m20260425_000004_add_queue_position`, `BIGINT NOT NULL DEFAULT 0`, index `idx_downloads_queue_position`) persists the manual ordering so drag-reorders survive restart. `QueueManager` now sorts candidates by priority desc → `queue_position` asc → `created_at` asc, and also subscribes to two new domain events (`DownloadPrioritySet`, `QueueReordered`) so changing priority triggers immediate rescheduling — a high-priority item starts as soon as a slot is free. The default `download_list` sort uses `queue_position` ASC → `created_at` DESC so fresh downloads (position 0) still appear newest-first while manually-moved rows stick. Frontend integration in `DownloadsTable` adds `@dnd-kit/core` + `@dnd-kit/sortable` with a drag handle column (enabled only for Queued/Retry/Waiting rows), a `SortableContext` around the virtualized rows, and a `computeReorderedIds` helper that filters non-reorderable IDs from the new order before invoking `download_reorder_queue`. Row dropdown menu gets Move to top / Move to bottom items for reorderable rows. New i18n keys `downloads.table.actions.moveToTop` / `moveToBottom` (en/fr). `DownloadView` / `DownloadViewDto` now expose `priority` + `queuePosition`. (task 12)
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/adapters/driven/plugin/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ pub fn build_host_functions(

// Ensure per-plugin config/state maps exist before any function runs.
let plugin_configs = shared.plugin_configs.entry(name.clone()).or_default();
// Persisted overrides may have been replayed before the plugin loaded.
// Drop entries that no longer pass the current schema (e.g. a manifest
// update narrowed an enum, tightened a regex, or removed the key) so
// the WASM plugin never observes a stale schema-invalid value via
// `get_config`.
let schema = manifest.config_schema();
plugin_configs.retain(|key, value| {
if schema.validate(key, value).is_ok() {
true
} else {
tracing::warn!(
plugin = %name,
key = %key,
"dropping persisted plugin config value that no longer matches schema"
);
false
}
});
for (key, value) in manifest.config_defaults() {
plugin_configs
.entry(key.clone())
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/adapters/driven/plugin/extism_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ impl PluginLoader for ExtismPluginLoader {
self.registry.set_enabled(name, enabled)
}

fn get_manifest(&self, name: &str) -> Result<Option<PluginManifest>, DomainError> {
Ok(self.registry.manifest(name))
}

fn set_runtime_config(&self, name: &str, key: &str, value: &str) -> Result<(), DomainError> {
self.shared_resources
.plugin_configs()
.entry(name.to_string())
.or_default()
.insert(key.to_string(), value.to_string());
Ok(())
}

fn extract_links(&self, url: &str) -> Result<String, DomainError> {
self.call_url_plugin_function(url, "extract_links")
}
Expand Down
277 changes: 275 additions & 2 deletions src-tauri/src/adapters/driven/plugin/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
use serde::Deserialize;

use crate::domain::error::DomainError;
use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest};
use crate::domain::model::plugin::{
ConfigField, ConfigFieldType, PluginCategory, PluginConfigSchema, PluginInfo, PluginManifest,
regex_syntax_error, unsupported_regex_feature,
};

#[derive(Deserialize)]
struct RawManifest {
Expand Down Expand Up @@ -36,7 +39,14 @@ struct RawCapabilities {

#[derive(Deserialize)]
struct RawConfigEntry {
#[serde(rename = "type")]
field_type: Option<String>,
default: Option<toml::Value>,
description: Option<String>,
options: Option<Vec<toml::Value>>,
min: Option<f64>,
max: Option<f64>,
regex: Option<String>,
}

/// Parse a plugin directory containing `plugin.toml` and a `.wasm` file.
Expand Down Expand Up @@ -83,10 +93,12 @@ pub fn parse_manifest(dir: &Path) -> Result<(PluginManifest, PathBuf), DomainErr
.map(build_capabilities)
.unwrap_or_default();
let config_defaults = build_config_defaults(&raw.config)?;
let config_schema = build_config_schema(&raw.config)?;

let mut manifest = PluginManifest::new(info)
.with_capabilities(caps)
.with_config_defaults(config_defaults);
.with_config_defaults(config_defaults)
.with_config_schema(config_schema);
if let Some(v) = raw.plugin.min_vortex_version {
manifest = manifest.with_min_version(v);
}
Expand Down Expand Up @@ -140,6 +152,61 @@ fn build_config_defaults(
Ok(defaults)
}

fn build_config_schema(
raw_config: &HashMap<String, RawConfigEntry>,
) -> Result<PluginConfigSchema, DomainError> {
let mut schema = PluginConfigSchema::new();
for (key, entry) in raw_config {
let field_type = match entry.field_type.as_deref() {
Some(t) => t
.parse::<ConfigFieldType>()
.map_err(|e| DomainError::PluginError(format!("config field '{key}': {e}")))?,
None => ConfigFieldType::String,
};

let mut field = ConfigField::new(field_type);
if let Some(default) = &entry.default {
field = field.with_default(encode_config_default(default)?);
}
if let Some(desc) = &entry.description {
field = field.with_description(desc.clone());
}
if let Some(options) = &entry.options {
let opts = options
.iter()
.map(encode_config_default)
.collect::<Result<Vec<_>, _>>()?;
field = field.with_options(opts);
}
if let Some(min) = entry.min {
field = field.with_min(min);
}
if let Some(max) = entry.max {
field = field.with_max(max);
}
if let Some(regex) = &entry.regex {
if let Some(err) = regex_syntax_error(regex) {
return Err(DomainError::PluginError(format!(
"config field '{key}' regex '{regex}' is malformed: {err}"
)));
}
if let Some(bad) = unsupported_regex_feature(regex) {
return Err(DomainError::PluginError(format!(
"config field '{key}' regex '{regex}' uses unsupported feature '{bad}' (alternation, groups and counted quantifiers are not implemented)"
)));
}
field = field.with_regex(regex.clone());
}
if let Some(default) = field.default_value() {
field.validate(default).map_err(|e| {
DomainError::PluginError(format!("config field '{key}' has invalid default: {e}"))
})?;
}
schema.insert(key.clone(), field);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Ok(schema)
}

fn encode_config_default(value: &toml::Value) -> Result<String, DomainError> {
match value {
toml::Value::String(s) => Ok(s.clone()),
Expand Down Expand Up @@ -448,4 +515,210 @@ description = "Dir name mismatch"
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), DomainError::PluginError(_)));
}

#[test]
fn test_parse_manifest_extracts_full_config_schema() {
use crate::domain::model::plugin::ConfigFieldType;

let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("with-schema");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "with-schema"
version = "1.0.0"
category = "crawler"
author = "Alice"
description = "Schema fields"

[config]
default_quality = { type = "enum", default = "1080p", options = ["360p", "720p", "1080p"], description = "Preferred resolution" }
extract_audio_only = { type = "boolean", default = false }
max_retries = { type = "integer", default = 3, min = 0, max = 10 }
"#,
);
write_dummy_wasm(&plugin_dir, "with-schema.wasm");

let (manifest, _) = parse_manifest(&plugin_dir).unwrap();
let schema = manifest.config_schema();
assert_eq!(schema.len(), 3);

let q = schema.get("default_quality").unwrap();
assert_eq!(q.field_type(), ConfigFieldType::Enum);
assert_eq!(q.default_value(), Some("1080p"));
assert_eq!(q.options(), &["360p", "720p", "1080p"]);
assert_eq!(q.description(), Some("Preferred resolution"));

let a = schema.get("extract_audio_only").unwrap();
assert_eq!(a.field_type(), ConfigFieldType::Boolean);
assert_eq!(a.default_value(), Some("false"));

let r = schema.get("max_retries").unwrap();
assert_eq!(r.field_type(), ConfigFieldType::Integer);
assert_eq!(r.min(), Some(0.0));
assert_eq!(r.max(), Some(10.0));
}

#[test]
fn test_parse_manifest_missing_type_defaults_to_string() {
use crate::domain::model::plugin::ConfigFieldType;

let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("loose-config");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "loose-config"
version = "1.0.0"
category = "crawler"
author = "Alice"
description = "Loose schema"

[config]
api_token = { default = "" }
"#,
);
write_dummy_wasm(&plugin_dir, "loose-config.wasm");

let (manifest, _) = parse_manifest(&plugin_dir).unwrap();
let f = manifest.config_schema().get("api_token").unwrap();
assert_eq!(f.field_type(), ConfigFieldType::String);
}

#[test]
fn test_parse_manifest_unknown_type_returns_err() {
let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("bad-type");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "bad-type"
version = "1.0.0"
category = "crawler"
author = "Alice"
description = "Bad type"

[config]
foo = { type = "spaceship" }
"#,
);
write_dummy_wasm(&plugin_dir, "bad-type.wasm");

let result = parse_manifest(&plugin_dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("spaceship"), "got: {err}");
}

#[test]
fn test_parse_manifest_no_config_yields_empty_schema() {
let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("no-config");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "no-config"
version = "1.0.0"
category = "utility"
author = "Charlie"
description = "No config block"
"#,
);
write_dummy_wasm(&plugin_dir, "no-config.wasm");

let (manifest, _) = parse_manifest(&plugin_dir).unwrap();
assert!(manifest.config_schema().is_empty());
}

#[test]
fn test_parse_manifest_rejects_malformed_regex() {
let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("malformed-regex");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "malformed-regex"
version = "1.0.0"
category = "utility"
author = "Alice"
description = "Malformed regex"

[config]
mode = { type = "string", regex = "[abc" }
"#,
);
write_dummy_wasm(&plugin_dir, "malformed-regex.wasm");

let result = parse_manifest(&plugin_dir);
let err = result.unwrap_err().to_string();
assert!(
err.contains("malformed"),
"expected malformed-pattern error, got: {err}"
);
}

#[test]
fn test_parse_manifest_rejects_unsupported_regex_feature() {
let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("bad-regex");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "bad-regex"
version = "1.0.0"
category = "utility"
author = "Alice"
description = "Bad regex"

[config]
mode = { type = "string", regex = "^(foo|bar)$" }
"#,
);
write_dummy_wasm(&plugin_dir, "bad-regex.wasm");

let result = parse_manifest(&plugin_dir);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unsupported feature"),
"expected unsupported-feature error, got: {err}"
);
}

#[test]
fn test_parse_manifest_extracts_regex_constraint() {
let tmp = TempDir::new().unwrap();
let plugin_dir = tmp.path().join("regexed");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_plugin_toml(
&plugin_dir,
r#"
[plugin]
name = "regexed"
version = "1.0.0"
category = "utility"
author = "Alice"
description = "Regex"

[config]
api_key = { type = "string", regex = "^[a-z0-9]+$" }
"#,
);
write_dummy_wasm(&plugin_dir, "regexed.wasm");

let (manifest, _) = parse_manifest(&plugin_dir).unwrap();
let f = manifest.config_schema().get("api_key").unwrap();
assert_eq!(f.regex(), Some("^[a-z0-9]+$"));
}
}
Loading
Loading