diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea3582e..b8e18b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MAT-136 registry coherence**: the soundcloud entry's `min_vortex_version` + is raised to 0.2.0 (the released 1.2.1 build uses 0.2.0-only host + functions), the MEGA and 1fichier descriptions no longer overpromise + unimplemented features, and a new `registry_coherence` integration test + validates every registry entry in CI — required fields, semver formats + (leading-zero segments rejected), checksum shape, known category including + `utility`, unique names; `min_vortex_version` stays optional as documented + in the registry template. +- **MAT-136 documentation paths**: README, ARCHI.md, and CLAUDE.md now point + at the real Tauri app-data location for the database, `config.toml`, and + plugins directory (`~/.local/share/dev.vortex.app/` on Linux, with macOS and + Windows equivalents) instead of the never-used `~/.config/vortex/`. - **MAT-133 cleanup observability**: engine tests now record artifact deletion requests so pre-download failures cannot silently exercise a cleanup path. - **MAT-133 review portability**: resume metadata locks preserve read-only @@ -136,6 +148,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **MAT-136 settings drive real behavior**: `max_segments_per_download` and + `max_retries` are applied to each new download instead of hardcoded domain + defaults, `retry_delay_seconds` feeds the retry backoff live via the + settings bridge, and proxy/user-agent/connection-timeout build the HTTP + client at startup (the Network section now says changes apply at next + launch); an invalid `user_agent` in a hand-edited config falls back to the + default instead of aborting startup, and the invalid-proxy warning no + longer logs the raw proxy URL, which can embed credentials. Options with no engine wiring yet — speed limit, pre-allocate + space, DNS over HTTPS — are disabled with a "Coming soon" badge, and the + Remote Access section is replaced by a planned-feature notice so + REST/WebSocket/Web UI can never look active while no server exists. +- **MAT-136 MEGA plugin 1.1.0**: `extract_links` and `resolve_stream_url` + now refuse with an explicit "downloads are not supported yet" error instead + of handing the engine an encrypted CDN URL whose bytes downloaded + "successfully" as unreadable AES ciphertext. The resolution and crypto code + stays in the plugin library, ready to rewire once host-side decryption + ships. - **MAT-132 account status contract**: account validation responses now expose their exact typed status to the UI, rejected-validation toasts use its localized label instead of plugin diagnostics, elapsed quota/cooldown markers diff --git a/README.md b/README.md index 08149287..1a11f14e 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ src-tauri/src/ # clipboard, tray, notifications, scheduler ``` -Plugin runtime: **Extism** sandbox. Each plugin is a `.wasm` + `plugin.toml` manifest declaring capabilities. Plugins live in `~/.local/share/dev.vortex.app/plugins//` and hot-reload via filesystem watcher. +Plugin runtime: **Extism** sandbox. Each plugin is a `.wasm` + `plugin.toml` manifest declaring capabilities. Plugins live in the Tauri app data dir — `~/.local/share/dev.vortex.app/plugins//` on Linux, `~/Library/Application Support/dev.vortex.app/plugins//` on macOS, `%APPDATA%\dev.vortex.app\plugins\\` on Windows — and hot-reload via filesystem watcher. See [`ARCHI.md`](ARCHI.md) for the full architecture documentation. diff --git a/registry/registry.toml b/registry/registry.toml index 3e563a12..de2125be 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -58,7 +58,11 @@ repository = "https://github.com/mpiton/vortex-mod-soundcloud" checksum_sha256 = "e0af384e218947dc8574feb32483ad4eabff4f81682dd7d215c9d480308a5d71" checksum_sha256_toml = "d3116c6df5804d708ee16e0d7c0513e22bda0375af29f70d4f5368c561bff393" official = true -min_vortex_version = "0.1.0" +# 1.2.x already calls `run_subprocess`/`download_to_file` and relies on +# `AdaptiveStreamOnly` — all introduced in Vortex 0.2.0. The released +# v1.2.1 plugin.toml still says 0.1.0 by mistake; the registry carries +# the correct floor until the next plugin release fixes the manifest. +min_vortex_version = "0.2.0" [[plugin]] name = "vortex-mod-mediafire" @@ -98,19 +102,19 @@ min_vortex_version = "0.1.0" [[plugin]] name = "vortex-mod-mega" -description = "MEGA hoster — file + folder URL resolution with AES-128-CTR streaming decryption + chunk-MAC verification (E2E client-side)" +description = "MEGA hoster — link/folder parsing and file metadata; downloads not available yet (MEGA files need client-side AES decryption, planned)" author = "vortex-community" -version = "1.0.0" +version = "1.1.0" category = "hoster" repository = "https://github.com/mpiton/vortex-mod-mega" -checksum_sha256 = "ee4fd7716f56802e43cef2804ddf6a49346177b716afffc974867a3316290f07" -checksum_sha256_toml = "c9420281eb587afc938142c5f70104b0febb176fe68cd2ec441c275c0b4ef805" +checksum_sha256 = "ad2aceaf0eb6df6746e2ddf5d403003b6601e363fb4d68fd71116fbc0bed836f" +checksum_sha256_toml = "330d8c9540b1018c9ad1d13385dbaab0f9ab625fc055fc383d32b83e213d3392" official = true min_vortex_version = "0.1.0" [[plugin]] name = "vortex-mod-1fichier" -description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)" +description = "1fichier hoster — premium downloads via API key; free mode fails when 1fichier asks for a CAPTCHA (solver not available yet)" author = "vortex-community" version = "1.1.0" category = "hoster" diff --git a/scripts/no-manual-deps.sh b/scripts/no-manual-deps.sh index 399a12e1..7df4582e 100755 --- a/scripts/no-manual-deps.sh +++ b/scripts/no-manual-deps.sh @@ -119,6 +119,19 @@ for cargo_toml in src-tauri/Cargo.toml Cargo.toml; do if cargo_dep_change_detected "$cargo_toml"; then if ! echo "$STAGED" | grep -qF "$lock"; then + # A feature-only edit (e.g. enabling a reqwest feature whose + # code lives in an already-locked crate) can leave the lock + # byte-identical, so there is nothing to stage. The point of + # this hook is manifest/lock consistency: accept when the + # existing lock still satisfies the staged manifest. + # `cargo metadata` reads the working tree, so only trust it + # when the working-tree manifest and lock are identical to + # the staged content; otherwise fail closed. + if git diff --quiet -- "$cargo_toml" "$lock" \ + && cargo metadata --manifest-path "$cargo_toml" --locked \ + --format-version 1 >/dev/null 2>&1; then + continue + fi echo "BLOCKED: $cargo_toml dependency table modified without updated $lock." echo "" echo "Correct procedure:" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e09a389a..befecb7b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ sea-orm = { version = "1.1.20", features = ["sqlx-sqlite", "runtime-tokio-native sea-orm-migration = { version = "1.1.20", features = ["sqlx-sqlite", "runtime-tokio-native-tls"], default-features = false } tokio = { version = "1.51.0", features = ["full"] } anyhow = "1" -reqwest = { version = "0.13.2", features = ["stream", "blocking"] } +reqwest = { version = "0.13.2", features = ["stream", "blocking", "socks"] } tokio-util = { version = "0.7.18", features = ["rt"] } futures-util = "0.3.32" bytes = "1.11.1" diff --git a/src-tauri/src/adapters/driven/network/client_config.rs b/src-tauri/src/adapters/driven/network/client_config.rs new file mode 100644 index 00000000..f6535b6a --- /dev/null +++ b/src-tauri/src/adapters/driven/network/client_config.rs @@ -0,0 +1,174 @@ +//! Shared HTTP client built from the persisted network settings (MAT-136). +//! +//! Applied at startup only — changing these settings requires an app +//! restart. `dns_over_https` is not consumed yet (planned). + +use std::time::Duration; + +use tracing::warn; + +use crate::domain::model::config::AppConfig; + +/// Build the app-wide reqwest client from user network settings. +/// +/// Invalid proxy values fall back to a direct connection instead of +/// failing startup: the app must stay usable so the user can fix the +/// setting. +pub fn client_from_config(config: &AppConfig) -> Result { + let mut builder = reqwest::Client::builder() + .user_agent(effective_user_agent(config)) + .connect_timeout(Duration::from_secs(effective_connect_timeout_secs(config))); + if let Some(proxy_url) = configured_proxy_uri(config) { + match reqwest::Proxy::all(&proxy_url) { + Ok(proxy) => builder = builder.proxy(proxy), + // Never log the raw URL: proxy URIs can embed `user:password@`. + Err(err) => warn!(error = %err, "invalid proxy setting ignored"), + } + } + builder.build() +} + +fn effective_user_agent(config: &AppConfig) -> String { + let trimmed = config.user_agent.trim(); + if trimmed.is_empty() { + AppConfig::default().user_agent + } else if reqwest::header::HeaderValue::from_str(trimmed).is_err() { + warn!(user_agent = trimmed, "invalid user_agent setting ignored"); + AppConfig::default().user_agent + } else { + trimmed.to_string() + } +} + +fn effective_connect_timeout_secs(config: &AppConfig) -> u64 { + if config.connection_timeout_seconds == 0 { + u64::from(AppConfig::default().connection_timeout_seconds) + } else { + u64::from(config.connection_timeout_seconds) + } +} + +/// Proxy URI to use, or `None` for a direct connection. Prepends the +/// selected proxy type as scheme when the user typed a bare `host:port`. +fn configured_proxy_uri(config: &AppConfig) -> Option { + if config.proxy_type == "none" { + return None; + } + let url = config.proxy_url.as_deref().map(str::trim)?; + if url.is_empty() { + return None; + } + if url.contains("://") { + Some(url.to_string()) + } else { + Some(format!("{}://{}", config.proxy_type, url)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_client_from_config_sends_configured_user_agent() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/probe")) + .and(header("user-agent", "TestAgent/9")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + let config = AppConfig { + user_agent: "TestAgent/9".to_string(), + ..AppConfig::default() + }; + let client = client_from_config(&config).expect("client builds"); + let response = client + .get(format!("{}/probe", mock_server.uri())) + .send() + .await + .expect("request succeeds"); + + assert_eq!(response.status(), 200); + } + + #[test] + fn test_client_from_config_with_invalid_proxy_url_falls_back_to_direct() { + let config = AppConfig { + proxy_type: "http".to_string(), + proxy_url: Some("::not a proxy url::".to_string()), + ..AppConfig::default() + }; + + assert!(client_from_config(&config).is_ok()); + } + + #[test] + fn test_client_from_config_with_control_chars_in_user_agent_still_builds() { + // A hand-edited config.toml can hold a value that is not a valid + // HTTP header; startup must not fail on it. + let config = AppConfig { + user_agent: "Bad\nAgent".to_string(), + ..AppConfig::default() + }; + + assert!(client_from_config(&config).is_ok()); + } + + #[test] + fn test_effective_user_agent_with_blank_value_uses_default() { + let config = AppConfig { + user_agent: " ".to_string(), + ..AppConfig::default() + }; + + assert_eq!(effective_user_agent(&config), "Vortex/1.0"); + } + + #[test] + fn test_effective_connect_timeout_with_zero_uses_default() { + let config = AppConfig { + connection_timeout_seconds: 0, + ..AppConfig::default() + }; + + assert_eq!(effective_connect_timeout_secs(&config), 30); + } + + #[test] + fn test_configured_proxy_uri_with_none_type_returns_none() { + assert_eq!(configured_proxy_uri(&AppConfig::default()), None); + } + + #[test] + fn test_configured_proxy_uri_with_bare_host_prepends_scheme() { + let config = AppConfig { + proxy_type: "socks5".to_string(), + proxy_url: Some("127.0.0.1:1080".to_string()), + ..AppConfig::default() + }; + + assert_eq!( + configured_proxy_uri(&config), + Some("socks5://127.0.0.1:1080".to_string()) + ); + } + + #[test] + fn test_configured_proxy_uri_with_explicit_scheme_keeps_url() { + let config = AppConfig { + proxy_type: "http".to_string(), + proxy_url: Some("http://proxy.local:3128".to_string()), + ..AppConfig::default() + }; + + assert_eq!( + configured_proxy_uri(&config), + Some("http://proxy.local:3128".to_string()) + ); + } +} diff --git a/src-tauri/src/adapters/driven/network/mod.rs b/src-tauri/src/adapters/driven/network/mod.rs index b87104bc..3b10979c 100644 --- a/src-tauri/src/adapters/driven/network/mod.rs +++ b/src-tauri/src/adapters/driven/network/mod.rs @@ -1,4 +1,5 @@ mod checksum; +mod client_config; mod download_artifact_lifecycle; mod download_engine; mod download_source_preparation; @@ -10,6 +11,7 @@ mod segment_worker; mod wait_manager; pub use checksum::StreamingChecksumComputer; +pub use client_config::client_from_config; pub use download_engine::SegmentedDownloadEngine; pub(crate) use protected_source::{ BodyPrefixDecision, SourcePolicy, allows_html_filename, safe_source_failure, diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index e0505dd7..fcb61a6b 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -60,12 +60,12 @@ impl CommandBus { } }; + let settings = self.config_store().get_config().unwrap_or_default(); let dest_dir = cmd.destination.unwrap_or_else(|| { // Prefer user-configured download dir; fall back to ~/Downloads/ - self.config_store() - .get_config() - .ok() - .and_then(|c| c.download_dir) + settings + .download_dir + .clone() .map(PathBuf::from) .or_else(dirs::download_dir) .unwrap_or_else(|| PathBuf::from(".")) @@ -93,7 +93,9 @@ impl CommandBus { let mut download = Download::new(id, url, file_name, dest.to_string_lossy().to_string()) .with_queue_position(queue_position) - .with_remote_metadata(cmd.size_bytes, cmd.resume_supported); + .with_remote_metadata(cmd.size_bytes, cmd.resume_supported) + .with_segments_count(settings.max_segments_per_download) + .with_max_retries(settings.max_retries); if let Some(hostname) = cmd.source_hostname_override { download = download.with_source_hostname(hostname); diff --git a/src-tauri/src/application/commands/start_download_tests.rs b/src-tauri/src/application/commands/start_download_tests.rs index 22131688..fba1041d 100644 --- a/src-tauri/src/application/commands/start_download_tests.rs +++ b/src-tauri/src/application/commands/start_download_tests.rs @@ -148,6 +148,31 @@ async fn test_start_download_persists_and_emits_event() { assert_eq!(events[0], DomainEvent::DownloadCreated { id }); } +#[tokio::test] +async fn test_start_download_applies_configured_segments_and_retries() { + let (bus, repo, _event_bus) = build_download_bus(Arc::new(MockHttpClient::failing())); + + let id = bus + .handle_start_download(StartDownloadCommand { + url: "https://example.com/files/data.bin".to_string(), + destination: Some(PathBuf::from("/tmp/downloads")), + filename: None, + size_bytes: None, + resume_supported: None, + source_hostname_override: None, + module_name: None, + account_id: None, + }) + .await + .unwrap(); + + // StubConfigStore serves AppConfig::default(): 8 segments, 5 retries + // (MAT-136 R-01: settings drive new downloads, not domain defaults). + let dl = repo.find_by_id(id).unwrap().unwrap(); + assert_eq!(dl.segments_count(), 8); + assert_eq!(dl.max_retries(), 5); +} + #[tokio::test] async fn test_start_download_persists_validated_account_association() { let (bus, repo, _) = build_download_bus(Arc::new(MockHttpClient::failing())); diff --git a/src-tauri/src/application/services/queue_config_bridge.rs b/src-tauri/src/application/services/queue_config_bridge.rs index ff9e399f..97b501aa 100644 --- a/src-tauri/src/application/services/queue_config_bridge.rs +++ b/src-tauri/src/application/services/queue_config_bridge.rs @@ -1,8 +1,8 @@ -//! Bridges `SettingsUpdated` events to [`QueueManager::set_max_concurrent`]. +//! Bridges `SettingsUpdated` events to the running [`QueueManager`]. //! -//! Reads `max_concurrent_downloads` from [`ConfigStore`] on every -//! `SettingsUpdated` event and propagates the new limit to the queue -//! manager so the running scheduler reflects UI changes without restart. +//! Reads `max_concurrent_downloads` and `retry_delay_seconds` from +//! [`ConfigStore`] on every `SettingsUpdated` event and propagates them +//! so the running scheduler reflects UI changes without restart. use std::sync::Arc; @@ -30,6 +30,7 @@ pub fn subscribe_queue_to_config( Ok(config) => { queue_manager .set_max_concurrent(normalize_max_concurrent(config.max_concurrent_downloads)); + queue_manager.set_retry_base_delay(config.retry_delay_seconds); } Err(err) => { tracing::error!(%err, "queue_config_bridge: failed to read config"); @@ -157,6 +158,27 @@ mod tests { assert_eq!(qm.max_concurrent(), 10); } + #[tokio::test] + async fn test_settings_updated_propagates_retry_base_delay() { + let config_store: Arc = Arc::new(StubConfigStore { + config: Mutex::new(AppConfig::default()), + }); + let bus = SyncEventBus::new(); + let qm = make_manager(4); + + subscribe_queue_to_config(&bus, Arc::clone(&config_store), Arc::clone(&qm)); + + config_store + .update_config(ConfigPatch { + retry_delay_seconds: Some(30), + ..Default::default() + }) + .unwrap(); + bus.publish(DomainEvent::SettingsUpdated); + + assert_eq!(qm.retry_base_delay_secs(), 30); + } + #[tokio::test] async fn test_non_settings_events_are_ignored() { let cfg = AppConfig::default(); diff --git a/src-tauri/src/application/services/queue_manager.rs b/src-tauri/src/application/services/queue_manager.rs index 6b0430c6..ab4c7c13 100644 --- a/src-tauri/src/application/services/queue_manager.rs +++ b/src-tauri/src/application/services/queue_manager.rs @@ -4,7 +4,7 @@ //! whenever a slot becomes available. use std::collections::HashMap; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -26,6 +26,9 @@ pub struct QueueManager { engine: Arc, event_bus: Arc, max_concurrent: Arc, + /// Base delay (seconds) for the exponential retry backoff, from the + /// `retry_delay_seconds` setting (MAT-136 R-01). + retry_base_delay_secs: Arc, active_count: Arc, schedule_lock: Arc>, retry_cancellations: Arc>>, @@ -89,6 +92,7 @@ impl QueueManager { engine, event_bus, max_concurrent: Arc::new(AtomicUsize::new(max_concurrent)), + retry_base_delay_secs: Arc::new(AtomicU64::new(DEFAULT_RETRY_BASE_DELAY_SECS)), active_count: Arc::new(AtomicUsize::new(0)), schedule_lock: Arc::new(tokio::sync::Mutex::new(())), retry_cancellations: Arc::new(Mutex::new(HashMap::new())), @@ -129,6 +133,15 @@ impl QueueManager { } // F3+F4: takes &Arc so we clone the real Arc into the spawned task + pub fn set_retry_base_delay(&self, secs: u32) { + self.retry_base_delay_secs + .store(u64::from(secs), Ordering::SeqCst); + } + + pub fn retry_base_delay_secs(&self) -> u64 { + self.retry_base_delay_secs.load(Ordering::SeqCst) + } + pub fn set_max_concurrent(self: &Arc, n: usize) { self.max_concurrent.store(n, Ordering::SeqCst); let this = Arc::clone(self); @@ -469,7 +482,7 @@ impl QueueManager { } let this = Arc::clone(self); - let delay = retry_delay(attempt); + let delay = retry_delay(self.retry_base_delay_secs.load(Ordering::SeqCst), attempt); tokio::spawn(async move { tokio::select! { @@ -557,12 +570,20 @@ impl QueueManager { } } +const DEFAULT_RETRY_BASE_DELAY_SECS: u64 = 10; + // F9: pub(crate) visibility -pub(crate) fn retry_delay(attempt: u32) -> Duration { - // Clamp exponent to 5 so the intermediate 2^exp never overflows u64. - // 10 * 2^5 = 320, capped to 300 by the min() below. +pub(crate) fn retry_delay(base_secs: u64, attempt: u32) -> Duration { + // A zero base (hand-edited config) would retry in a hot loop. + let base_secs = if base_secs == 0 { + DEFAULT_RETRY_BASE_DELAY_SECS + } else { + base_secs + }; + // Clamp exponent to 5 so base * 2^exp stays far below u64::MAX + // (u32 config base * 32 max); the min() below caps the result anyway. let exp = attempt.saturating_sub(1).min(5); - let delay = Duration::from_secs(10 * (1u64 << exp)); + let delay = Duration::from_secs(base_secs * (1u64 << exp)); delay.min(Duration::from_secs(300)) } @@ -876,17 +897,29 @@ mod tests { #[test] fn test_retry_delay_exponential() { - assert_eq!(retry_delay(1), Duration::from_secs(10)); - assert_eq!(retry_delay(2), Duration::from_secs(20)); - assert_eq!(retry_delay(3), Duration::from_secs(40)); - assert_eq!(retry_delay(4), Duration::from_secs(80)); - assert_eq!(retry_delay(5), Duration::from_secs(160)); + assert_eq!(retry_delay(10, 1), Duration::from_secs(10)); + assert_eq!(retry_delay(10, 2), Duration::from_secs(20)); + assert_eq!(retry_delay(10, 3), Duration::from_secs(40)); + assert_eq!(retry_delay(10, 4), Duration::from_secs(80)); + assert_eq!(retry_delay(10, 5), Duration::from_secs(160)); } #[test] fn test_retry_delay_capped_at_300s() { - assert_eq!(retry_delay(6), Duration::from_secs(300)); - assert_eq!(retry_delay(10), Duration::from_secs(300)); + assert_eq!(retry_delay(10, 6), Duration::from_secs(300)); + assert_eq!(retry_delay(10, 10), Duration::from_secs(300)); + } + + #[test] + fn test_retry_delay_with_configured_base_scales_from_setting() { + assert_eq!(retry_delay(5, 1), Duration::from_secs(5)); + assert_eq!(retry_delay(5, 2), Duration::from_secs(10)); + assert_eq!(retry_delay(120, 3), Duration::from_secs(300)); + } + + #[test] + fn test_retry_delay_with_zero_base_falls_back_to_default() { + assert_eq!(retry_delay(0, 1), Duration::from_secs(10)); } #[tokio::test] diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index cecbe4b1..f5795951 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -26,11 +26,15 @@ pub struct AppConfig { // ── Downloads ──────────────────────────────────────────────────── pub max_concurrent_downloads: u32, pub max_segments_per_download: u32, - /// `None` means unlimited. + /// `None` means unlimited. Planned: not yet consumed by the download + /// engine — no throttling happens regardless of the value. pub speed_limit_bytes_per_sec: Option, pub max_retries: u32, pub retry_delay_seconds: u32, pub verify_checksums: bool, + /// Planned: the segmented engine always pre-sizes the file because + /// segment workers write at arbitrary offsets; disabling is not + /// supported yet and the value is not consumed. pub pre_allocate_space: bool, /// Enable runtime re-split of slow segments when a faster segment /// finishes. PRD §7.1 (répartition dynamique). @@ -57,6 +61,8 @@ pub struct AppConfig { pub proxy_type: String, pub proxy_url: Option, pub user_agent: String, + /// Planned: not yet consumed — the HTTP client uses the system + /// resolver regardless of the value. pub dns_over_https: bool, pub connection_timeout_seconds: u32, @@ -140,9 +146,9 @@ impl Default for AppConfig { // Remote Access web_interface_enabled: false, web_interface_port: 9876, - rest_api_enabled: true, + rest_api_enabled: false, api_key: String::new(), - websocket_enabled: true, + websocket_enabled: false, // Browser Integration min_file_size_mb: 1.0, @@ -410,7 +416,7 @@ pub fn apply_patch(config: &mut AppConfig, patch: &ConfigPatch) { // Link Grabber if let Some(v) = patch.link_check_parallelism { - // Clamp at write-time so `~/.config/vortex/config.toml` always + // Clamp at write-time so the persisted `config.toml` always // round-trips a canonical value, matching the bounds enforced // at probe-time by `normalize_link_check_parallelism`. config.link_check_parallelism = @@ -450,15 +456,16 @@ mod tests { // Browser integration assert_eq!(config.min_file_size_mb, 1.0); - // Remote access — protocols enabled by PRD, but the gatekeeper - // (`web_interface_enabled`) stays off and `api_key` empty in the - // domain. The adapter layer is responsible for hydrating a generated - // key on first launch; we lock the bare-domain defaults here so a - // future change cannot accidentally expose the server with no auth. + // Remote access — no REST/WS server exists yet (MAT-136), so every + // remote-access preference defaults to OFF and `api_key` stays empty + // in the domain. The adapter layer is responsible for hydrating a + // generated key on first launch; we lock the bare-domain defaults + // here so a future change cannot accidentally expose the server + // with no auth. assert!(!config.web_interface_enabled); assert_eq!(config.web_interface_port, 9876); - assert!(config.rest_api_enabled); - assert!(config.websocket_enabled); + assert!(!config.rest_api_enabled); + assert!(!config.websocket_enabled); assert!(config.api_key.is_empty()); } diff --git a/src-tauri/src/domain/ports/driven/config_store.rs b/src-tauri/src/domain/ports/driven/config_store.rs index 36367e54..20bfd714 100644 --- a/src-tauri/src/domain/ports/driven/config_store.rs +++ b/src-tauri/src/domain/ports/driven/config_store.rs @@ -8,7 +8,8 @@ use crate::domain::model::config::{AppConfig, ConfigPatch}; /// Reads and updates application configuration. /// -/// The adapter loads from `~/.config/vortex/config.toml`, +/// The adapter loads from `config.toml` in the Tauri app data dir +/// (Linux: `~/.local/share/dev.vortex.app/config.toml`), /// applies patches, and writes back atomically. pub trait ConfigStore: Send + Sync { /// Get the current application configuration. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c9195436..c7965164 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,18 +135,21 @@ pub fn run() { // ── Driven adapters ───────────────────────────────────── let event_bus: Arc = Arc::new(TokioEventBus::new(256)); let file_storage: Arc = Arc::new(FsFileStorage::new()); - let reqwest_client = reqwest::Client::builder() - .user_agent("Vortex/0.1") - .connect_timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; - let http_client: Arc = - Arc::new(ReqwestHttpClient::with_client(reqwest_client.clone())); let config_store: Arc = Arc::new(TomlConfigStore::new( config_path, resolve_system_download_dir(), Some(uuid::Uuid::new_v4().to_string()), )); + // Network settings (user-agent, timeout, proxy) are applied to + // the shared client at startup; changes require a restart. + let startup_config = config_store + .get_config() + .unwrap_or_else(|_| crate::domain::model::config::AppConfig::default()); + let reqwest_client = + adapters::driven::network::client_from_config(&startup_config) + .map_err(|e| e.to_string())?; + let http_client: Arc = + Arc::new(ReqwestHttpClient::with_client(reqwest_client.clone())); let credential_store: Arc = Arc::new(KeyringCredentialStore); let account_credential_store: Arc = Arc::new(KeyringAccountStore); @@ -276,20 +279,17 @@ pub fn run() { download_source_handler.clone(); // ── Download engine ───────────────────────────────────── - let initial_engine_config = config_store - .get_config() - .unwrap_or_else(|_| crate::domain::model::config::AppConfig::default()); let segmented_engine = Arc::new( SegmentedDownloadEngine::new( reqwest_client, file_storage.clone(), event_bus.clone(), - 4, + startup_config.max_segments_per_download, ) .with_source_resolver(download_source_resolver) .with_dynamic_split( - initial_engine_config.dynamic_split_enabled, - initial_engine_config.dynamic_split_min_remaining_mb, + startup_config.dynamic_split_enabled, + startup_config.dynamic_split_min_remaining_mb, ), ); // Keep settings → engine bridge alive so UI changes to @@ -342,6 +342,7 @@ pub fn run() { ) .with_checksum_pipeline(config_store.clone(), checksum_computer_for_queue), ); + queue_manager.set_retry_base_delay(startup_config.retry_delay_seconds); // Propagate future settings updates (UI → command bus) to the // running queue manager without requiring a restart. diff --git a/src-tauri/tests/registry_coherence.rs b/src-tauri/tests/registry_coherence.rs new file mode 100644 index 00000000..b2e8cc5c --- /dev/null +++ b/src-tauri/tests/registry_coherence.rs @@ -0,0 +1,132 @@ +//! MAT-136 R-05: the plugin registry must stay coherent — parseable TOML, +//! complete entries, well-formed versions and checksums, unique names. +//! This test IS the automated validation the ticket asks for; it runs in CI +//! with `cargo test --workspace`. + +use std::collections::HashSet; +use std::path::Path; + +const CATEGORIES: &[&str] = &[ + "crawler", + "hoster", + "debrid", + "container", + "captcha", + "extractor", + "notifier", + "utility", +]; + +fn load_registry() -> toml::Table { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../registry/registry.toml"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + raw.parse::() + .unwrap_or_else(|e| panic!("registry.toml is not valid TOML: {e}")) +} + +fn plugins(registry: &toml::Table) -> &[toml::Value] { + registry + .get("plugin") + .and_then(|p| p.as_array()) + .map(Vec::as_slice) + .expect("registry.toml must contain [[plugin]] entries") +} + +fn str_field<'a>(plugin: &'a toml::Value, name: &str, field: &str) -> &'a str { + let value = plugin + .get(field) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("{name}: missing string field `{field}`")); + assert!(!value.trim().is_empty(), "{name}: field `{field}` is empty"); + value +} + +fn is_semver_triple(s: &str) -> bool { + let parts: Vec<&str> = s.split('.').collect(); + parts.len() == 3 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars().all(|c| c.is_ascii_digit()) + && (p.len() == 1 || !p.starts_with('0')) + }) +} + +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 + && s.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +#[test] +fn test_registry_entries_are_complete_and_well_formed() { + let registry = load_registry(); + let plugins = plugins(®istry); + assert!(!plugins.is_empty(), "registry has no plugins"); + + for plugin in plugins { + let name = plugin + .get("name") + .and_then(|v| v.as_str()) + .expect("every [[plugin]] entry needs a `name`") + .to_string(); + + for field in ["description", "author", "repository"] { + str_field(plugin, &name, field); + } + + let version = str_field(plugin, &name, "version"); + assert!( + is_semver_triple(version), + "{name}: version `{version}` is not MAJOR.MINOR.PATCH" + ); + // `min_vortex_version` is optional (see registry/TEMPLATE.toml and + // `Option` in the store client); validate only when present. + if let Some(value) = plugin.get("min_vortex_version") { + let min_vortex = value + .as_str() + .unwrap_or_else(|| panic!("{name}: `min_vortex_version` must be a string")); + assert!( + is_semver_triple(min_vortex), + "{name}: min_vortex_version `{min_vortex}` is not MAJOR.MINOR.PATCH" + ); + } + + let category = str_field(plugin, &name, "category"); + assert!( + CATEGORIES.contains(&category), + "{name}: unknown category `{category}` (expected one of {CATEGORIES:?})" + ); + + for field in ["checksum_sha256", "checksum_sha256_toml"] { + let checksum = str_field(plugin, &name, field); + assert!( + is_sha256_hex(checksum), + "{name}: `{field}` is not 64 lowercase hex chars: `{checksum}`" + ); + } + } +} + +#[test] +fn test_is_semver_triple_with_leading_zero_rejects() { + assert!(!is_semver_triple("01.2.3")); + assert!(!is_semver_triple("1.02.3")); + assert!(is_semver_triple("0.2.10")); +} + +#[test] +fn test_registry_plugin_names_are_unique() { + let registry = load_registry(); + let mut seen = HashSet::new(); + for plugin in plugins(®istry) { + let name = plugin + .get("name") + .and_then(|v| v.as_str()) + .expect("every [[plugin]] entry needs a `name`"); + assert!( + seen.insert(name.to_string()), + "duplicate plugin name: {name}" + ); + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 29fa665a..b3a25281 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -81,24 +81,13 @@ "userAgent": "User agent", "dnsOverHttps": "DNS over HTTPS", "dnsOverHttpsDesc": "Use encrypted DNS queries", - "connectionTimeout": "Connection timeout (seconds)" + "connectionTimeout": "Connection timeout (seconds)", + "restartHint": "Proxy, user agent and timeout changes take effect at next launch." }, "remote": { "title": "Remote Access", "description": "Web interface and API configuration", - "warning": "Enabling remote access exposes your download manager to the network. Make sure to use a strong API key and restrict access to trusted networks.", - "webInterface": "Web interface", - "webInterfaceDesc": "Enable the browser-based control panel", - "webInterfacePort": "Web interface port", - "restApi": "REST API", - "restApiDesc": "Enable the HTTP REST API for third-party integrations", - "websocket": "WebSocket", - "websocketDesc": "Enable real-time WebSocket events", - "apiKey": "API Key", - "showApiKey": "Show API key", - "hideApiKey": "Hide API key", - "copyApiKey": "Copy API key", - "regenerateApiKey": "Regenerate API key" + "planned": "Remote access (web interface, REST API, WebSocket) is planned for a future release. No server runs in this version, so these options are not available yet." }, "browser": { "title": "Browser Integration", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bfc3b51a..8d37a5a7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -81,24 +81,13 @@ "userAgent": "Agent utilisateur", "dnsOverHttps": "DNS sur HTTPS", "dnsOverHttpsDesc": "Utiliser des requêtes DNS chiffrées", - "connectionTimeout": "Délai de connexion (secondes)" + "connectionTimeout": "Délai de connexion (secondes)", + "restartHint": "Les changements de proxy, user agent et délai de connexion prennent effet au prochain démarrage." }, "remote": { "title": "Accès distant", "description": "Configuration de l'interface web et de l'API", - "warning": "L'activation de l'accès distant expose votre gestionnaire de téléchargements au réseau. Utilisez une clé API robuste et limitez l'accès aux réseaux de confiance.", - "webInterface": "Interface web", - "webInterfaceDesc": "Activer le panneau de contrôle basé sur navigateur", - "webInterfacePort": "Port de l'interface web", - "restApi": "API REST", - "restApiDesc": "Activer l'API REST HTTP pour les intégrations tierces", - "websocket": "WebSocket", - "websocketDesc": "Activer les événements WebSocket en temps réel", - "apiKey": "Clé API", - "showApiKey": "Afficher la clé API", - "hideApiKey": "Masquer la clé API", - "copyApiKey": "Copier la clé API", - "regenerateApiKey": "Régénérer la clé API" + "planned": "L'accès distant (interface web, API REST, WebSocket) est prévu pour une version future. Aucun serveur ne tourne dans cette version, ces options ne sont donc pas encore disponibles." }, "browser": { "title": "Intégration navigateur", diff --git a/src/views/SettingsView/DownloadsSection.tsx b/src/views/SettingsView/DownloadsSection.tsx index 779cc0bb..1f53cd45 100644 --- a/src/views/SettingsView/DownloadsSection.tsx +++ b/src/views/SettingsView/DownloadsSection.tsx @@ -46,6 +46,7 @@ export function DownloadsSection({ config }: DownloadsSectionProps) { min={1} max={32} /> + {/* MAT-136 R-02: throttling is not consumed by the engine yet. */} handleChange("verifyChecksums", v)} /> + {/* MAT-136 R-02: the segmented engine always pre-sizes the file. */} handleChange("preAllocateSpace", v)} + disabled + badge={t("common.comingSoon")} /> diff --git a/src/views/SettingsView/NetworkSection.tsx b/src/views/SettingsView/NetworkSection.tsx index 9eccf1a7..a61ac91e 100644 --- a/src/views/SettingsView/NetworkSection.tsx +++ b/src/views/SettingsView/NetworkSection.tsx @@ -45,6 +45,8 @@ export function NetworkSection({ config }: NetworkSectionProps) {

