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
8 changes: 8 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 All @@ -17,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- CAPTCHA recovery now re-arms every pending timeout, and download removal
atomically coordinates engine cancellation with challenge cleanup (MAT-140).
- Contributor Automation: `actions/first-interaction` v3 requires both
`issue_message` and `pr_message` at runtime, so the split welcome jobs
each crashed with "Input required and not supplied". Merged them into a
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