Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- CAPTCHA pipeline: persistent manual challenge queue, solve/skip/retry
actions, timeout handling, automatic download resumption, compact image
transport, bounded plugin inputs, and redacted challenge history (MAT-140).

### Security

- `cargo audit` config now ignores RUSTSEC-2026-0194/0195 (quick-xml 0.39.4
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pbkdf2 = "0.13.0"
hmac = "0.13.0"
rand = "0.10.1"
url = "2.5.8"
base64 = "0.22.1"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
30 changes: 29 additions & 1 deletion src-tauri/src/adapters/driven/config/toml_config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use std::sync::Mutex;
use crate::domain::error::DomainError;
use crate::domain::model::account::AccountSelectionStrategy;
use crate::domain::model::config::{
AppConfig, ConfigPatch, apply_patch, normalize_history_retention_days,
AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, apply_patch,
normalize_history_retention_days,
};
use crate::domain::ports::driven::ConfigStore;

Expand Down Expand Up @@ -162,6 +163,9 @@ struct ConfigDto {
dynamic_split_enabled: bool,
dynamic_split_min_remaining_mb: u64,

// CAPTCHA
captcha_timeout_seconds: u32,

// History
history_retention_days: i64,

Expand Down Expand Up @@ -225,6 +229,7 @@ impl From<AppConfig> for ConfigDto {
pre_allocate_space: c.pre_allocate_space,
dynamic_split_enabled: c.dynamic_split_enabled,
dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb,
captcha_timeout_seconds: c.captcha_timeout_seconds,
history_retention_days: c.history_retention_days,
account_selection_strategy: c.account_selection_strategy.to_string(),
proxy_type: c.proxy_type,
Expand Down Expand Up @@ -283,6 +288,9 @@ impl TryFrom<ConfigDto> for AppConfig {
pre_allocate_space: d.pre_allocate_space,
dynamic_split_enabled: d.dynamic_split_enabled,
dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb,
captcha_timeout_seconds: d
.captcha_timeout_seconds
.clamp(MIN_CAPTCHA_TIMEOUT_SECONDS, MAX_CAPTCHA_TIMEOUT_SECONDS),
history_retention_days: normalize_history_retention_days(d.history_retention_days),
account_selection_strategy,
proxy_type: d.proxy_type,
Expand Down Expand Up @@ -311,6 +319,7 @@ impl TryFrom<ConfigDto> for AppConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::config::{MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS};

/// Non-empty bootstrap key used by tests that don't assert on `api_key`
/// but still exercise a fresh-config code path, which now requires one.
Expand Down Expand Up @@ -554,6 +563,25 @@ mod tests {
assert_eq!(config.history_retention_days, 0);
}

#[test]
fn test_loading_config_clamps_hand_edited_captcha_timeout() {
for (persisted, expected) in [
(1, MIN_CAPTCHA_TIMEOUT_SECONDS),
(u32::MAX, MAX_CAPTCHA_TIMEOUT_SECONDS),
] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, format!("captcha_timeout_seconds = {persisted}\n")).unwrap();

let store = TomlConfigStore::new(path, None, None);

assert_eq!(
store.get_config().unwrap().captcha_timeout_seconds,
expected
);
}
}

#[test]
fn test_history_retention_days_is_persisted_and_reloaded() {
// Round-trips the new history retention preference through
Expand Down
60 changes: 59 additions & 1 deletion src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ pub fn spawn_tauri_event_bridge(app_handle: AppHandle, event_bus: &dyn EventBus)
/// notifications per download and the first refetch runs against the
/// pre-persist state (the race this flow exists to fix).
fn should_forward_to_frontend(event: &DomainEvent) -> bool {
!matches!(event, DomainEvent::DownloadCompleted { .. })
!matches!(
event,
DomainEvent::DownloadCompleted { .. } | DomainEvent::CaptchaRequired { .. }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
)
}

fn event_name(event: &DomainEvent) -> &'static str {
match event {
DomainEvent::DownloadCreated { .. } => "download-created",
DomainEvent::DownloadQueued { .. } => "download-queued",
DomainEvent::DownloadStarted { .. } => "download-started",
DomainEvent::DownloadPaused { .. } => "download-paused",
DomainEvent::DownloadResumed { .. } => "download-resumed",
Expand All @@ -44,6 +48,11 @@ fn event_name(event: &DomainEvent) -> &'static str {
DomainEvent::DownloadWaiting { .. } => "download-waiting",
DomainEvent::DownloadWaitingStarted { .. } => "download-waiting-started",
DomainEvent::DownloadWaitingEnded { .. } => "download-waiting-ended",
DomainEvent::CaptchaRequired { .. } => "captcha-required",
DomainEvent::CaptchaPending { .. } => "captcha-pending",
DomainEvent::CaptchaSolved { .. } => "captcha-solved",
DomainEvent::CaptchaSkipped { .. } => "captcha-skipped",
DomainEvent::CaptchaTimedOut { .. } => "captcha-timed-out",
DomainEvent::DownloadChecking { .. } => "download-checking",
DomainEvent::DownloadCancelled { .. } => "download-cancelled",
DomainEvent::DownloadRemoved { .. } => "download-removed",
Expand Down Expand Up @@ -85,6 +94,7 @@ fn event_name(event: &DomainEvent) -> &'static str {
fn event_payload(event: &DomainEvent) -> serde_json::Value {
match event {
DomainEvent::DownloadCreated { id }
| DomainEvent::DownloadQueued { id }
| DomainEvent::DownloadStarted { id }
| DomainEvent::DownloadPaused { id }
| DomainEvent::DownloadResumed { id }
Expand All @@ -97,6 +107,43 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value {
| DomainEvent::DownloadExtracting { id } => json!({ "id": id.0 }),
DomainEvent::DownloadCompletedPersisted { id, .. } => json!({ "id": id.0 }),

DomainEvent::CaptchaRequired { download_id, .. } => {
json!({ "downloadId": download_id.0 })
}
DomainEvent::CaptchaPending {
challenge_id,
download_id,
} => json!({ "challengeId": challenge_id.as_str(), "downloadId": download_id.0 }),
DomainEvent::CaptchaSolved {
challenge_id,
download_id,
solver,
duration_ms,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"solver": solver,
"durationMs": duration_ms,
}),
DomainEvent::CaptchaSkipped {
challenge_id,
download_id,
reason,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"reason": reason,
}),
DomainEvent::CaptchaTimedOut {
challenge_id,
download_id,
duration_ms,
} => json!({
"challengeId": challenge_id.as_str(),
"downloadId": download_id.0,
"durationMs": duration_ms,
}),

DomainEvent::DownloadFailed { id, error } => json!({ "id": id.0, "error": error }),
DomainEvent::DownloadRetrying { id, attempt } => {
json!({ "id": id.0, "attempt": attempt })
Expand Down Expand Up @@ -318,6 +365,7 @@ fn to_tauri_event(event: &DomainEvent) -> (&'static str, serde_json::Value) {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::captcha::CaptchaType;
use crate::domain::model::download::DownloadId;

#[test]
Expand Down Expand Up @@ -349,6 +397,16 @@ mod tests {
));
}

#[test]
fn test_internal_captcha_required_event_is_not_forwarded() {
assert!(!should_forward_to_frontend(&DomainEvent::CaptchaRequired {
download_id: DownloadId(8),
challenge_type: CaptchaType::Image,
challenge_url: "https://hoster.example/captcha".into(),
image_data: Some(std::sync::Arc::<[u8]>::from(vec![1, 2, 3])),
}));
}

#[test]
fn test_event_name_download_variants() {
assert_eq!(
Expand Down
55 changes: 55 additions & 0 deletions src-tauri/src/adapters/driven/logging/download_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
DomainEvent::DownloadCreated { id } => {
store.push(id.0, "[INFO] Download created".to_string());
}
DomainEvent::DownloadQueued { id } => {
store.push(id.0, "[INFO] Download queued".to_string());
}
DomainEvent::DownloadStarted { id } => {
store.push(id.0, "[INFO] Download started".to_string());
}
Expand Down Expand Up @@ -62,6 +65,37 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
};
store.push(id.0, format!("[INFO] Wait {suffix}"));
}
DomainEvent::CaptchaPending { download_id, .. } => {
store.push(download_id.0, "[INFO] CAPTCHA waiting for user".to_string());
}
DomainEvent::CaptchaSolved {
download_id,
solver,
duration_ms,
..
} => {
store.push(
download_id.0,
format!("[INFO] CAPTCHA solved by {solver} in {duration_ms}ms"),
);
}
DomainEvent::CaptchaSkipped {
download_id,
reason,
..
} => {
store.push(download_id.0, format!("[WARN] CAPTCHA skipped: {reason}"));
}
DomainEvent::CaptchaTimedOut {
download_id,
duration_ms,
..
} => {
store.push(
download_id.0,
format!("[WARN] CAPTCHA timed out after {duration_ms}ms"),
);
}
DomainEvent::DownloadChecking { id } => {
store.push(id.0, "[INFO] Checking download".to_string());
}
Expand Down Expand Up @@ -142,6 +176,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
);
}
DomainEvent::DownloadProgress { .. }
| DomainEvent::CaptchaRequired { .. }
| DomainEvent::DownloadCompletedPersisted { .. }
| DomainEvent::DownloadPrioritySet { .. }
| DomainEvent::QueueReordered { .. }
Expand Down Expand Up @@ -173,6 +208,7 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
mod tests {
use super::record_download_event;
use crate::domain::event::DomainEvent;
use crate::domain::model::captcha::CaptchaId;
use crate::domain::model::download::DownloadId;

use super::DownloadLogStore;
Expand All @@ -195,6 +231,25 @@ mod tests {
);
}

#[test]
fn prefixes_skipped_captcha_log_lines() {
let store = DownloadLogStore::new(8);

record_download_event(
&store,
&DomainEvent::CaptchaSkipped {
challenge_id: CaptchaId::new("captcha-42"),
download_id: DownloadId(42),
reason: "user choice".into(),
},
);

assert_eq!(
store.recent(42, 10),
vec!["[WARN] CAPTCHA skipped: user choice".to_string()]
);
}

#[test]
fn ignores_unscoped_events() {
let store = DownloadLogStore::new(8);
Expand Down
50 changes: 34 additions & 16 deletions src-tauri/src/adapters/driven/network/download_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ struct RemoteMetadata {
/// signal.
const MIN_SPLIT_SAMPLE_DURATION: std::time::Duration = std::time::Duration::from_millis(500);

fn source_resolution_event(
download_id: DownloadId,
error: &DomainError,
cancelled: bool,
) -> DomainEvent {
if cancelled {
return DomainEvent::DownloadCancelled { id: download_id };
}
if let DomainError::CaptchaRequired {
challenge_type,
challenge_url,
image_data,
} = error
{
return DomainEvent::CaptchaRequired {
download_id,
challenge_type: *challenge_type,
challenge_url: challenge_url.clone(),
image_data: image_data.as_deref().map(Arc::<[u8]>::from),
};
}
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(error),
}
}

fn segment_count_for_attempt(
requested_segments: u32,
total_size: u64,
Expand Down Expand Up @@ -416,14 +443,8 @@ impl DownloadEngine for SegmentedDownloadEngine {
{
Ok(prepared) => prepared,
Err(error) => {
let event = if cancel_token.is_cancelled() {
DomainEvent::DownloadCancelled { id: download_id }
} else {
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(&error),
}
};
let event =
source_resolution_event(download_id, &error, cancel_token.is_cancelled());
event_bus.publish(event);
active_downloads
.lock()
Expand Down Expand Up @@ -524,14 +545,11 @@ impl DownloadEngine for SegmentedDownloadEngine {
{
Ok(refreshed) => refreshed,
Err(error) => {
let event = if cancel_token.is_cancelled() {
DomainEvent::DownloadCancelled { id: download_id }
} else {
DomainEvent::DownloadFailed {
id: download_id,
error: safe_source_failure(&error),
}
};
let event = source_resolution_event(
download_id,
&error,
cancel_token.is_cancelled(),
);
event_bus.publish(event);
break;
}
Expand Down
Loading
Loading