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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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/<name>/` on Linux, `~/Library/Application Support/dev.vortex.app/plugins/<name>/` on macOS, `%APPDATA%\dev.vortex.app\plugins\<name>\` on Windows — and hot-reload via filesystem watcher.

See [`ARCHI.md`](ARCHI.md) for the full architecture documentation.

Expand Down
16 changes: 10 additions & 6 deletions registry/registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions scripts/no-manual-deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
echo "BLOCKED: $cargo_toml dependency table modified without updated $lock."
echo ""
echo "Correct procedure:"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
174 changes: 174 additions & 0 deletions src-tauri/src/adapters/driven/network/client_config.rs
Original file line number Diff line number Diff line change
@@ -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<reqwest::Client, reqwest::Error> {
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"),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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<String> {
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())
);
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/adapters/driven/network/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod checksum;
mod client_config;
mod download_artifact_lifecycle;
mod download_engine;
mod download_source_preparation;
Expand All @@ -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,
Expand Down
12 changes: 7 additions & 5 deletions src-tauri/src/application/commands/start_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("."))
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/src/application/commands/start_download_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Loading
Loading