{t("settings.network.title")}

{t("settings.network.description")}

+ {/* MAT-136 R-02: proxy/UA/timeout feed the client built at startup. */} +

{t("settings.network.restartHint")}

@@ -88,11 +90,14 @@ export function NetworkSection({ config }: NetworkSectionProps) { />
+ {/* MAT-136 R-02: DoH is not consumed by the HTTP client yet. */} handleChange("dnsOverHttps", v)} + disabled + badge={t("common.comingSoon")} /> ("settings_update", { - invalidateKeys: [["settings_get"]], - onSuccess: () => { - toast.success(t("settings.toast.updateSuccess")); - }, - }); - - const handleChange = (key: K, value: AppConfig[K]) => { - mutate({ patch: { [key]: value } as AppConfigPatch }); - }; - - const maskedKey = "\u2022".repeat(32); return (
@@ -37,86 +15,12 @@ export function RemoteAccessSection({ config }: RemoteAccessSectionProps) {

{t("settings.remote.description")}

- + - -

- {t("settings.remote.warning")} -

+ +

{t("settings.remote.planned")}

- -
- handleChange("webInterfaceEnabled", v)} - /> - - {config.webInterfaceEnabled && ( - handleChange("webInterfacePort", v)} - min={1024} - max={65535} - /> - )} - - handleChange("restApiEnabled", v)} - /> - - handleChange("websocketEnabled", v)} - /> -
- - {config.restApiEnabled && ( -
-

{t("settings.remote.apiKey")}

-
- - - - -
-
- )} ); } diff --git a/src/views/SettingsView/SettingField.tsx b/src/views/SettingsView/SettingField.tsx index ad7796a2..8f99fea8 100644 --- a/src/views/SettingsView/SettingField.tsx +++ b/src/views/SettingsView/SettingField.tsx @@ -1,12 +1,15 @@ import { useState, useId, useEffect } from "react"; import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; interface SettingToggleProps { label: string; description?: string; checked: boolean; onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + badge?: string; } export function SettingToggle({ @@ -14,17 +17,22 @@ export function SettingToggle({ description, checked, onCheckedChange, + disabled, + badge, }: SettingToggleProps) { const id = useId(); return (
- +
+ + {badge && {badge}} +
{description &&

{description}

}
- +
); } @@ -37,6 +45,8 @@ interface SettingNumberInputProps { min?: number; max?: number; step?: number; + disabled?: boolean; + badge?: string; } export function SettingNumberInput({ @@ -47,6 +57,8 @@ export function SettingNumberInput({ min, max, step, + disabled, + badge, }: SettingNumberInputProps) { const id = useId(); const [localValue, setLocalValue] = useState(String(value)); @@ -73,9 +85,12 @@ export function SettingNumberInput({ return (
- +
+ + {badge && {badge}} +
{description &&

{description}

}
setLocalValue(e.target.value)} onBlur={commit} onKeyDown={(e) => { diff --git a/src/views/SettingsView/SettingsView.tsx b/src/views/SettingsView/SettingsView.tsx index e2cd7a2b..3775df85 100644 --- a/src/views/SettingsView/SettingsView.tsx +++ b/src/views/SettingsView/SettingsView.tsx @@ -43,7 +43,7 @@ function SectionContent({ tab, config }: { tab: SettingTab; config: AppConfig }) case "network": return ; case "remote": - return ; + return ; case "browser": return ; case "appearance": diff --git a/src/views/SettingsView/__tests__/Sections.test.tsx b/src/views/SettingsView/__tests__/Sections.test.tsx index c73577b4..0c89c11d 100644 --- a/src/views/SettingsView/__tests__/Sections.test.tsx +++ b/src/views/SettingsView/__tests__/Sections.test.tsx @@ -241,10 +241,25 @@ describe("DownloadsSection", () => { it("should cap maxConcurrentDownloads input at 20 per PRD §6.10", () => { renderWithQuery(); - const label = screen.getByText("Max concurrent downloads"); - const input = label.closest("div")?.parentElement?.querySelector("input"); - expect(input).not.toBeNull(); - expect(input?.max).toBe("20"); + const input = screen.getByLabelText("Max concurrent downloads"); + expect(input.max).toBe("20"); + }); + + // MAT-136 R-02: throttling and pre-allocation are not consumed by the + // engine yet — the controls must be non-interactive and marked planned. + it("should disable speed limit input when throttling is not implemented", () => { + renderWithQuery(); + expect(screen.getByLabelText("Speed limit (MiB/s)")).toBeDisabled(); + }); + + it("should disable pre-allocate toggle when setting is not consumed", () => { + renderWithQuery(); + expect(screen.getByRole("switch", { name: "Pre-allocate space" })).toBeDisabled(); + }); + + it("should mark planned download settings with coming soon badge", () => { + renderWithQuery(); + expect(screen.getAllByText("Coming soon")).toHaveLength(2); }); }); @@ -268,44 +283,32 @@ describe("NetworkSection", () => { renderWithQuery(); expect(screen.getByText("DNS over HTTPS")).toBeInTheDocument(); }); -}); -describe("RemoteAccessSection", () => { - it("should render security warning", () => { - renderWithQuery(); - expect(screen.getByText(/remote access exposes/i)).toBeInTheDocument(); + // MAT-136 R-02: DoH is not consumed by the HTTP client yet. + it("should disable DNS over HTTPS toggle when setting is not consumed", () => { + renderWithQuery(); + expect(screen.getByRole("switch", { name: "DNS over HTTPS" })).toBeDisabled(); }); - it("should not show port input when web interface is disabled", () => { - renderWithQuery(); - expect(screen.queryByText("Web interface port")).not.toBeInTheDocument(); + it("should show restart hint when network settings apply at launch", () => { + renderWithQuery(); + expect(screen.getByText(/next launch/i)).toBeInTheDocument(); }); +}); - it("should show port input when web interface is enabled", () => { - renderWithQuery(); - expect(screen.getByText("Web interface port")).toBeInTheDocument(); +describe("RemoteAccessSection", () => { + // MAT-136 R-03: no server exists yet, the section must never look active. + it("should render planned notice when section is shown", () => { + renderWithQuery(); + expect(screen.getByText(/planned for a future release/i)).toBeInTheDocument(); }); - it("should not show API key when REST API is disabled", () => { - renderWithQuery(); + it("should not render interactive controls when no server exists", () => { + renderWithQuery(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); expect(screen.queryByText("API Key")).not.toBeInTheDocument(); }); - - it("should show masked API key when REST API is enabled", () => { - renderWithQuery(); - expect(screen.getByText("API Key")).toBeInTheDocument(); - expect(screen.getByLabelText("Show API key")).toBeInTheDocument(); - }); - - it("should reveal API key when show button clicked", async () => { - const user = userEvent.setup(); - renderWithQuery(); - - await user.click(screen.getByLabelText("Show API key")); - - expect(screen.getByDisplayValue("test-api-key-abc-123")).toBeInTheDocument(); - expect(screen.getByLabelText("Hide API key")).toBeInTheDocument(); - }); }); describe("BrowserSection", () => {