From 6333fe080229b475a07bb84f67ac22cd2a56ea1d Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:34:07 +0200 Subject: [PATCH 1/7] feat(captcha): add automatic solver cascade (MAT-141) --- .github/workflows/plugin-ci.yml | 6 + CHANGELOG.md | 3 + registry/registry.toml | 36 +++ src-tauri/capabilities/default.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- .../adapters/driven/captcha_interaction.rs | 75 +++++ .../driven/config/toml_config_store.rs | 35 ++- src-tauri/src/adapters/driven/mod.rs | 1 + .../adapters/driven/plugin/capabilities.rs | 71 ++++- .../adapters/driven/plugin/captcha_solver.rs | 33 +++ .../driven/plugin/captcha_solver_tests.rs | 67 +++++ .../adapters/driven/plugin/extism_loader.rs | 153 +++++++++- .../adapters/driven/plugin/host_functions.rs | 38 +++ src-tauri/src/adapters/driven/plugin/mod.rs | 7 + .../src/adapters/driven/plugin/provenance.rs | 5 +- .../driven/plugin/tesseract_broker.rs | 264 ++++++++++++++++++ .../driven/plugin/tesseract_broker_tests.rs | 102 +++++++ .../adapters/driven/plugin/ytdlp_broker.rs | 2 +- .../driven/plugin/ytdlp_broker/platform.rs | 33 ++- .../adapters/driven/sqlite/captcha_repo.rs | 5 +- .../driven/sqlite/captcha_repo_tests.rs | 27 +- .../driven/sqlite/entities/captcha_log.rs | 48 +++- ...0720_000012_add_captcha_solver_attempts.rs | 40 +++ .../adapters/driven/sqlite/migrations/mod.rs | 2 + src-tauri/src/adapters/driving/tauri_ipc.rs | 69 ++++- src-tauri/src/application/commands/captcha.rs | 247 ++++++++++++---- .../commands/captcha_credential.rs | 110 ++++++++ .../src/application/commands/captcha_tests.rs | 242 +++++++++++++++- src-tauri/src/application/commands/mod.rs | 21 ++ .../src/application/commands/tests_support.rs | 17 ++ .../application/read_models/captcha_view.rs | 47 +++- src-tauri/src/domain/model/captcha.rs | 205 ++++++++++++++ src-tauri/src/domain/model/config.rs | 86 ++++++ .../ports/driven/captcha_interaction.rs | 7 + .../src/domain/ports/driven/captcha_solver.rs | 34 ++- src-tauri/src/domain/ports/driven/mod.rs | 2 + .../src/domain/ports/driven/plugin_loader.rs | 31 ++ src-tauri/src/lib.rs | 56 ++-- src/App.tsx | 42 +-- .../__tests__/ClipboardIndicator.test.tsx | 5 + src/hooks/__tests__/useAppEffects.test.ts | 5 + src/i18n/locales/en.json | 26 +- src/i18n/locales/fr.json | 26 +- src/layouts/__tests__/AppLayout.test.tsx | 5 + src/stores/__tests__/settingsStore.test.ts | 5 + src/types/captcha.ts | 14 + src/types/settings.ts | 1 + src/views/CaptchaBrowserWindow.tsx | 22 ++ src/views/CaptchaChallengePanel.tsx | 34 ++- src/views/CaptchaSolverSettings.tsx | 195 +++++++++++-- .../__tests__/LinkGrabberView.test.tsx | 5 + .../SettingsView/__tests__/Sections.test.tsx | 5 + .../__tests__/SettingsView.test.tsx | 5 + .../__tests__/CaptchaBrowserWindow.test.tsx | 64 +++++ src/views/__tests__/CaptchaView.test.tsx | 90 ++++++ 55 files changed, 2620 insertions(+), 160 deletions(-) create mode 100644 src-tauri/src/adapters/driven/captcha_interaction.rs create mode 100644 src-tauri/src/adapters/driven/plugin/captcha_solver.rs create mode 100644 src-tauri/src/adapters/driven/plugin/captcha_solver_tests.rs create mode 100644 src-tauri/src/adapters/driven/plugin/tesseract_broker.rs create mode 100644 src-tauri/src/adapters/driven/plugin/tesseract_broker_tests.rs create mode 100644 src-tauri/src/adapters/driven/sqlite/migrations/m20260720_000012_add_captcha_solver_attempts.rs create mode 100644 src-tauri/src/application/commands/captcha_credential.rs create mode 100644 src-tauri/src/domain/ports/driven/captcha_interaction.rs create mode 100644 src/views/CaptchaBrowserWindow.tsx create mode 100644 src/views/__tests__/CaptchaBrowserWindow.test.tsx diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 2863899c..ac41f239 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -110,6 +110,12 @@ jobs: test -s "${WASM_PATH}" case "${PACKAGE_NAME}" in + vortex-mod-captcha-anticaptcha) + required="can_solve solve get_balance" + ;; + vortex-mod-captcha-browser|vortex-mod-captcha-ocr) + required="can_solve solve" + ;; vortex-mod-containers) required="can_decrypt detect decrypt" ;; diff --git a/CHANGELOG.md b/CHANGELOG.md index 928e1117..8f50b706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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). +- CAPTCHA solvers: configurable OCR → AntiCaptcha → browser cascade, typed + Tesseract host broker, keyring-backed AntiCaptcha credentials, persisted + per-solver attempts, and a dedicated human-assisted WebView (MAT-141). ### Security diff --git a/registry/registry.toml b/registry/registry.toml index de2125be..8b3e24fc 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -64,6 +64,42 @@ official = true # the correct floor until the next plugin release fixes the manifest. min_vortex_version = "0.2.0" +[[plugin]] +name = "vortex-mod-captcha-ocr" +description = "Local OCR solver for simple image CAPTCHAs via the typed Tesseract host broker" +author = "vortex-community" +version = "1.0.0" +category = "captcha" +repository = "https://github.com/mpiton/vortex-mod-captcha-ocr" +checksum_sha256 = "bbb6bdcb2d9cf89ff4136bdb39129686a00c92b0620050cb6f61168cb60ef181" +checksum_sha256_toml = "08fbee0b5464ad4eb55ee1da28088f4ad9b2179eb8ee82a5505edfd3bd91c660" +official = true +min_vortex_version = "0.3.0" + +[[plugin]] +name = "vortex-mod-captcha-anticaptcha" +description = "Paid AntiCaptcha image solver with credentials isolated in the OS keyring" +author = "vortex-community" +version = "1.0.0" +category = "captcha" +repository = "https://github.com/mpiton/vortex-mod-captcha-anticaptcha" +checksum_sha256 = "6a18863455ee375511ed6a34de96dfb64df75096c4a968ab7d17b0805b8c993a" +checksum_sha256_toml = "b16268b0ae26b222c3048677d0d707f5338c87169466d7fbe0f7a682e213acc9" +official = true +min_vortex_version = "0.3.0" + +[[plugin]] +name = "vortex-mod-captcha-browser" +description = "Human-assisted CAPTCHA fallback in a dedicated local Tauri WebView" +author = "vortex-community" +version = "1.0.0" +category = "captcha" +repository = "https://github.com/mpiton/vortex-mod-captcha-browser" +checksum_sha256 = "edfdb71e407af9a3b5910394d7e66326be13ab08d6bdea1b00a2ce65cfee0c4e" +checksum_sha256_toml = "99c4404a16fcf45023c0e74e05be40ef75fbf605b6f6b3c90b9f536effb8c7ea" +official = true +min_vortex_version = "0.3.0" + [[plugin]] name = "vortex-mod-mediafire" description = "MediaFire free hoster — direct download URL resolution from public file pages" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 85776dd7..be9e4ed3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "identifier": "default", "description": "Default capabilities for Vortex", - "windows": ["main"], + "windows": ["main", "captcha-browser-*"], "permissions": [ "core:default", "core:tray:default", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index f4c20fca..414ef1b2 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Vortex","local":true,"windows":["main"],"permissions":["core:default","core:tray:default","notification:allow-notify","notification:allow-request-permission","notification:allow-is-permission-granted","dialog:allow-save","dialog:allow-open"]},"dev-pilot":{"identifier":"dev-pilot","description":"tauri-pilot testing plugin (Unix debug builds only)","local":true,"windows":["main"],"permissions":["pilot:default"],"platforms":["linux","macOS"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Vortex","local":true,"windows":["main","captcha-browser-*"],"permissions":["core:default","core:tray:default","notification:allow-notify","notification:allow-request-permission","notification:allow-is-permission-granted","dialog:allow-save","dialog:allow-open"]},"dev-pilot":{"identifier":"dev-pilot","description":"tauri-pilot testing plugin (Unix debug builds only)","local":true,"windows":["main"],"permissions":["pilot:default"],"platforms":["linux","macOS"]}} \ No newline at end of file diff --git a/src-tauri/src/adapters/driven/captcha_interaction.rs b/src-tauri/src/adapters/driven/captcha_interaction.rs new file mode 100644 index 00000000..a94ba6a1 --- /dev/null +++ b/src-tauri/src/adapters/driven/captcha_interaction.rs @@ -0,0 +1,75 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaChallenge; +use crate::domain::ports::driven::CaptchaInteraction; + +pub struct TauriCaptchaInteraction { + app: AppHandle, +} + +impl TauriCaptchaInteraction { + pub fn new(app: AppHandle) -> Self { + Self { app } + } +} + +impl CaptchaInteraction for TauriCaptchaInteraction { + fn request(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + let label = browser_window_label(challenge.id().as_str()); + if let Some(window) = self.app.get_webview_window(&label) { + window.show().map_err(window_error)?; + window.set_focus().map_err(window_error)?; + return Ok(()); + } + + WebviewWindowBuilder::new( + &self.app, + label, + WebviewUrl::App(browser_window_path(challenge.id().as_str()).into()), + ) + .title("Vortex CAPTCHA") + .inner_size(560.0, 680.0) + .resizable(true) + .center() + .build() + .map_err(window_error)?; + Ok(()) + } +} + +fn browser_window_label(challenge_id: &str) -> String { + let mut hasher = DefaultHasher::new(); + challenge_id.hash(&mut hasher); + format!("captcha-browser-{:016x}", hasher.finish()) +} + +fn browser_window_path(challenge_id: &str) -> String { + let encoded: String = url::form_urlencoded::byte_serialize(challenge_id.as_bytes()).collect(); + format!("index.html?captchaWindow={encoded}") +} + +fn window_error(_: tauri::Error) -> DomainError { + DomainError::PluginError("could not open the CAPTCHA browser window".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_path_encodes_the_challenge_id_and_label_is_capability_safe() { + assert_eq!( + browser_window_path("captcha / ?"), + "index.html?captchaWindow=captcha+%2F+%3F" + ); + assert!( + browser_window_label("captcha / ?") + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + ); + } +} diff --git a/src-tauri/src/adapters/driven/config/toml_config_store.rs b/src-tauri/src/adapters/driven/config/toml_config_store.rs index 0f94e324..aada5f4b 100644 --- a/src-tauri/src/adapters/driven/config/toml_config_store.rs +++ b/src-tauri/src/adapters/driven/config/toml_config_store.rs @@ -10,7 +10,7 @@ use crate::domain::error::DomainError; use crate::domain::model::account::AccountSelectionStrategy; use crate::domain::model::config::{ AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, apply_patch, - normalize_history_retention_days, + normalize_captcha_solver_order, normalize_history_retention_days, }; use crate::domain::ports::driven::ConfigStore; @@ -165,6 +165,7 @@ struct ConfigDto { // CAPTCHA captcha_timeout_seconds: u32, + captcha_solver_order: Vec, // History history_retention_days: i64, @@ -230,6 +231,7 @@ impl From for ConfigDto { 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, + captcha_solver_order: c.captcha_solver_order, history_retention_days: c.history_retention_days, account_selection_strategy: c.account_selection_strategy.to_string(), proxy_type: c.proxy_type, @@ -291,6 +293,7 @@ impl TryFrom for AppConfig { captcha_timeout_seconds: d .captcha_timeout_seconds .clamp(MIN_CAPTCHA_TIMEOUT_SECONDS, MAX_CAPTCHA_TIMEOUT_SECONDS), + captcha_solver_order: normalize_captcha_solver_order(&d.captcha_solver_order), history_retention_days: normalize_history_retention_days(d.history_retention_days), account_selection_strategy, proxy_type: d.proxy_type, @@ -319,7 +322,10 @@ impl TryFrom for AppConfig { #[cfg(test)] mod tests { use super::*; - use crate::domain::model::config::{MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS}; + use crate::domain::model::config::{ + CAPTCHA_SOLVER_BROWSER, CAPTCHA_SOLVER_OCR, MAX_CAPTCHA_TIMEOUT_SECONDS, + MIN_CAPTCHA_TIMEOUT_SECONDS, default_captcha_solver_order, + }; /// 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. @@ -419,6 +425,31 @@ mod tests { // All other fields should be defaults assert_eq!(config.max_concurrent_downloads, 4); assert!(config.notifications_enabled); + assert_eq!(config.captcha_solver_order, default_captcha_solver_order()); + } + + #[test] + fn test_captcha_solver_order_is_persisted_and_reloaded() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let store = TomlConfigStore::new(path.clone(), None, Some(TEST_API_KEY.to_string())); + let expected = vec![ + CAPTCHA_SOLVER_BROWSER.to_string(), + CAPTCHA_SOLVER_OCR.to_string(), + ]; + + store + .update_config(ConfigPatch { + captcha_solver_order: Some(expected.clone()), + ..Default::default() + }) + .unwrap(); + let restarted = TomlConfigStore::new(path, None, None); + + assert_eq!( + restarted.get_config().unwrap().captcha_solver_order, + expected + ); } #[test] diff --git a/src-tauri/src/adapters/driven/mod.rs b/src-tauri/src/adapters/driven/mod.rs index 236eaaea..77d09620 100644 --- a/src-tauri/src/adapters/driven/mod.rs +++ b/src-tauri/src/adapters/driven/mod.rs @@ -1,5 +1,6 @@ //! Driven adapters — implementations of domain port traits. +pub mod captcha_interaction; pub mod clipboard; pub mod config; pub mod credential; diff --git a/src-tauri/src/adapters/driven/plugin/capabilities.rs b/src-tauri/src/adapters/driven/plugin/capabilities.rs index e039ce14..eee7d384 100644 --- a/src-tauri/src/adapters/driven/plugin/capabilities.rs +++ b/src-tauri/src/adapters/driven/plugin/capabilities.rs @@ -130,6 +130,7 @@ pub struct PluginHostContext { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct HostFunctionGrants { pub(super) ytdlp: bool, + pub(super) tesseract: bool, } /// Build host functions based on manifest capabilities. @@ -207,6 +208,14 @@ fn build_host_functions_with_slot( "ignoring yt-dlp capability without verified official provenance" ); } + let declares_tesseract = manifest.has_capability("subprocess:tesseract"); + let supports_tesseract = super::tesseract_broker::supports_plugin(&name); + if declares_tesseract && (!supports_tesseract || !grants.tesseract) { + tracing::warn!( + plugin = %name, + "ignoring Tesseract capability without verified official provenance" + ); + } let ctx = PluginHostContext { plugin_name: name, @@ -239,6 +248,11 @@ fn build_host_functions_with_slot( user_data.clone(), )); } + if declares_tesseract && supports_tesseract && grants.tesseract { + functions.push(super::host_functions::make_run_tesseract_function( + user_data.clone(), + )); + } functions } @@ -274,7 +288,10 @@ mod tests { let functions = build_host_functions_with_grants( &manifest, &shared, - HostFunctionGrants { ytdlp: true }, + HostFunctionGrants { + ytdlp: true, + ..Default::default() + }, ); // 6 base + http + typed yt-dlp + legacy compatibility = 9 @@ -395,7 +412,10 @@ mod tests { let functions = build_host_functions_with_grants( &manifest, &shared, - HostFunctionGrants { ytdlp: true }, + HostFunctionGrants { + ytdlp: true, + ..Default::default() + }, ); assert_eq!(functions.len(), 8); @@ -415,6 +435,53 @@ mod tests { assert!(!functions.iter().any(|f| f.name() == "run_subprocess")); } + #[test] + fn verified_ocr_plugin_registers_only_the_typed_tesseract_broker() { + let shared = Arc::new(SharedHostResources::new()); + let manifest = + make_named_manifest_with_caps("vortex-mod-captcha-ocr", vec!["subprocess:tesseract"]); + + let functions = build_host_functions_with_grants( + &manifest, + &shared, + HostFunctionGrants { + ytdlp: false, + tesseract: true, + }, + ); + + assert!( + functions + .iter() + .any(|function| function.name() == "run_tesseract") + ); + assert!( + !functions + .iter() + .any(|function| function.name() == "run_subprocess") + ); + } + + #[test] + fn ocr_manifest_cannot_self_grant_tesseract_access() { + let shared = Arc::new(SharedHostResources::new()); + let manifest = + make_named_manifest_with_caps("vortex-mod-captcha-ocr", vec!["subprocess:tesseract"]); + + let functions = build_host_functions(&manifest, &shared); + + assert!( + !functions + .iter() + .any(|function| function.name() == "run_tesseract") + ); + assert!( + !functions + .iter() + .any(|function| function.name() == "run_subprocess") + ); + } + #[test] fn test_unapproved_plugin_cannot_register_ytdlp() { let shared = Arc::new(SharedHostResources::new()); diff --git a/src-tauri/src/adapters/driven/plugin/captcha_solver.rs b/src-tauri/src/adapters/driven/plugin/captcha_solver.rs new file mode 100644 index 00000000..890dc580 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/captcha_solver.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaChallenge; +use crate::domain::ports::driven::{CaptchaSolver, CaptchaSolverOutcome, PluginLoader}; + +pub struct PluginCaptchaSolver { + plugin_name: String, + loader: Arc, +} + +impl PluginCaptchaSolver { + pub fn new(plugin_name: impl Into, loader: Arc) -> Self { + Self { + plugin_name: plugin_name.into(), + loader, + } + } +} + +impl CaptchaSolver for PluginCaptchaSolver { + fn name(&self) -> &str { + &self.plugin_name + } + + fn solve( + &self, + challenge: &CaptchaChallenge, + _solution: &str, + ) -> Result { + self.loader.solve_captcha(&self.plugin_name, challenge) + } +} diff --git a/src-tauri/src/adapters/driven/plugin/captcha_solver_tests.rs b/src-tauri/src/adapters/driven/plugin/captcha_solver_tests.rs new file mode 100644 index 00000000..3dd3599e --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/captcha_solver_tests.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use super::captcha_solver::PluginCaptchaSolver; +use crate::domain::error::DomainError; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaSolution, CaptchaType}; +use crate::domain::model::download::DownloadId; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{CaptchaSolver, CaptchaSolverOutcome, PluginLoader}; + +struct SolvingLoader; + +impl PluginLoader for SolvingLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(None) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(Vec::new()) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn solve_captcha( + &self, + plugin_name: &str, + _: &CaptchaChallenge, + ) -> Result { + assert_eq!(plugin_name, "vortex-mod-captcha-ocr"); + Ok(CaptchaSolverOutcome::Solved( + CaptchaSolution::try_new("answer").expect("valid solution"), + )) + } +} + +fn challenge() -> CaptchaChallenge { + CaptchaChallenge::new( + CaptchaId::new("captcha-1"), + DownloadId(1), + CaptchaType::Image, + "https://example.com/captcha".to_string(), + 1_000, + 61_000, + ) + .expect("valid challenge") +} + +#[test] +fn plugin_solver_delegates_to_the_exact_named_plugin() { + let solver = PluginCaptchaSolver::new("vortex-mod-captcha-ocr", Arc::new(SolvingLoader)); + + assert_eq!(solver.name(), "vortex-mod-captcha-ocr"); + let outcome = solver.solve(&challenge(), "").expect("solve"); + let CaptchaSolverOutcome::Solved(solution) = outcome else { + panic!("expected solved outcome"); + }; + assert_eq!(solution.expose(), "answer"); +} diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index c99da8f1..fa7025d0 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -5,13 +5,18 @@ use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use base64::Engine; + use crate::domain::error::DomainError; use crate::domain::model::account::AccountStatus; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaSolution}; use crate::domain::model::credential::Credential; -use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; use crate::domain::ports::driven::plugin_loader::DownloadedFileInfo; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; -use crate::domain::ports::driven::{ExtractedHosterLink, PluginLoader, ValidationOutcome}; +use crate::domain::ports::driven::{ + CaptchaSolverOutcome, ExtractedHosterLink, PluginLoader, ValidationOutcome, +}; use super::builtin::HttpModule; use super::capabilities::{SharedHostResources, build_host_functions_for_instance}; @@ -38,6 +43,59 @@ struct InstallState { count: AtomicUsize, } +const MAX_CAPTCHA_SOLVER_OUTPUT_BYTES: usize = 8 * 1024; + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct CaptchaSolverRequest<'a> { + challenge_id: &'a str, + challenge_type: String, + challenge_url: &'a str, + image_data: Option, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CaptchaSolverResponse { + status: String, + solution: Option, +} + +fn encode_captcha_solver_request(challenge: &CaptchaChallenge) -> Result { + serde_json::to_string(&CaptchaSolverRequest { + challenge_id: challenge.id().as_str(), + challenge_type: challenge.challenge_type().to_string(), + challenge_url: challenge.url(), + image_data: challenge + .image_data() + .map(|image| base64::engine::general_purpose::STANDARD.encode(image)), + }) + .map_err(|_| DomainError::PluginError("failed to encode CAPTCHA request".into())) +} + +fn parse_captcha_solver_output(output: &str) -> Result { + if output.len() > MAX_CAPTCHA_SOLVER_OUTPUT_BYTES { + return Err(DomainError::PluginError( + "CAPTCHA solver response exceeds safety limit".into(), + )); + } + let response: CaptchaSolverResponse = serde_json::from_str(output) + .map_err(|_| DomainError::PluginError("CAPTCHA solver returned invalid JSON".into()))?; + match (response.status.as_str(), response.solution) { + ("solved", Some(solution)) => CaptchaSolution::try_new(solution) + .map(CaptchaSolverOutcome::Solved) + .map_err(|_| { + DomainError::PluginError("CAPTCHA solver returned an invalid solution".into()) + }), + ("unavailable", None) => Ok(CaptchaSolverOutcome::Unavailable), + ("rejected", None) => Ok(CaptchaSolverOutcome::Rejected), + ("interaction_required", None) => Ok(CaptchaSolverOutcome::InteractionRequired), + _ => Err(DomainError::PluginError( + "CAPTCHA solver returned an invalid status payload".into(), + )), + } +} + impl InstallState { fn new() -> Self { Self { @@ -487,6 +545,56 @@ impl PluginLoader for ExtismPluginLoader { Ok(()) } + fn solve_captcha( + &self, + plugin_name: &str, + challenge: &CaptchaChallenge, + ) -> Result { + let info = self + .registry + .list_info() + .into_iter() + .find(|info| info.name() == plugin_name) + .ok_or_else(|| DomainError::NotFound(plugin_name.to_string()))?; + if !info.is_enabled() || info.category() != PluginCategory::Captcha { + return Err(DomainError::NotFound(format!( + "CAPTCHA solver '{plugin_name}' is not enabled" + ))); + } + for export in ["can_solve", "solve"] { + if !self.registry.function_exists(plugin_name, export)? { + return Err(DomainError::PluginError(format!( + "CAPTCHA plugin '{plugin_name}' does not export '{export}'" + ))); + } + } + let request = encode_captcha_solver_request(challenge)?; + let supports = self + .registry + .call_plugin(plugin_name, "can_solve", &request) + .map_err(|_| { + DomainError::PluginError(format!( + "CAPTCHA plugin '{plugin_name}' capability probe failed" + )) + })?; + match supports.trim() { + "false" => return Ok(CaptchaSolverOutcome::Unavailable), + "true" => {} + _ => { + return Err(DomainError::PluginError(format!( + "CAPTCHA plugin '{plugin_name}' returned an invalid capability response" + ))); + } + } + let output = self + .registry + .call_plugin(plugin_name, "solve", &request) + .map_err(|_| { + DomainError::PluginError(format!("CAPTCHA plugin '{plugin_name}' solve failed")) + })?; + parse_captcha_solver_output(&output) + } + fn extract_links(&self, url: &str) -> Result { self.call_url_plugin_function(url, "extract_links") } @@ -923,6 +1031,7 @@ fn parse_validation_outcome(output: &str) -> Result) - ) } +/// Run Tesseract with a host-owned executable, image stdin and fixed arguments. +pub fn make_run_tesseract_function( + user_data: extism::UserData, +) -> extism::Function { + extism::Function::new( + "run_tesseract", + [extism::ValType::I64], + [extism::ValType::I64], + user_data, + |plugin, inputs, outputs, ud| { + let input = read_input_string(plugin, inputs)?; + let request: PluginTesseractRequest = serde_json::from_str(&input) + .map_err(|_| anyhow::anyhow!("run_tesseract: invalid request"))?; + let plugin_name = { + let guard = ud.get()?; + let ctx = guard + .lock() + .map_err(|_| anyhow::anyhow!("run_tesseract: mutex poisoned"))?; + if !ctx + .capabilities + .iter() + .any(|cap| cap == "subprocess:tesseract") + { + return Err(anyhow::anyhow!("run_tesseract: capability is not declared")); + } + ctx.plugin_name.clone() + }; + let response = run_tesseract_request(&plugin_name, request)?; + let json = serde_json::to_string(&response) + .map_err(|_| anyhow::anyhow!("run_tesseract: failed to encode response"))?; + write_output_string(plugin, outputs, &json) + }, + ) +} + /// Compatibility shim for already-published plugins using the former ABI. /// /// The broker accepts only the exact historical yt-dlp profiles of official diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs index 5fde9504..bfa3d68f 100644 --- a/src-tauri/src/adapters/driven/plugin/mod.rs +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -1,6 +1,9 @@ pub mod account_validator; pub mod builtin; pub mod capabilities; +pub mod captcha_solver; +#[cfg(test)] +mod captcha_solver_tests; pub mod extism_loader; pub mod github_store_client; pub mod host_functions; @@ -10,10 +13,14 @@ mod hoster_contract_tests; pub mod manifest; mod provenance; pub mod registry; +pub(crate) mod tesseract_broker; +#[cfg(test)] +mod tesseract_broker_tests; pub mod watcher; pub(crate) mod ytdlp_broker; pub use account_validator::PluginAccountValidator; +pub use captcha_solver::PluginCaptchaSolver; pub use extism_loader::ExtismPluginLoader; pub use github_store_client::GithubStoreClient; pub use registry::PluginRegistry; diff --git a/src-tauri/src/adapters/driven/plugin/provenance.rs b/src-tauri/src/adapters/driven/plugin/provenance.rs index e380dbf7..79c6ea5e 100644 --- a/src-tauri/src/adapters/driven/plugin/provenance.rs +++ b/src-tauri/src/adapters/driven/plugin/provenance.rs @@ -132,7 +132,10 @@ impl OfficialProvenanceStore { && entry.wasm_sha256 == digest(wasm_bytes) && entry.manifest_sha256 == digest(manifest_bytes) }); - HostFunctionGrants { ytdlp: verified } + HostFunctionGrants { + ytdlp: verified, + tesseract: verified && name == "vortex-mod-captcha-ocr", + } } pub(super) fn revoke(&self, name: &str) -> Result<(), DomainError> { diff --git a/src-tauri/src/adapters/driven/plugin/tesseract_broker.rs b/src-tauri/src/adapters/driven/plugin/tesseract_broker.rs new file mode 100644 index 00000000..345c9665 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/tesseract_broker.rs @@ -0,0 +1,264 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, bail}; +use base64::Engine; +use command_group::{CommandGroup, GroupChild}; + +use crate::domain::model::captcha::{ + MAX_CAPTCHA_IMAGE_BYTES, MAX_CAPTCHA_SOLUTION_BYTES, captcha_image_mime_type, +}; + +const OCR_PLUGIN_NAME: &str = "vortex-mod-captcha-ocr"; +const PROCESS_TIMEOUT: Duration = Duration::from_secs(15); +const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(25); +const OUTPUT_LIMIT: usize = MAX_CAPTCHA_SOLUTION_BYTES; +const ERROR_OUTPUT_LIMIT: usize = 4 * 1024; + +pub(crate) fn supports_plugin(plugin_name: &str) -> bool { + plugin_name == OCR_PLUGIN_NAME +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PluginTesseractRequest { + image_data: String, +} + +#[derive(Debug, serde::Serialize)] +pub(crate) struct TesseractResponse { + pub(crate) status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) solution: Option, +} + +pub(crate) fn run_plugin_request( + plugin_name: &str, + request: PluginTesseractRequest, +) -> anyhow::Result { + run_with_discovery(plugin_name, request, || Ok(discover_tesseract())) +} + +pub(crate) fn run_with_discovery( + plugin_name: &str, + request: PluginTesseractRequest, + discover: impl FnOnce() -> anyhow::Result>, +) -> anyhow::Result { + run_with_discovery_timeout(plugin_name, request, discover, PROCESS_TIMEOUT) +} + +pub(crate) fn run_with_discovery_timeout( + plugin_name: &str, + request: PluginTesseractRequest, + discover: impl FnOnce() -> anyhow::Result>, + timeout: Duration, +) -> anyhow::Result { + if !supports_plugin(plugin_name) { + bail!("run_tesseract: plugin is not authorized"); + } + let image = decode_image(request)?; + let Some(binary) = discover()? else { + return Ok(TesseractResponse { + status: "unavailable", + solution: None, + }); + }; + let output = execute(&binary, image, timeout)?; + if !output.status.success() { + return Ok(TesseractResponse { + status: "rejected", + solution: None, + }); + } + let solution = String::from_utf8(output.stdout) + .context("run_tesseract: stdout is not valid UTF-8")? + .trim() + .to_string(); + if solution.is_empty() || solution.len() > MAX_CAPTCHA_SOLUTION_BYTES { + return Ok(TesseractResponse { + status: "rejected", + solution: None, + }); + } + Ok(TesseractResponse { + status: "solved", + solution: Some(solution), + }) +} + +fn decode_image(request: PluginTesseractRequest) -> anyhow::Result> { + let max_encoded = MAX_CAPTCHA_IMAGE_BYTES.div_ceil(3) * 4; + if request.image_data.len() > max_encoded { + bail!("run_tesseract: encoded image exceeds safety limit"); + } + let image = base64::engine::general_purpose::STANDARD + .decode(request.image_data) + .context("run_tesseract: image is not valid base64")?; + if image.is_empty() + || image.len() > MAX_CAPTCHA_IMAGE_BYTES + || captcha_image_mime_type(&image).is_none() + { + bail!("run_tesseract: image is invalid or exceeds safety limits"); + } + Ok(image) +} + +fn discover_tesseract() -> Option { + let mut candidates = Vec::new(); + let mut roots = Vec::new(); + #[cfg(unix)] + { + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/tesseract"), + PathBuf::from("/usr/local/bin/tesseract"), + PathBuf::from("/usr/bin/tesseract"), + PathBuf::from("/run/current-system/sw/bin/tesseract"), + PathBuf::from("/nix/var/nix/profiles/default/bin/tesseract"), + ]); + roots.extend([ + PathBuf::from("/opt/homebrew"), + PathBuf::from("/usr/local"), + PathBuf::from("/usr"), + PathBuf::from("/run/current-system"), + PathBuf::from("/nix/store"), + PathBuf::from("/nix/var/nix/profiles"), + ]); + } + #[cfg(windows)] + if let Some(program_files) = std::env::var_os("ProgramFiles") { + let program_files = PathBuf::from(program_files); + candidates.push(program_files.join("Tesseract-OCR/tesseract.exe")); + roots.push(program_files); + } + super::ytdlp_broker::platform::find_approved_named_binary( + &candidates, + &roots, + if cfg!(windows) { + "tesseract.exe" + } else { + "tesseract" + }, + ) + .ok() +} + +struct ProcessOutput { + status: ExitStatus, + stdout: Vec, +} + +fn execute(binary: &Path, image: Vec, timeout: Duration) -> anyhow::Result { + if !binary.is_absolute() { + bail!("run_tesseract: approved binary path must be absolute"); + } + let mut command = Command::new(binary); + command + .args(["stdin", "stdout", "-l", "eng", "--psm", "7"]) + .env_clear() + .env("LANG", "C.UTF-8") + .env("LC_ALL", "C.UTF-8") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + super::ytdlp_broker::platform::copy_required_environment(&mut command); + let mut child = command + .group_spawn() + .with_context(|| format!("run_tesseract: failed to spawn '{}'", binary.display()))?; + + let stdin = child + .inner() + .stdin + .take() + .context("run_tesseract: stdin is unavailable")?; + let stdout = spawn_reader(child.inner().stdout.take(), OUTPUT_LIMIT); + let stderr = spawn_reader(child.inner().stderr.take(), ERROR_OUTPUT_LIMIT); + let stdin = spawn_writer(stdin, image); + let status = wait_for_group(&mut child, timeout); + let stdin = join_writer(stdin); + let stdout = join_reader(stdout, "stdout"); + let stderr = join_reader(stderr, "stderr"); + let status = status?; + stdin?; + let stdout = stdout?; + let _stderr = stderr?; + Ok(ProcessOutput { status, stdout }) +} + +fn spawn_writer( + mut writer: impl Write + Send + 'static, + image: Vec, +) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + writer + .write_all(&image) + .context("run_tesseract: failed to write image") + }) +} + +fn join_writer(handle: std::thread::JoinHandle>) -> anyhow::Result<()> { + handle + .join() + .map_err(|_| anyhow::anyhow!("run_tesseract: stdin writer panicked"))? +} + +fn spawn_reader( + reader: Option, + limit: usize, +) -> std::thread::JoinHandle>> { + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let mut reader = reader.context("run_tesseract: process pipe is unavailable")?; + reader + .by_ref() + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .context("run_tesseract: failed to read process output")?; + if bytes.len() > limit { + bail!("run_tesseract: process output exceeds safety limit"); + } + Ok(bytes) + }) +} + +fn join_reader( + handle: std::thread::JoinHandle>>, + stream: &str, +) -> anyhow::Result> { + handle + .join() + .map_err(|_| anyhow::anyhow!("run_tesseract: {stream} reader panicked"))? +} + +fn wait_for_group(child: &mut GroupChild, timeout: Duration) -> anyhow::Result { + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(error) => { + terminate_group(child)?; + return Err(error).context("run_tesseract: failed to poll process"); + } + } + if started.elapsed() >= timeout { + terminate_group(child)?; + bail!("run_tesseract: process timed out"); + } + std::thread::sleep(PROCESS_POLL_INTERVAL); + } +} + +fn terminate_group(child: &mut GroupChild) -> anyhow::Result<()> { + let kill_error = child.kill().err(); + child + .wait() + .context("run_tesseract: failed to reap process group")?; + if let Some(error) = kill_error + && error.kind() != std::io::ErrorKind::InvalidInput + { + return Err(error).context("run_tesseract: failed to kill process group"); + } + Ok(()) +} diff --git a/src-tauri/src/adapters/driven/plugin/tesseract_broker_tests.rs b/src-tauri/src/adapters/driven/plugin/tesseract_broker_tests.rs new file mode 100644 index 00000000..dbd8b4e8 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/tesseract_broker_tests.rs @@ -0,0 +1,102 @@ +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use base64::Engine; + +use super::tesseract_broker::{ + PluginTesseractRequest, run_with_discovery, run_with_discovery_timeout, +}; +use crate::domain::model::captcha::MAX_CAPTCHA_IMAGE_BYTES; + +fn png_image() -> Vec { + b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec() +} + +fn request() -> PluginTesseractRequest { + serde_json::from_value(serde_json::json!({ + "image_data": base64::engine::general_purpose::STANDARD.encode(png_image()) + })) + .expect("valid request") +} + +#[test] +fn tesseract_request_rejects_unknown_fields() { + assert!( + serde_json::from_value::(serde_json::json!({ + "image_data": "abc", + "args": ["--arbitrary"] + })) + .is_err() + ); +} + +#[test] +fn missing_tesseract_is_reported_as_unavailable() { + let response = run_with_discovery("vortex-mod-captcha-ocr", request(), || Ok(None)) + .expect("missing binary is not a broker failure"); + + assert_eq!(response.status, "unavailable"); + assert!(response.solution.is_none()); +} + +#[cfg(unix)] +#[test] +fn tesseract_receives_image_on_stdin_and_only_fixed_arguments() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + let binary = temp.path().join("tesseract"); + std::fs::write( + &binary, + "#!/bin/sh\n[ \"$1\" = stdin ] && [ \"$2\" = stdout ] && [ \"$3\" = -l ] && [ \"$4\" = eng ] && [ \"$5\" = --psm ] && [ \"$6\" = 7 ] || exit 9\n/bin/cat >/dev/null\nprintf ' ABC123 \\n'\n", + ) + .expect("write fake tesseract"); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)) + .expect("make executable"); + + let response = run_with_discovery("vortex-mod-captcha-ocr", request(), || { + Ok(Some(PathBuf::from(&binary))) + }) + .expect("run fake tesseract"); + + assert_eq!(response.status, "solved"); + assert_eq!(response.solution.as_deref(), Some("ABC123")); +} + +#[test] +fn tesseract_broker_rejects_other_plugins() { + let error = run_with_discovery("untrusted-plugin", request(), || Ok(None)) + .expect_err("broker must be scoped to the OCR plugin"); + + assert!(error.to_string().contains("not authorized")); +} + +#[cfg(unix)] +#[test] +fn timeout_also_bounds_a_child_that_never_reads_stdin() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + let binary = temp.path().join("tesseract"); + std::fs::write(&binary, "#!/bin/sh\nsleep 60\n").expect("write fake tesseract"); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)) + .expect("make executable"); + let mut image = png_image(); + image.resize(MAX_CAPTCHA_IMAGE_BYTES, 0); + let request = serde_json::from_value(serde_json::json!({ + "image_data": base64::engine::general_purpose::STANDARD.encode(image) + })) + .expect("valid request"); + + let started = Instant::now(); + let error = run_with_discovery_timeout( + "vortex-mod-captcha-ocr", + request, + || Ok(Some(PathBuf::from(&binary))), + Duration::from_millis(100), + ) + .expect_err("blocked stdin must time out"); + + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(3)); +} diff --git a/src-tauri/src/adapters/driven/plugin/ytdlp_broker.rs b/src-tauri/src/adapters/driven/plugin/ytdlp_broker.rs index a4933bd7..443cbbcc 100644 --- a/src-tauri/src/adapters/driven/plugin/ytdlp_broker.rs +++ b/src-tauri/src/adapters/driven/plugin/ytdlp_broker.rs @@ -3,7 +3,7 @@ mod legacy; mod legacy_download; mod output; -mod platform; +pub(crate) mod platform; mod process; mod request; mod selectors; diff --git a/src-tauri/src/adapters/driven/plugin/ytdlp_broker/platform.rs b/src-tauri/src/adapters/driven/plugin/ytdlp_broker/platform.rs index 5a74caf7..5b431aa9 100644 --- a/src-tauri/src/adapters/driven/plugin/ytdlp_broker/platform.rs +++ b/src-tauri/src/adapters/driven/plugin/ytdlp_broker/platform.rs @@ -70,25 +70,36 @@ fn approved_roots() -> Vec { pub(super) fn find_approved_binary( candidates: &[PathBuf], roots: &[PathBuf], +) -> anyhow::Result { + find_approved_named_binary( + candidates, + roots, + if cfg!(windows) { + "yt-dlp.exe" + } else { + "yt-dlp" + }, + ) +} + +pub(crate) fn find_approved_named_binary( + candidates: &[PathBuf], + roots: &[PathBuf], + expected_name: &str, ) -> anyhow::Result { for candidate in candidates { let Ok(canonical) = std::fs::canonicalize(candidate) else { continue; }; - if valid_binary(&canonical, roots)? { + if valid_binary(&canonical, roots, expected_name)? { return Ok(canonical); } } - bail!("yt-dlp not found in approved locations; install it with: pip install yt-dlp") + bail!("approved executable '{expected_name}' was not found") } -fn valid_binary(path: &Path, roots: &[PathBuf]) -> anyhow::Result { - let expected = if cfg!(windows) { - "yt-dlp.exe" - } else { - "yt-dlp" - }; - if path.file_name().and_then(|name| name.to_str()) != Some(expected) { +fn valid_binary(path: &Path, roots: &[PathBuf], expected_name: &str) -> anyhow::Result { + if path.file_name().and_then(|name| name.to_str()) != Some(expected_name) { return Ok(false); } let metadata = std::fs::metadata(path)?; @@ -213,7 +224,7 @@ fn trusted_directory(path: &Path, roots: &[PathBuf]) -> Option { } #[cfg(windows)] -pub(super) fn copy_required_environment(command: &mut Command) { +pub(crate) fn copy_required_environment(command: &mut Command) { for name in ["SystemRoot", "WINDIR"] { if let Some(value) = std::env::var_os(name) { command.env(name, value); @@ -222,4 +233,4 @@ pub(super) fn copy_required_environment(command: &mut Command) { } #[cfg(not(windows))] -pub(super) fn copy_required_environment(_command: &mut Command) {} +pub(crate) fn copy_required_environment(_command: &mut Command) {} diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs index 2a164452..26fecb1b 100644 --- a/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo.rs @@ -13,10 +13,10 @@ use super::util::{block_on, map_db_err}; const CAPTCHA_METADATA_QUERY: &str = "SELECT id, download_id, challenge_type, \ '[redacted]' AS challenge_url, \ - NULL AS image_data, status, solver, attempts, created_at, expires_at, resolved_at, \ + NULL AS image_data, status, solver, attempts, solver_attempts_json, created_at, expires_at, resolved_at, \ duration_ms, failure_reason FROM captcha_log ORDER BY created_at DESC LIMIT 200"; const PENDING_CAPTCHA_METADATA_QUERY: &str = "SELECT id, download_id, challenge_type, \ - '[redacted]' AS challenge_url, NULL AS image_data, status, solver, attempts, created_at, expires_at, \ + '[redacted]' AS challenge_url, NULL AS image_data, status, solver, attempts, solver_attempts_json, created_at, expires_at, \ resolved_at, duration_ms, failure_reason FROM captcha_log WHERE status = ? \ ORDER BY created_at ASC"; @@ -45,6 +45,7 @@ impl CaptchaRepository for SqliteCaptchaRepo { captcha_log::Column::Status, captcha_log::Column::Solver, captcha_log::Column::Attempts, + captcha_log::Column::SolverAttemptsJson, captcha_log::Column::ExpiresAt, captcha_log::Column::ResolvedAt, captcha_log::Column::DurationMs, diff --git a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs index 9d943c47..7f4ed4ec 100644 --- a/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs +++ b/src-tauri/src/adapters/driven/sqlite/captcha_repo_tests.rs @@ -2,7 +2,10 @@ use sea_orm::{ConnectionTrait, Statement}; use super::captcha_repo::SqliteCaptchaRepo; use super::connection::setup_test_db; -use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; +use crate::domain::model::captcha::{ + CaptchaChallenge, CaptchaId, CaptchaSolverAttempt, CaptchaSolverAttemptOutcome, CaptchaStatus, + CaptchaType, +}; use crate::domain::model::download::DownloadId; use crate::domain::ports::driven::CaptchaRepository; @@ -77,6 +80,28 @@ async fn captcha_log_never_has_a_solution_column() { assert!(!names.iter().any(|name| name.contains("solution"))); } +#[tokio::test(flavor = "multi_thread")] +async fn captcha_solver_attempts_survive_a_repository_round_trip() { + let db = setup_test_db().await.expect("test db"); + let repo = SqliteCaptchaRepo::new(db); + let mut item = challenge("captcha-attempts", 43); + item.record_solver_attempt(CaptchaSolverAttempt::new( + "vortex-mod-captcha-ocr", + CaptchaSolverAttemptOutcome::Unavailable, + 1_100, + 25, + )) + .expect("record attempt"); + + repo.save(&item).expect("save challenge with attempt"); + let restored = repo + .find_by_id(item.id()) + .expect("find") + .expect("stored challenge"); + + assert_eq!(restored.solver_attempts(), item.solver_attempts()); +} + #[tokio::test(flavor = "multi_thread")] async fn pending_query_returns_every_challenge_needed_for_recovery() { let db = setup_test_db().await.expect("test db"); diff --git a/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs b/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs index fd398107..6f28554e 100644 --- a/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs +++ b/src-tauri/src/adapters/driven/sqlite/entities/captcha_log.rs @@ -2,7 +2,8 @@ use sea_orm::entity::prelude::*; use crate::domain::error::DomainError; use crate::domain::model::captcha::{ - CaptchaChallenge, CaptchaChallengeRecord, CaptchaId, CaptchaStatus, CaptchaType, + CaptchaChallenge, CaptchaChallengeRecord, CaptchaId, CaptchaSolverAttempt, + CaptchaSolverAttemptOutcome, CaptchaStatus, CaptchaType, }; use crate::domain::model::download::DownloadId; @@ -18,6 +19,7 @@ pub struct Model { pub status: String, pub solver: Option, pub attempts: i32, + pub solver_attempts_json: String, pub created_at: i64, pub expires_at: i64, pub resolved_at: Option, @@ -41,6 +43,7 @@ impl Model { status: self.status.parse::()?, solver: self.solver, attempts: u32::try_from(self.attempts).map_err(|_| invalid_integer("attempts"))?, + solver_attempts: decode_solver_attempts(&self.solver_attempts_json)?, created_at: to_u64(self.created_at, "created_at")?, expires_at: to_u64(self.expires_at, "expires_at")?, resolved_at: self @@ -71,6 +74,7 @@ impl ActiveModel { attempts: Set( i32::try_from(challenge.attempts()).map_err(|_| invalid_integer("attempts"))? ), + solver_attempts_json: Set(encode_solver_attempts(challenge.solver_attempts())?), created_at: Set(to_i64(challenge.created_at(), "created_at")?), expires_at: Set(to_i64(challenge.expires_at(), "expires_at")?), resolved_at: Set(challenge @@ -86,6 +90,48 @@ impl ActiveModel { } } +#[derive(serde::Serialize, serde::Deserialize)] +struct SolverAttemptDto { + solver: String, + outcome: String, + attempted_at: u64, + duration_ms: u64, +} + +fn encode_solver_attempts(attempts: &[CaptchaSolverAttempt]) -> Result { + let attempts: Vec<_> = attempts + .iter() + .map(|attempt| SolverAttemptDto { + solver: attempt.solver().to_string(), + outcome: attempt.outcome().to_string(), + attempted_at: attempt.attempted_at(), + duration_ms: attempt.duration_ms(), + }) + .collect(); + serde_json::to_string(&attempts).map_err(|error| { + DomainError::StorageError(format!("failed to encode CAPTCHA attempts: {error}")) + }) +} + +fn decode_solver_attempts(value: &str) -> Result, DomainError> { + let attempts: Vec = serde_json::from_str(value).map_err(|error| { + DomainError::StorageError(format!( + "captcha_log contains invalid solver attempts: {error}" + )) + })?; + attempts + .into_iter() + .map(|attempt| { + Ok(CaptchaSolverAttempt::new( + attempt.solver, + attempt.outcome.parse::()?, + attempt.attempted_at, + attempt.duration_ms, + )) + }) + .collect() +} + fn to_u64(value: i64, field: &str) -> Result { u64::try_from(value).map_err(|_| invalid_integer(field)) } diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/m20260720_000012_add_captcha_solver_attempts.rs b/src-tauri/src/adapters/driven/sqlite/migrations/m20260720_000012_add_captcha_solver_attempts.rs new file mode 100644 index 00000000..b475dc98 --- /dev/null +++ b/src-tauri/src/adapters/driven/sqlite/migrations/m20260720_000012_add_captcha_solver_attempts.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(CaptchaLog::Table) + .add_column( + ColumnDef::new(CaptchaLog::SolverAttemptsJson) + .text() + .not_null() + .default("[]"), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(CaptchaLog::Table) + .drop_column(CaptchaLog::SolverAttemptsJson) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum CaptchaLog { + Table, + SolverAttemptsJson, +} diff --git a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs index 7bc52989..f7d39fef 100644 --- a/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs +++ b/src-tauri/src/adapters/driven/sqlite/migrations/mod.rs @@ -11,6 +11,7 @@ mod m20260430_000008_add_package_external_id; mod m20260505_000009_add_mirrors; mod m20260716_000010_wire_premium_accounts; mod m20260719_000011_create_captcha_log; +mod m20260720_000012_add_captcha_solver_attempts; pub struct Migrator; @@ -29,6 +30,7 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000009_add_mirrors::Migration), Box::new(m20260716_000010_wire_premium_accounts::Migration), Box::new(m20260719_000011_create_captcha_log::Migration), + Box::new(m20260720_000012_add_captcha_solver_attempts::Migration), ] } } diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index 72c16ef7..9aead358 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -17,20 +17,21 @@ use crate::application::commands::{ AccountPatch, AddAccountCommand, AddDownloadToPackageCommand, CancelDownloadCommand, ChangeDirectoryBulkCommand, ChangeDirectoryBulkOutcome, ChangeDirectoryCommand, ChangeDirectoryFailure, CheckOnlineCommand, ClearDownloadsByStateCommand, ClearHistoryCommand, - CreatePackageCommand, DeleteAccountCommand, DeleteHistoryEntryCommand, DeletePackageCommand, - DisablePluginCommand, EnablePluginCommand, ExportAccountsCommand, ExportAccountsOutcome, - ExportHistoryCommand, ExportHistoryFormat, ImportAccountsCommand, ImportAccountsOutcome, - ImportContainerCommand, ImportContainerOutcome, InstallPluginCommand, - MovePackageToFolderCommand, MoveToBottomCommand, MoveToTopCommand, OpenDownloadFileCommand, - OpenDownloadFolderCommand, PackageMoveOutcome, PackagePatch, PauseAllDownloadsCommand, - PauseDownloadCommand, PurgeHistoryCommand, RedownloadCommand, RedownloadSource, - RemoveDownloadCommand, RemoveDownloadFromPackageCommand, ReorderQueueCommand, + CreatePackageCommand, DeleteAccountCommand, DeleteCaptchaCredentialCommand, + DeleteHistoryEntryCommand, DeletePackageCommand, DisablePluginCommand, EnablePluginCommand, + ExportAccountsCommand, ExportAccountsOutcome, ExportHistoryCommand, ExportHistoryFormat, + ImportAccountsCommand, ImportAccountsOutcome, ImportContainerCommand, ImportContainerOutcome, + InstallPluginCommand, MovePackageToFolderCommand, MoveToBottomCommand, MoveToTopCommand, + OpenDownloadFileCommand, OpenDownloadFolderCommand, PackageMoveOutcome, PackagePatch, + PauseAllDownloadsCommand, PauseDownloadCommand, PurgeHistoryCommand, RedownloadCommand, + RedownloadSource, RemoveDownloadCommand, RemoveDownloadFromPackageCommand, ReorderQueueCommand, ReportBrokenPluginCommand, ResolveLinksCommand, ResolvedLinkDto, ResumeAllDownloadsCommand, - ResumeDownloadCommand, RetryCaptchaCommand, RetryDownloadCommand, SetPackagePasswordCommand, - SetPackagePriorityCommand, SetPriorityCommand, SkipCaptchaCommand, SolveCaptchaCommand, - StartDownloadCommand, TogglePackageAutoExtractCommand, UninstallPluginCommand, - UpdateAccountCommand, UpdateConfigCommand, UpdatePackageCommand, UpdatePluginConfigCommand, - ValidateAccountCommand, ValidationOutcomeDto, VerifyChecksumCommand, VerifyChecksumOutcome, + ResumeDownloadCommand, RetryCaptchaCommand, RetryDownloadCommand, SetCaptchaCredentialCommand, + SetPackagePasswordCommand, SetPackagePriorityCommand, SetPriorityCommand, SkipCaptchaCommand, + SolveCaptchaCommand, StartDownloadCommand, TogglePackageAutoExtractCommand, + UninstallPluginCommand, UpdateAccountCommand, UpdateConfigCommand, UpdatePackageCommand, + UpdatePluginConfigCommand, ValidateAccountCommand, ValidationOutcomeDto, VerifyChecksumCommand, + VerifyChecksumOutcome, }; use crate::application::error::AppError; use crate::application::queries::{ @@ -245,6 +246,44 @@ pub async fn captcha_get_pending( .map_err(|error| error.to_string()) } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptchaCredentialStatusDto { + pub configured: bool, +} + +#[tauri::command] +pub async fn captcha_credential_status( + state: State<'_, AppState>, +) -> Result { + state + .command_bus + .captcha_credential_configured() + .map(|configured| CaptchaCredentialStatusDto { configured }) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_credential_set( + state: State<'_, AppState>, + api_key: String, +) -> Result<(), String> { + state + .command_bus + .handle_set_captcha_credential(SetCaptchaCredentialCommand { api_key }) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn captcha_credential_delete(state: State<'_, AppState>) -> Result<(), String> { + state + .command_bus + .handle_delete_captcha_credential(DeleteCaptchaCredentialCommand) + .await + .map_err(|error| error.to_string()) +} + /// Per-id failure entry surfaced in [`ChangeDirectoryBulkOutcomeDto`]. #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -1238,6 +1277,7 @@ pub struct SettingsDto { // CAPTCHA pub captcha_timeout_seconds: u32, + pub captcha_solver_order: Vec, // History pub history_retention_days: i64, @@ -1304,6 +1344,7 @@ impl From for SettingsDto { 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, + captcha_solver_order: c.captcha_solver_order, history_retention_days: c.history_retention_days, account_selection_strategy: c.account_selection_strategy.to_string(), proxy_type: c.proxy_type, @@ -1355,6 +1396,7 @@ pub struct ConfigPatchDto { // CAPTCHA pub captcha_timeout_seconds: Option, + pub captcha_solver_order: Option>, // History pub history_retention_days: Option, @@ -1421,6 +1463,7 @@ impl TryFrom for ConfigPatch { 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, + captcha_solver_order: d.captcha_solver_order, history_retention_days: d.history_retention_days, account_selection_strategy, proxy_type: d.proxy_type, diff --git a/src-tauri/src/application/commands/captcha.rs b/src-tauri/src/application/commands/captcha.rs index 7625725d..20b34ad0 100644 --- a/src-tauri/src/application/commands/captcha.rs +++ b/src-tauri/src/application/commands/captcha.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -14,11 +14,12 @@ use crate::application::error::AppError; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; use crate::domain::model::captcha::{ - CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType, MAX_CAPTCHA_SOLUTION_BYTES, + CaptchaChallenge, CaptchaId, CaptchaSolution, CaptchaSolverAttempt, + CaptchaSolverAttemptOutcome, CaptchaStatus, CaptchaType, }; use crate::domain::ports::driven::{ - CaptchaRepository, CaptchaSolver, CaptchaSolverOutcome, Clock, ConfigStore, DownloadRepository, - EventBus, + CaptchaInteraction, CaptchaRepository, CaptchaSolver, CaptchaSolverOutcome, Clock, ConfigStore, + DownloadRepository, EventBus, }; use crate::domain::ports::driving::CommandHandler; @@ -40,10 +41,10 @@ impl CaptchaSolver for ManualCaptchaSolver { ) { return Ok(CaptchaSolverOutcome::Unavailable); } - if solution.trim().is_empty() || solution.len() > MAX_CAPTCHA_SOLUTION_BYTES { - return Ok(CaptchaSolverOutcome::Rejected); + match CaptchaSolution::try_new(solution) { + Ok(solution) => Ok(CaptchaSolverOutcome::Solved(solution)), + Err(_) => Ok(CaptchaSolverOutcome::Rejected), } - Ok(CaptchaSolverOutcome::Solved) } } @@ -55,7 +56,9 @@ pub struct CaptchaCommandHandler { config: Arc, clock: Arc, solvers: Arc>>, + interaction: Option>, timers: Arc>>, + solver_runs: Arc>>, mutation_lock: Arc>, } @@ -75,11 +78,18 @@ impl CaptchaCommandHandler { config, clock, solvers: Arc::new(solvers), + interaction: None, timers: Arc::new(Mutex::new(HashMap::new())), + solver_runs: Arc::new(Mutex::new(HashMap::new())), mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } + pub fn with_interaction(mut self, interaction: Arc) -> Self { + self.interaction = Some(interaction); + self + } + pub fn start_listening(self: &Arc) { let handler = Arc::clone(self); self.events.subscribe(Box::new(move |event| { @@ -110,6 +120,7 @@ impl CaptchaCommandHandler { pub async fn restore_pending(&self) -> Result<(), DomainError> { for challenge in self.captchas.list_pending()? { self.schedule_timeout(&challenge); + self.start_solver_cascade(challenge.id().clone()); } Ok(()) } @@ -166,33 +177,148 @@ impl CaptchaCommandHandler { } } + fn start_solver_cascade(&self, id: CaptchaId) { + let token = CancellationToken::new(); + if let Some(previous) = solver_run_map(&self.solver_runs).insert(id.clone(), token.clone()) + { + previous.cancel(); + } + let handler = self.clone(); + tokio::spawn(async move { + if let Err(error) = handler.run_solver_cascade(id, token).await { + tracing::warn!(error = %error, "automatic CAPTCHA solver cascade stopped"); + } + }); + } + + fn cancel_solver_cascade(&self, id: &CaptchaId) { + if let Some(token) = solver_run_map(&self.solver_runs).remove(id) { + token.cancel(); + } + } + + async fn run_solver_cascade( + &self, + id: CaptchaId, + token: CancellationToken, + ) -> Result<(), DomainError> { + let order = self.config.get_config()?.captcha_solver_order; + for solver_name in order { + if token.is_cancelled() { + return Ok(()); + } + let Some(solver) = self + .solvers + .iter() + .find(|solver| solver.name() == solver_name) + .cloned() + else { + continue; + }; + let challenge = self.find(&id)?; + if challenge.status() != CaptchaStatus::Pending + || challenge.is_expired(self.clock.now_unix_ms()) + { + return Ok(()); + } + + let attempted_at = self.clock.now_unix_ms(); + let started = Instant::now(); + let solver_challenge = challenge.clone(); + let result = + tokio::task::spawn_blocking(move || solver.solve(&solver_challenge, "")).await; + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + let (outcome, attempt_outcome) = match result { + Ok(Ok(outcome)) => { + let attempt_outcome = match &outcome { + CaptchaSolverOutcome::Solved(_) => CaptchaSolverAttemptOutcome::Solved, + CaptchaSolverOutcome::Unavailable => { + CaptchaSolverAttemptOutcome::Unavailable + } + CaptchaSolverOutcome::Rejected => CaptchaSolverAttemptOutcome::Rejected, + CaptchaSolverOutcome::InteractionRequired => { + CaptchaSolverAttemptOutcome::InteractionRequired + } + }; + (Some(outcome), attempt_outcome) + } + Ok(Err(DomainError::NotFound(_))) => { + (None, CaptchaSolverAttemptOutcome::Unavailable) + } + Ok(Err(_)) | Err(_) => (None, CaptchaSolverAttemptOutcome::Failed), + }; + + let _guard = self.mutation_lock.lock().await; + let mut challenge = self.find(&id)?; + let now = self.clock.now_unix_ms(); + if token.is_cancelled() + || challenge.status() != CaptchaStatus::Pending + || challenge.is_expired(now) + { + return Ok(()); + } + challenge.record_solver_attempt(CaptchaSolverAttempt::new( + solver_name.clone(), + attempt_outcome, + attempted_at, + duration_ms, + ))?; + match outcome { + Some(CaptchaSolverOutcome::Solved(_solution)) => { + return self.finish_as_solved_locked(challenge, now, &solver_name); + } + Some(CaptchaSolverOutcome::InteractionRequired) => { + self.captchas.save(&challenge)?; + drop(_guard); + if let Some(interaction) = &self.interaction { + interaction.request(&challenge)?; + } + return Ok(()); + } + Some(CaptchaSolverOutcome::Unavailable | CaptchaSolverOutcome::Rejected) | None => { + self.captchas.save(&challenge)? + } + } + } + Ok(()) + } + fn find(&self, id: &CaptchaId) -> Result { self.captchas .find_by_id(id)? .ok_or_else(|| DomainError::NotFound(format!("CAPTCHA {id}"))) } - fn select_solver( + fn finish_as_solved_locked( &self, - challenge: &CaptchaChallenge, - solution: &str, - ) -> Result { - let mut last_error = None; - for solver in self.solvers.iter() { - match solver.solve(challenge, solution) { - Ok(CaptchaSolverOutcome::Solved) => return Ok(solver.name().to_string()), - Ok(CaptchaSolverOutcome::Rejected) => { - return Err(DomainError::ValidationError( - "CAPTCHA solution was rejected".into(), - )); - } - Ok(CaptchaSolverOutcome::Unavailable) => {} - Err(error) => last_error = Some(error), + mut challenge: CaptchaChallenge, + now: u64, + solver: &str, + ) -> Result<(), DomainError> { + let mut download = self + .downloads + .find_by_id(challenge.download_id())? + .ok_or_else(|| DomainError::NotFound("CAPTCHA download".into()))?; + let queued_event = download.queue_after_wait()?; + let pending_challenge = challenge.clone(); + challenge.solve(now, solver)?; + self.captchas.save(&challenge)?; + if let Err(error) = self.downloads.save(&download) { + if let Err(rollback_error) = self.captchas.save(&pending_challenge) { + tracing::error!(error = %rollback_error, "failed to roll back CAPTCHA solve"); } + return Err(error); } - Err(last_error.unwrap_or_else(|| { - DomainError::ValidationError("No solver supports this CAPTCHA type".into()) - })) + self.cancel_timer(challenge.id()); + self.cancel_solver_cascade(challenge.id()); + self.events.publish(queued_event); + self.events.publish(DomainEvent::CaptchaSolved { + challenge_id: challenge.id().clone(), + download_id: challenge.download_id(), + solver: solver.to_string(), + duration_ms: challenge.duration_ms().unwrap_or_default(), + }); + Ok(()) } } @@ -208,18 +334,25 @@ impl CommandHandler for CaptchaCommandHandler { error: "CAPTCHA challenge could not be queued".into(), }); } - result + let (id, created) = result?; + if created { + self.start_solver_cascade(id.clone()); + } + Ok(id) } } impl CaptchaCommandHandler { - async fn enqueue(&self, command: EnqueueCaptchaCommand) -> Result { + async fn enqueue( + &self, + command: EnqueueCaptchaCommand, + ) -> Result<(CaptchaId, bool), DomainError> { let _guard = self.mutation_lock.lock().await; if let Some(existing) = self .captchas .find_pending_by_download(command.download_id)? { - return Ok(existing.id().clone()); + return Ok((existing.id().clone(), false)); } let now = self.clock.now_unix_ms(); let expires_at = self.timeout_deadline(now)?; @@ -253,7 +386,7 @@ impl CaptchaCommandHandler { download_id: command.download_id, }); self.schedule_timeout(&challenge); - Ok(challenge.id().clone()) + Ok((challenge.id().clone(), true)) } } @@ -269,30 +402,30 @@ impl CommandHandler for CaptchaCommandHandler { "CAPTCHA challenge has expired".into(), )); } - let solver = self.select_solver(&challenge, &command.solution)?; - let mut download = self - .downloads - .find_by_id(challenge.download_id())? - .ok_or_else(|| DomainError::NotFound("CAPTCHA download".into()))?; - let queued_event = download.queue_after_wait()?; - let pending_challenge = challenge.clone(); - challenge.solve(now, &solver)?; - self.captchas.save(&challenge)?; - if let Err(error) = self.downloads.save(&download) { - if let Err(rollback_error) = self.captchas.save(&pending_challenge) { - tracing::error!(error = %rollback_error, "failed to roll back CAPTCHA solve"); + let started = Instant::now(); + let outcome = ManualCaptchaSolver.solve(&challenge, &command.solution)?; + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + let attempt_outcome = match &outcome { + CaptchaSolverOutcome::Solved(_) => CaptchaSolverAttemptOutcome::Solved, + CaptchaSolverOutcome::Unavailable => CaptchaSolverAttemptOutcome::Unavailable, + CaptchaSolverOutcome::Rejected => CaptchaSolverAttemptOutcome::Rejected, + CaptchaSolverOutcome::InteractionRequired => { + CaptchaSolverAttemptOutcome::InteractionRequired } - return Err(error); + }; + challenge.record_solver_attempt(CaptchaSolverAttempt::new( + "manual", + attempt_outcome, + now, + duration_ms, + ))?; + if matches!(outcome, CaptchaSolverOutcome::Solved(_)) { + return self.finish_as_solved_locked(challenge, now, "manual"); } - self.cancel_timer(challenge.id()); - self.events.publish(queued_event); - self.events.publish(DomainEvent::CaptchaSolved { - challenge_id: challenge.id().clone(), - download_id: challenge.download_id(), - solver, - duration_ms: challenge.duration_ms().unwrap_or_default(), - }); - Ok(()) + self.captchas.save(&challenge)?; + Err(DomainError::ValidationError( + "CAPTCHA solution was rejected".into(), + )) } } @@ -407,6 +540,7 @@ impl CaptchaCommandHandler { return Err(error); } self.cancel_timer(challenge.id()); + self.cancel_solver_cascade(challenge.id()); let event = if timed_out { DomainEvent::CaptchaTimedOut { challenge_id: challenge.id().clone(), @@ -441,6 +575,8 @@ impl CommandHandler for CaptchaCommandHandler { challenge.retry(now, self.timeout_deadline(now)?)?; self.captchas.save(&challenge)?; self.schedule_timeout(&challenge); + drop(_guard); + self.start_solver_cascade(challenge.id().clone()); return Ok(()); } if challenge.status() == CaptchaStatus::Solved { @@ -515,3 +651,12 @@ fn timer_map( Err(poisoned) => poisoned.into_inner(), } } + +fn solver_run_map( + runs: &Mutex>, +) -> std::sync::MutexGuard<'_, HashMap> { + match runs.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} diff --git a/src-tauri/src/application/commands/captcha_credential.rs b/src-tauri/src/application/commands/captcha_credential.rs new file mode 100644 index 00000000..29d9a666 --- /dev/null +++ b/src-tauri/src/application/commands/captcha_credential.rs @@ -0,0 +1,110 @@ +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; +use crate::domain::model::config::CAPTCHA_SOLVER_ANTICAPTCHA; +use crate::domain::model::credential::Credential; + +use super::{DeleteCaptchaCredentialCommand, SetCaptchaCredentialCommand}; + +const MAX_ANTICAPTCHA_API_KEY_BYTES: usize = 1_024; + +impl CommandBus { + pub async fn handle_set_captcha_credential( + &self, + command: SetCaptchaCredentialCommand, + ) -> Result<(), AppError> { + let api_key = command.api_key.trim(); + if api_key.is_empty() || api_key.len() > MAX_ANTICAPTCHA_API_KEY_BYTES { + return Err(AppError::Validation( + "AntiCaptcha API key is empty or exceeds safety limits".into(), + )); + } + self.credential_store().store( + CAPTCHA_SOLVER_ANTICAPTCHA, + &Credential::new("api-key", api_key), + )?; + Ok(()) + } + + pub async fn handle_delete_captcha_credential( + &self, + _command: DeleteCaptchaCredentialCommand, + ) -> Result<(), AppError> { + self.credential_store().delete(CAPTCHA_SOLVER_ANTICAPTCHA)?; + Ok(()) + } + + pub fn captcha_credential_configured(&self) -> Result { + Ok(self + .credential_store() + .get(CAPTCHA_SOLVER_ANTICAPTCHA)? + .is_some()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::application::commands::tests_support::{ + InMemoryCredentialStore, build_credential_bus, + }; + use crate::application::commands::{ + DeleteCaptchaCredentialCommand, SetCaptchaCredentialCommand, + }; + use crate::domain::model::config::CAPTCHA_SOLVER_ANTICAPTCHA; + use crate::domain::ports::driven::CredentialStore; + + #[tokio::test] + async fn anti_captcha_api_key_is_stored_only_in_the_scoped_credential_store() { + let credentials = Arc::new(InMemoryCredentialStore::new()); + let bus = build_credential_bus(credentials.clone()); + + bus.handle_set_captcha_credential(SetCaptchaCredentialCommand { + api_key: "secret-api-key".into(), + }) + .await + .expect("store credential"); + + assert!(bus.captcha_credential_configured().expect("status")); + let stored = credentials + .get(CAPTCHA_SOLVER_ANTICAPTCHA) + .expect("read credential") + .expect("configured credential"); + assert_eq!(stored.username(), "api-key"); + assert_eq!(stored.password(), "secret-api-key"); + } + + #[tokio::test] + async fn anti_captcha_api_key_can_be_deleted_and_is_redacted_from_debug() { + let credentials = Arc::new(InMemoryCredentialStore::new()); + let bus = build_credential_bus(credentials); + let command = SetCaptchaCredentialCommand { + api_key: "never-log-me".into(), + }; + assert!(!format!("{command:?}").contains("never-log-me")); + bus.handle_set_captcha_credential(command) + .await + .expect("store credential"); + + bus.handle_delete_captcha_credential(DeleteCaptchaCredentialCommand) + .await + .expect("delete credential"); + + assert!(!bus.captcha_credential_configured().expect("status")); + } + + #[tokio::test] + async fn empty_anti_captcha_api_key_is_rejected_before_keyring_access() { + let credentials = Arc::new(InMemoryCredentialStore::new()); + let bus = build_credential_bus(credentials.clone()); + + let result = bus + .handle_set_captcha_credential(SetCaptchaCredentialCommand { + api_key: " ".into(), + }) + .await; + + assert!(result.is_err()); + assert_eq!(credentials.entry_count(), 0); + } +} diff --git a/src-tauri/src/application/commands/captcha_tests.rs b/src-tauri/src/application/commands/captcha_tests.rs index 17018176..34c6e86f 100644 --- a/src-tauri/src/application/commands/captcha_tests.rs +++ b/src-tauri/src/application/commands/captcha_tests.rs @@ -10,10 +10,17 @@ use crate::application::commands::captcha::{CaptchaCommandHandler, ManualCaptcha use crate::application::commands::tests_support::{CapturingEventBus, InMemoryDownloadRepo}; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; -use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId, CaptchaStatus, CaptchaType}; -use crate::domain::model::config::{AppConfig, ConfigPatch}; +use crate::domain::model::captcha::{ + CaptchaChallenge, CaptchaId, CaptchaSolution, CaptchaSolverAttemptOutcome, CaptchaStatus, + CaptchaType, +}; +use crate::domain::model::config::{ + AppConfig, CAPTCHA_SOLVER_ANTICAPTCHA, CAPTCHA_SOLVER_BROWSER, CAPTCHA_SOLVER_OCR, ConfigPatch, +}; use crate::domain::model::download::{Download, DownloadId, DownloadState, Url}; -use crate::domain::ports::driven::{CaptchaRepository, Clock, ConfigStore, DownloadRepository}; +use crate::domain::ports::driven::{ + CaptchaInteraction, CaptchaRepository, Clock, ConfigStore, DownloadRepository, +}; use crate::domain::ports::driven::{CaptchaSolver, CaptchaSolverOutcome}; use crate::domain::ports::driving::CommandHandler; @@ -81,14 +88,12 @@ impl Clock for FixedClock { } } -struct FixedConfig; +#[derive(Default)] +struct FixedConfig(AppConfig); impl ConfigStore for FixedConfig { fn get_config(&self) -> Result { - Ok(AppConfig { - captcha_timeout_seconds: 120, - ..AppConfig::default() - }) + Ok(self.0.clone()) } fn update_config(&self, _: ConfigPatch) -> Result { @@ -109,6 +114,13 @@ fn fixture() -> Fixture { } fn fixture_with_solvers(solvers: Vec>) -> Fixture { + fixture_with_config_and_solvers(AppConfig::default(), solvers) +} + +fn fixture_with_config_and_solvers( + config: AppConfig, + solvers: Vec>, +) -> Fixture { let captchas = Arc::new(MemoryCaptchaRepo::new()); let downloads = Arc::new(InMemoryDownloadRepo::new()); let events = Arc::new(CapturingEventBus::new()); @@ -125,7 +137,7 @@ fn fixture_with_solvers(solvers: Vec>) -> Fixture { captchas.clone(), downloads.clone(), events.clone(), - Arc::new(FixedConfig), + Arc::new(FixedConfig(config)), clock.clone(), solvers, ); @@ -148,6 +160,54 @@ impl CaptchaSolver for FailingCaptchaSolver { } } +struct RecordedCaptchaSolver { + name: &'static str, + outcome: CaptchaSolverOutcome, + calls: Arc>>, +} + +#[derive(Default)] +struct RecordingCaptchaInteraction(Mutex>); + +impl CaptchaInteraction for RecordingCaptchaInteraction { + fn request(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + self.0.lock().unwrap().push(challenge.id().clone()); + Ok(()) + } +} + +impl CaptchaSolver for RecordedCaptchaSolver { + fn name(&self) -> &str { + self.name + } + + fn solve( + &self, + _challenge: &CaptchaChallenge, + _solution: &str, + ) -> Result { + self.calls.lock().unwrap().push(self.name.to_string()); + Ok(self.outcome.clone()) + } +} + +async fn wait_for_solver_attempts(captchas: &MemoryCaptchaRepo, id: &CaptchaId, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if captchas + .find_by_id(id) + .unwrap() + .is_some_and(|challenge| challenge.solver_attempts().len() >= expected) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("automatic CAPTCHA cascade should finish"); +} + fn png_image() -> Vec { b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec() } @@ -186,6 +246,145 @@ async fn enqueue_parks_download_and_emits_pending() { assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaPending { challenge_id, download_id } if challenge_id == &id && *download_id == DownloadId(42)))); } +#[tokio::test] +async fn enqueue_runs_enabled_solvers_in_order_until_one_succeeds() { + let calls = Arc::new(Mutex::new(Vec::new())); + let solvers: Vec> = vec![ + Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_OCR, + outcome: CaptchaSolverOutcome::Unavailable, + calls: calls.clone(), + }), + Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_ANTICAPTCHA, + outcome: CaptchaSolverOutcome::Solved( + CaptchaSolution::try_new("ephemeral-answer").unwrap(), + ), + calls: calls.clone(), + }), + Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_BROWSER, + outcome: CaptchaSolverOutcome::InteractionRequired, + calls: calls.clone(), + }), + ]; + let (handler, captchas, downloads, _, _) = + fixture_with_config_and_solvers(AppConfig::default(), solvers); + + let id = enqueue(&handler).await; + wait_for_solver_attempts(&captchas, &id, 2).await; + + assert_eq!( + calls.lock().unwrap().as_slice(), + [CAPTCHA_SOLVER_OCR, CAPTCHA_SOLVER_ANTICAPTCHA] + ); + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Solved); + assert_eq!(stored.solver(), Some(CAPTCHA_SOLVER_ANTICAPTCHA)); + assert_eq!(stored.solver_attempts().len(), 2); + assert_eq!( + stored.solver_attempts()[0].outcome(), + CaptchaSolverAttemptOutcome::Unavailable + ); + assert_eq!( + stored.solver_attempts()[1].outcome(), + CaptchaSolverAttemptOutcome::Solved + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Queued + ); +} + +#[tokio::test] +async fn automatic_failures_fall_through_and_are_logged_per_solver() { + let calls = Arc::new(Mutex::new(Vec::new())); + let solvers: Vec> = vec![ + Arc::new(FailingCaptchaSolver), + Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_ANTICAPTCHA, + outcome: CaptchaSolverOutcome::Rejected, + calls: calls.clone(), + }), + Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_BROWSER, + outcome: CaptchaSolverOutcome::InteractionRequired, + calls: calls.clone(), + }), + ]; + let config = AppConfig { + captcha_solver_order: vec![ + "failing".into(), + CAPTCHA_SOLVER_ANTICAPTCHA.into(), + CAPTCHA_SOLVER_BROWSER.into(), + ], + ..AppConfig::default() + }; + let (handler, captchas, downloads, _, _) = fixture_with_config_and_solvers(config, solvers); + + let id = enqueue(&handler).await; + wait_for_solver_attempts(&captchas, &id, 3).await; + + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Pending); + assert_eq!( + stored + .solver_attempts() + .iter() + .map(|attempt| attempt.outcome()) + .collect::>(), + vec![ + CaptchaSolverAttemptOutcome::Failed, + CaptchaSolverAttemptOutcome::Rejected, + CaptchaSolverAttemptOutcome::InteractionRequired, + ] + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Waiting + ); +} + +#[tokio::test] +async fn browser_fallback_requests_a_human_interaction_window() { + let calls = Arc::new(Mutex::new(Vec::new())); + let interaction = Arc::new(RecordingCaptchaInteraction::default()); + let config = AppConfig { + captcha_solver_order: vec![CAPTCHA_SOLVER_BROWSER.into()], + ..AppConfig::default() + }; + let (handler, captchas, _, _, _) = fixture_with_config_and_solvers( + config, + vec![Arc::new(RecordedCaptchaSolver { + name: CAPTCHA_SOLVER_BROWSER, + outcome: CaptchaSolverOutcome::InteractionRequired, + calls, + })], + ); + let handler = handler.with_interaction(interaction.clone()); + + let id = enqueue(&handler).await; + wait_for_solver_attempts(&captchas, &id, 1).await; + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if interaction.0.lock().unwrap().as_slice() == [id.clone()] { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("browser interaction should be requested"); +} + #[tokio::test] async fn enqueue_failure_restores_the_download_and_emits_a_safe_failure() { let (handler, captchas, downloads, events, _) = fixture(); @@ -250,6 +449,31 @@ async fn manual_solve_logs_metadata_and_requeues_without_persisting_answer() { assert!(events.snapshot().iter().any(|event| matches!(event, DomainEvent::CaptchaSolved { challenge_id, .. } if challenge_id == &id))); } +#[tokio::test] +async fn rejected_manual_solution_is_logged_without_leaving_the_pending_state() { + let (handler, captchas, _, _, _) = fixture(); + let id = enqueue(&handler).await; + + let result = >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: " ".into(), + }, + ) + .await; + + assert!(matches!(result, Err(DomainError::ValidationError(_)))); + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Pending); + assert_eq!(stored.solver_attempts().len(), 1); + assert_eq!(stored.solver_attempts()[0].solver(), "manual"); + assert_eq!( + stored.solver_attempts()[0].outcome(), + CaptchaSolverAttemptOutcome::Rejected + ); +} + #[tokio::test] async fn solve_falls_through_when_an_earlier_solver_errors() { let (handler, captchas, _, _, _) = fixture_with_solvers(vec![ diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index eca64d9c..55882582 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -12,6 +12,7 @@ mod add_account; mod add_download_to_package; mod cancel_download; pub mod captcha; +mod captcha_credential; mod change_directory; mod check_online; mod clear_downloads_by_state; @@ -161,6 +162,26 @@ pub struct TimeoutCaptchaCommand { } impl Command for TimeoutCaptchaCommand {} +pub struct SetCaptchaCredentialCommand { + pub api_key: String, +} + +impl std::fmt::Debug for SetCaptchaCredentialCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SetCaptchaCredentialCommand") + .field("api_key", &"") + .finish() + } +} + +impl Command for SetCaptchaCredentialCommand {} + +#[derive(Debug)] +pub struct DeleteCaptchaCredentialCommand; + +impl Command for DeleteCaptchaCredentialCommand {} + #[derive(Debug)] pub struct PauseAllDownloadsCommand; impl Command for PauseAllDownloadsCommand {} diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index 742808cc..cb1bc2af 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -901,6 +901,23 @@ pub(crate) fn build_account_bus_with_plugin_loader( bus } +pub(crate) fn build_credential_bus(credential_store: Arc) -> CommandBus { + CommandBus::new( + Arc::new(StubDownloadRepo), + Arc::new(StubDownloadEngine), + Arc::new(CapturingEventBus::new()), + Arc::new(StubFileStorage), + Arc::new(StubHttpClient), + Arc::new(StubPluginLoader), + Arc::new(StubConfigStore), + credential_store, + Arc::new(StubClipboardObserver), + Arc::new(StubArchiveExtractor), + Arc::new(NoopHistoryRepo), + None, + ) +} + /// Build a [`CommandBus`] wired with the package ports needed by the /// package-command handlers. The download write repo is supplied so /// `set_priority` and `move_to_folder` can read/save member downloads. diff --git a/src-tauri/src/application/read_models/captcha_view.rs b/src-tauri/src/application/read_models/captcha_view.rs index aa131276..06a4d71f 100644 --- a/src-tauri/src/application/read_models/captcha_view.rs +++ b/src-tauri/src/application/read_models/captcha_view.rs @@ -15,6 +15,7 @@ pub struct CaptchaViewDto { pub status: String, pub solver: Option, pub attempts: u32, + pub solver_attempts: Vec, pub created_at: u64, pub expires_at: u64, pub resolved_at: Option, @@ -22,6 +23,15 @@ pub struct CaptchaViewDto { pub failure_reason: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptchaSolverAttemptDto { + pub solver: String, + pub outcome: String, + pub attempted_at: u64, + pub duration_ms: u64, +} + impl From for CaptchaViewDto { fn from(challenge: CaptchaChallenge) -> Self { Self::build(challenge, true) @@ -50,6 +60,16 @@ impl CaptchaViewDto { status: challenge.status().to_string(), solver: challenge.solver().map(str::to_string), attempts: challenge.attempts(), + solver_attempts: challenge + .solver_attempts() + .iter() + .map(|attempt| CaptchaSolverAttemptDto { + solver: attempt.solver().to_string(), + outcome: attempt.outcome().to_string(), + attempted_at: attempt.attempted_at(), + duration_ms: attempt.duration_ms(), + }) + .collect(), created_at: challenge.created_at(), expires_at: challenge.expires_at(), resolved_at: challenge.resolved_at(), @@ -62,7 +82,9 @@ impl CaptchaViewDto { #[cfg(test)] mod tests { use super::*; - use crate::domain::model::captcha::{CaptchaId, CaptchaType}; + use crate::domain::model::captcha::{ + CaptchaId, CaptchaSolverAttempt, CaptchaSolverAttemptOutcome, CaptchaType, + }; use crate::domain::model::download::DownloadId; fn challenge() -> CaptchaChallenge { @@ -90,4 +112,27 @@ mod tests { assert_eq!(payload["imageData"], "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"); assert_eq!(detail.image_mime_type.as_deref(), Some("image/png")); } + + #[test] + fn solver_attempt_metadata_is_exposed_without_any_solution() { + let mut challenge = challenge(); + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + "vortex-mod-captcha-ocr", + CaptchaSolverAttemptOutcome::Unavailable, + 1_100, + 25, + )) + .expect("attempt"); + + let payload = serde_json::to_value(CaptchaViewDto::metadata(challenge)).unwrap(); + + assert_eq!( + payload["solverAttempts"][0]["solver"], + "vortex-mod-captcha-ocr" + ); + assert_eq!(payload["solverAttempts"][0]["outcome"], "unavailable"); + assert_eq!(payload["solverAttempts"][0]["durationMs"], 25); + assert!(payload.to_string().find("solution").is_none()); + } } diff --git a/src-tauri/src/domain/model/captcha.rs b/src-tauri/src/domain/model/captcha.rs index 75163309..ae4c4ca0 100644 --- a/src-tauri/src/domain/model/captcha.rs +++ b/src-tauri/src/domain/model/captcha.rs @@ -9,6 +9,7 @@ pub const MAX_CAPTCHA_SOLUTION_BYTES: usize = 4 * 1024; pub const MAX_CAPTCHA_ID_BYTES: usize = 128; pub const MAX_CAPTCHA_IMAGE_PIXELS: u64 = 16_000_000; const MAX_CAPTCHA_URL_BYTES: usize = 8 * 1024; +const MAX_CAPTCHA_SOLVER_NAME_BYTES: usize = 128; const REDACTED_CAPTCHA_URL: &str = "[redacted]"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -38,6 +39,32 @@ impl fmt::Display for CaptchaId { } } +#[derive(Clone, PartialEq, Eq)] +pub struct CaptchaSolution(String); + +impl CaptchaSolution { + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() || value.len() > MAX_CAPTCHA_SOLUTION_BYTES { + return Err(validation("CAPTCHA solution is invalid")); + } + Ok(Self(value)) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for CaptchaSolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CaptchaSolution") + .field(&"") + .finish() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptchaType { Image, @@ -112,6 +139,82 @@ impl FromStr for CaptchaStatus { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptchaSolverAttemptOutcome { + Solved, + Unavailable, + Rejected, + Failed, + InteractionRequired, +} + +impl fmt::Display for CaptchaSolverAttemptOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Solved => "solved", + Self::Unavailable => "unavailable", + Self::Rejected => "rejected", + Self::Failed => "failed", + Self::InteractionRequired => "interaction_required", + }) + } +} + +impl FromStr for CaptchaSolverAttemptOutcome { + type Err = DomainError; + + fn from_str(value: &str) -> Result { + match value { + "solved" => Ok(Self::Solved), + "unavailable" => Ok(Self::Unavailable), + "rejected" => Ok(Self::Rejected), + "failed" => Ok(Self::Failed), + "interaction_required" => Ok(Self::InteractionRequired), + _ => Err(validation("unknown CAPTCHA solver attempt outcome")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptchaSolverAttempt { + solver: String, + outcome: CaptchaSolverAttemptOutcome, + attempted_at: u64, + duration_ms: u64, +} + +impl CaptchaSolverAttempt { + pub fn new( + solver: impl Into, + outcome: CaptchaSolverAttemptOutcome, + attempted_at: u64, + duration_ms: u64, + ) -> Self { + Self { + solver: solver.into(), + outcome, + attempted_at, + duration_ms, + } + } + + pub fn solver(&self) -> &str { + &self.solver + } + + pub fn outcome(&self) -> CaptchaSolverAttemptOutcome { + self.outcome + } + + pub fn attempted_at(&self) -> u64 { + self.attempted_at + } + + pub fn duration_ms(&self) -> u64 { + self.duration_ms + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CaptchaChallenge { id: CaptchaId, @@ -122,6 +225,7 @@ pub struct CaptchaChallenge { status: CaptchaStatus, solver: Option, attempts: u32, + solver_attempts: Vec, created_at: u64, expires_at: u64, resolved_at: Option, @@ -138,6 +242,7 @@ pub struct CaptchaChallengeRecord { pub status: CaptchaStatus, pub solver: Option, pub attempts: u32, + pub solver_attempts: Vec, pub created_at: u64, pub expires_at: u64, pub resolved_at: Option, @@ -172,6 +277,7 @@ impl CaptchaChallenge { status: CaptchaStatus::Pending, solver: None, attempts: 0, + solver_attempts: Vec::new(), created_at, expires_at, resolved_at: None, @@ -192,6 +298,9 @@ impl CaptchaChallenge { if let Some(image) = record.image_data { challenge = challenge.with_image_data(image)?; } + for attempt in record.solver_attempts { + challenge.record_solver_attempt(attempt)?; + } challenge.status = record.status; challenge.solver = record.solver; challenge.attempts = record.attempts; @@ -254,6 +363,20 @@ impl CaptchaChallenge { Ok(()) } + pub fn record_solver_attempt( + &mut self, + attempt: CaptchaSolverAttempt, + ) -> Result<(), DomainError> { + self.ensure_pending()?; + if attempt.solver().trim().is_empty() + || attempt.solver().len() > MAX_CAPTCHA_SOLVER_NAME_BYTES + { + return Err(validation("CAPTCHA solver name is invalid")); + } + self.solver_attempts.push(attempt); + Ok(()) + } + fn ensure_pending(&self) -> Result<(), DomainError> { if self.status != CaptchaStatus::Pending { return Err(validation("CAPTCHA challenge is no longer pending")); @@ -300,6 +423,10 @@ impl CaptchaChallenge { self.attempts } + pub fn solver_attempts(&self) -> &[CaptchaSolverAttempt] { + &self.solver_attempts + } + pub fn created_at(&self) -> u64 { self.created_at } @@ -472,6 +599,84 @@ mod tests { assert_eq!(c.duration_ms(), Some(3_000)); } + #[test] + fn captcha_solution_validates_and_redacts_debug_output() { + let solution = CaptchaSolution::try_new("secret-answer").expect("valid solution"); + + assert_eq!(solution.expose(), "secret-answer"); + assert_eq!(format!("{solution:?}"), "CaptchaSolution(\"\")"); + assert!(CaptchaSolution::try_new(" ").is_err()); + assert!(CaptchaSolution::try_new("x".repeat(MAX_CAPTCHA_SOLUTION_BYTES + 1)).is_err()); + } + + #[test] + fn solver_attempts_are_ordered_and_reconstructed_without_solutions() { + let mut challenge = make_challenge(); + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + "vortex-mod-captcha-ocr", + CaptchaSolverAttemptOutcome::Unavailable, + 1_100, + 25, + )) + .expect("record OCR attempt"); + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + "vortex-mod-captcha-anticaptcha", + CaptchaSolverAttemptOutcome::Solved, + 1_200, + 75, + )) + .expect("record service attempt"); + + assert_eq!(challenge.solver_attempts().len(), 2); + assert_eq!( + challenge.solver_attempts()[0].solver(), + "vortex-mod-captcha-ocr" + ); + assert_eq!( + challenge.solver_attempts()[1].outcome(), + CaptchaSolverAttemptOutcome::Solved + ); + + let record = CaptchaChallengeRecord { + id: challenge.id().clone(), + download_id: challenge.download_id(), + challenge_type: challenge.challenge_type(), + url: challenge.url().to_string(), + image_data: None, + status: challenge.status(), + solver: None, + attempts: challenge.attempts(), + solver_attempts: challenge.solver_attempts().to_vec(), + created_at: challenge.created_at(), + expires_at: challenge.expires_at(), + resolved_at: None, + duration_ms: None, + failure_reason: None, + }; + let restored = CaptchaChallenge::reconstruct(record).expect("reconstruct challenge"); + + assert_eq!(restored.solver_attempts(), challenge.solver_attempts()); + } + + #[test] + fn solver_attempt_rejects_blank_names() { + let mut challenge = make_challenge(); + + assert!( + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + " ", + CaptchaSolverAttemptOutcome::Failed, + 1_100, + 10, + )) + .is_err() + ); + assert!(challenge.solver_attempts().is_empty()); + } + #[test] fn skip_and_timeout_are_explicit_terminal_states() { let mut c = make_challenge(); diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index 10ae04fc..b04f1efa 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -47,6 +47,8 @@ pub struct AppConfig { // ── CAPTCHA ──────────────────────────────────────────────────── /// Manual challenge deadline. Expiry applies the safe default: skip. pub captcha_timeout_seconds: u32, + /// Enabled automatic CAPTCHA solvers, in cascade order. + pub captcha_solver_order: Vec, // ── History ────────────────────────────────────────────────────── /// Number of days history entries are retained before automatic @@ -136,6 +138,7 @@ impl Default for AppConfig { // CAPTCHA captcha_timeout_seconds: DEFAULT_CAPTCHA_TIMEOUT_SECONDS, + captcha_solver_order: default_captcha_solver_order(), // History history_retention_days: 30, @@ -187,6 +190,34 @@ pub const DEFAULT_LINK_CHECK_TIMEOUT_SECS: u32 = 10; pub const DEFAULT_CAPTCHA_TIMEOUT_SECONDS: u32 = 120; pub const MIN_CAPTCHA_TIMEOUT_SECONDS: u32 = 10; pub const MAX_CAPTCHA_TIMEOUT_SECONDS: u32 = 3_600; +pub const CAPTCHA_SOLVER_OCR: &str = "vortex-mod-captcha-ocr"; +pub const CAPTCHA_SOLVER_ANTICAPTCHA: &str = "vortex-mod-captcha-anticaptcha"; +pub const CAPTCHA_SOLVER_BROWSER: &str = "vortex-mod-captcha-browser"; + +pub fn default_captcha_solver_order() -> Vec { + [ + CAPTCHA_SOLVER_OCR, + CAPTCHA_SOLVER_ANTICAPTCHA, + CAPTCHA_SOLVER_BROWSER, + ] + .into_iter() + .map(str::to_string) + .collect() +} + +pub fn normalize_captcha_solver_order(raw: &[String]) -> Vec { + let mut normalized = Vec::with_capacity(raw.len().min(3)); + for solver in raw { + let known = matches!( + solver.as_str(), + CAPTCHA_SOLVER_OCR | CAPTCHA_SOLVER_ANTICAPTCHA | CAPTCHA_SOLVER_BROWSER + ); + if known && !normalized.contains(solver) { + normalized.push(solver.clone()); + } + } + normalized +} /// Lower bound for `link_check_parallelism`. Below 1 the queue stalls. pub const MIN_LINK_CHECK_PARALLELISM: u32 = 1; @@ -234,6 +265,7 @@ pub struct ConfigPatch { // CAPTCHA pub captcha_timeout_seconds: Option, + pub captcha_solver_order: Option>, // History pub history_retention_days: Option, @@ -363,6 +395,9 @@ pub fn apply_patch(config: &mut AppConfig, patch: &ConfigPatch) { config.captcha_timeout_seconds = v.clamp(MIN_CAPTCHA_TIMEOUT_SECONDS, MAX_CAPTCHA_TIMEOUT_SECONDS); } + if let Some(ref order) = patch.captcha_solver_order { + config.captcha_solver_order = normalize_captcha_solver_order(order); + } // History if let Some(v) = patch.history_retention_days { @@ -543,6 +578,57 @@ mod tests { assert_eq!(config.captcha_timeout_seconds, MIN_CAPTCHA_TIMEOUT_SECONDS); } + #[test] + fn captcha_solver_order_defaults_to_the_documented_cascade() { + assert_eq!( + AppConfig::default().captcha_solver_order, + vec![ + CAPTCHA_SOLVER_OCR.to_string(), + CAPTCHA_SOLVER_ANTICAPTCHA.to_string(), + CAPTCHA_SOLVER_BROWSER.to_string(), + ] + ); + } + + #[test] + fn captcha_solver_order_patch_keeps_only_unique_known_solvers() { + let mut config = AppConfig::default(); + apply_patch( + &mut config, + &ConfigPatch { + captcha_solver_order: Some(vec![ + CAPTCHA_SOLVER_BROWSER.to_string(), + "unknown".to_string(), + CAPTCHA_SOLVER_OCR.to_string(), + CAPTCHA_SOLVER_BROWSER.to_string(), + ]), + ..Default::default() + }, + ); + + assert_eq!( + config.captcha_solver_order, + vec![ + CAPTCHA_SOLVER_BROWSER.to_string(), + CAPTCHA_SOLVER_OCR.to_string(), + ] + ); + } + + #[test] + fn captcha_solver_order_can_disable_all_automatic_solvers() { + let mut config = AppConfig::default(); + apply_patch( + &mut config, + &ConfigPatch { + captcha_solver_order: Some(Vec::new()), + ..Default::default() + }, + ); + + assert!(config.captcha_solver_order.is_empty()); + } + #[test] fn test_apply_patch_updates_link_check_fields() { let mut config = AppConfig::default(); diff --git a/src-tauri/src/domain/ports/driven/captcha_interaction.rs b/src-tauri/src/domain/ports/driven/captcha_interaction.rs new file mode 100644 index 00000000..5164f521 --- /dev/null +++ b/src-tauri/src/domain/ports/driven/captcha_interaction.rs @@ -0,0 +1,7 @@ +use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaChallenge; + +/// Requests a local human-assisted UI for a pending CAPTCHA challenge. +pub trait CaptchaInteraction: Send + Sync { + fn request(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError>; +} diff --git a/src-tauri/src/domain/ports/driven/captcha_solver.rs b/src-tauri/src/domain/ports/driven/captcha_solver.rs index 20a5a674..df77a110 100644 --- a/src-tauri/src/domain/ports/driven/captcha_solver.rs +++ b/src-tauri/src/domain/ports/driven/captcha_solver.rs @@ -1,11 +1,12 @@ use crate::domain::error::DomainError; -use crate::domain::model::captcha::CaptchaChallenge; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaSolution}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum CaptchaSolverOutcome { - Solved, + Solved(CaptchaSolution), Unavailable, Rejected, + InteractionRequired, } pub trait CaptchaSolver: Send + Sync { @@ -17,3 +18,30 @@ pub trait CaptchaSolver: Send + Sync { solution: &str, ) -> Result; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::captcha::CaptchaSolution; + + #[test] + fn solved_outcome_carries_a_redacted_ephemeral_solution() { + let outcome = CaptchaSolverOutcome::Solved( + CaptchaSolution::try_new("secret-answer").expect("valid solution"), + ); + + let CaptchaSolverOutcome::Solved(solution) = outcome else { + panic!("expected solved outcome"); + }; + assert_eq!(solution.expose(), "secret-answer"); + assert!(!format!("{solution:?}").contains("secret-answer")); + } + + #[test] + fn interactive_solver_can_request_the_browser_fallback() { + assert_eq!( + CaptchaSolverOutcome::InteractionRequired, + CaptchaSolverOutcome::InteractionRequired + ); + } +} diff --git a/src-tauri/src/domain/ports/driven/mod.rs b/src-tauri/src/domain/ports/driven/mod.rs index 1d440cb1..6be3f14f 100644 --- a/src-tauri/src/domain/ports/driven/mod.rs +++ b/src-tauri/src/domain/ports/driven/mod.rs @@ -5,6 +5,7 @@ pub mod account_credential_store; pub mod account_repository; pub mod account_validator; pub mod archive_extractor; +pub mod captcha_interaction; pub mod captcha_repository; pub mod captcha_solver; pub mod checksum_computer; @@ -36,6 +37,7 @@ pub use account_credential_store::AccountCredentialStore; pub use account_repository::AccountRepository; pub use account_validator::{AccountValidator, ValidationOutcome}; pub use archive_extractor::ArchiveExtractor; +pub use captcha_interaction::CaptchaInteraction; pub use captcha_repository::CaptchaRepository; pub use captcha_solver::{CaptchaSolver, CaptchaSolverOutcome}; pub use checksum_computer::ChecksumComputer; diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index a302e69b..874c41f2 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -4,9 +4,11 @@ //! to determine which plugin can handle a given URL. use crate::domain::error::DomainError; +use crate::domain::model::captcha::CaptchaChallenge; use crate::domain::model::credential::Credential; use crate::domain::model::plugin::{PluginInfo, PluginManifest}; use crate::domain::ports::driven::account_validator::ValidationOutcome; +use crate::domain::ports::driven::captcha_solver::CaptchaSolverOutcome; use crate::domain::ports::driven::hoster_link::ExtractedHosterLink; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; @@ -195,6 +197,17 @@ pub trait PluginLoader: Send + Sync { Ok(()) } + /// Ask one enabled CAPTCHA plugin to solve a bounded challenge. + fn solve_captcha( + &self, + plugin_name: &str, + _challenge: &CaptchaChallenge, + ) -> Result { + Err(DomainError::NotFound(format!( + "CAPTCHA solver '{plugin_name}' is not available" + ))) + } + /// Decrypt a link container blob (DLC / CCF / RSDF / Metalink) using /// an enabled plugin in the [`Container`](crate::domain::model::plugin::PluginCategory::Container) /// category that exports a `decrypt` function. Implementations probe @@ -295,6 +308,24 @@ mod tests { assert!(matches!(result, Err(DomainError::NotFound(_)))); } + #[test] + fn test_solve_captcha_default_returns_not_found() { + let challenge = crate::domain::model::captcha::CaptchaChallenge::new( + crate::domain::model::captcha::CaptchaId::new("captcha-1"), + crate::domain::model::download::DownloadId(1), + crate::domain::model::captcha::CaptchaType::Image, + "https://example.com/captcha".to_string(), + 1_000, + 61_000, + ) + .expect("valid challenge"); + + assert!(matches!( + MinimalLoader.solve_captcha("vortex-mod-captcha-ocr", &challenge), + Err(DomainError::NotFound(_)) + )); + } + #[test] fn test_load_official_from_dir_default_is_fail_closed() { let loader = MinimalLoader; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 475453dd..2ce8e65d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use domain::ports::driven::{ }; // Public API — concrete types for app wiring (main.rs, Tauri setup, integration tests) +pub use adapters::driven::captcha_interaction::TauriCaptchaInteraction; pub use adapters::driven::clipboard::TauriClipboardObserver; pub use adapters::driven::config::TomlConfigStore; pub use adapters::driven::credential::KeyringAccountStore; @@ -39,7 +40,8 @@ pub use adapters::driven::notification::spawn_notification_bridge; pub use adapters::driven::plugin::builtin::HttpModule; pub use adapters::driven::plugin::capabilities::SharedHostResources; pub use adapters::driven::plugin::{ - ExtismPluginLoader, GithubStoreClient, PluginAccountValidator, PluginRegistry, PluginWatcher, + ExtismPluginLoader, GithubStoreClient, PluginAccountValidator, PluginCaptchaSolver, + PluginRegistry, PluginWatcher, }; pub use adapters::driven::scheduler::{HISTORY_PURGE_STATE_FILE, HistoryPurgeWorker, SystemClock}; pub use adapters::driven::sqlite::account_repo::SqliteAccountRepo; @@ -77,12 +79,13 @@ pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ self, AppState, account_add, account_delete, account_export, account_get, account_import, account_list, account_traffic_get, account_update, account_validate, browse_file, - browse_folder, captcha_get_pending, captcha_list, captcha_retry, captcha_skip, captcha_solve, - clipboard_state, clipboard_toggle, command_get_media_metadata, download_cancel, - download_change_directory, download_change_directory_bulk, download_clear_completed, - download_clear_failed, download_count_by_state, download_detail, download_list, download_logs, - download_media_start, download_move_to_bottom, download_move_to_top, download_open_file, - download_open_folder, download_pause, download_pause_all, download_redownload, download_remove, + browse_folder, captcha_credential_delete, captcha_credential_set, captcha_credential_status, + captcha_get_pending, captcha_list, captcha_retry, captcha_skip, captcha_solve, clipboard_state, + clipboard_toggle, command_get_media_metadata, download_cancel, download_change_directory, + download_change_directory_bulk, download_clear_completed, download_clear_failed, + download_count_by_state, download_detail, download_list, download_logs, download_media_start, + download_move_to_bottom, download_move_to_top, download_open_file, download_open_folder, + download_pause, download_pause_all, download_redownload, download_remove, download_reorder_queue, download_resume, download_resume_all, download_retry, download_set_priority, download_skip_wait, download_start, download_verify_checksum, history_clear, history_delete_entry, history_export, history_get_by_id, history_list, @@ -187,7 +190,9 @@ pub fn run() { ); // ── Plugin system ─────────────────────────────────────── - let shared_resources = Arc::new(SharedHostResources::new()); + let shared_resources = Arc::new( + SharedHostResources::new().with_credential_store(credential_store.clone()), + ); let plugin_config_store: Arc< dyn crate::domain::ports::driven::PluginConfigStore, > = Arc::new( @@ -358,16 +363,28 @@ pub fn run() { queue_manager.clone(), ); - let captcha_solvers: Vec> = - vec![Arc::new(ManualCaptchaSolver)]; - let captcha_handler = Arc::new(CaptchaCommandHandler::new( - captcha_repo.clone(), - download_repo.clone(), - event_bus.clone(), - config_store.clone(), - Arc::new(SystemClock) as Arc, - captcha_solvers, - )); + let captcha_solvers: Vec> = [ + crate::domain::model::config::CAPTCHA_SOLVER_OCR, + crate::domain::model::config::CAPTCHA_SOLVER_ANTICAPTCHA, + crate::domain::model::config::CAPTCHA_SOLVER_BROWSER, + ] + .into_iter() + .map(|name| { + Arc::new(PluginCaptchaSolver::new(name, plugin_loader.clone())) + as Arc + }) + .collect(); + let captcha_handler = Arc::new( + CaptchaCommandHandler::new( + captcha_repo.clone(), + download_repo.clone(), + event_bus.clone(), + config_store.clone(), + Arc::new(SystemClock) as Arc, + captcha_solvers, + ) + .with_interaction(Arc::new(TauriCaptchaInteraction::new(app.handle().clone()))), + ); captcha_handler.start_listening(); // ── Plugin store client ───────────────────────────────── @@ -600,6 +617,9 @@ pub fn run() { captcha_retry, captcha_list, captcha_get_pending, + captcha_credential_status, + captcha_credential_set, + captcha_credential_delete, download_change_directory, download_change_directory_bulk, download_retry, diff --git a/src/App.tsx b/src/App.tsx index fe524338..e2123c5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,29 +17,35 @@ import { SettingsView, } from "@/views"; import { queryClient } from "@/api/client"; +import { CaptchaBrowserWindow } from "@/views/CaptchaBrowserWindow"; export function App() { + const captchaWindowId = new URLSearchParams(window.location.search).get("captchaWindow"); return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + {captchaWindowId ? ( + + ) : ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + )} diff --git a/src/components/__tests__/ClipboardIndicator.test.tsx b/src/components/__tests__/ClipboardIndicator.test.tsx index ccc24c66..d30810bc 100644 --- a/src/components/__tests__/ClipboardIndicator.test.tsx +++ b/src/components/__tests__/ClipboardIndicator.test.tsx @@ -33,6 +33,11 @@ const baseConfig: AppConfig = { dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, captchaTimeoutSeconds: 120, + captchaSolverOrder: [ + "vortex-mod-captcha-ocr", + "vortex-mod-captcha-anticaptcha", + "vortex-mod-captcha-browser", + ], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/hooks/__tests__/useAppEffects.test.ts b/src/hooks/__tests__/useAppEffects.test.ts index 309251c9..ccc9cc86 100644 --- a/src/hooks/__tests__/useAppEffects.test.ts +++ b/src/hooks/__tests__/useAppEffects.test.ts @@ -30,6 +30,11 @@ const baseConfig: AppConfig = { dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, captchaTimeoutSeconds: 120, + captchaSolverOrder: [ + "vortex-mod-captcha-ocr", + "vortex-mod-captcha-anticaptcha", + "vortex-mod-captcha-browser", + ], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a88bb585..836d2570 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -22,9 +22,19 @@ "empty": "No CAPTCHA waiting", "loading": "Loading CAPTCHA queue…", "error": "Could not load the CAPTCHA queue", + "browserTitle": "Human CAPTCHA check", + "browserUnavailable": "This CAPTCHA is no longer pending. You can close this window.", "history": "Recent challenges", "download": "Download #{{id}}", "answer": "Captcha answer", + "attempts": "Solver attempts", + "outcomes": { + "solved": "Solved", + "unavailable": "Unavailable", + "rejected": "Rejected", + "failed": "Failed", + "interaction_required": "Human interaction requested" + }, "unsupported": "This challenge requires browser interaction and is not supported yet.", "actions": { "solve": "Solve", @@ -46,7 +56,21 @@ }, "settings": { "title": "Solver settings", - "description": "Solvers run in order. Only manual solving is enabled in this release.", + "description": "Enabled solvers run automatically in the order shown.", + "automatic": "Automatic cascade", + "solvers": { + "ocr": "Tesseract OCR", + "antiCaptcha": "AntiCaptcha", + "browser": "Browser fallback" + }, + "moveUp": "Move {{solver}} up", + "moveDown": "Move {{solver}} down", + "tesseractDetection": "Tesseract is detected in trusted system paths. If it is missing, the cascade continues automatically.", + "apiKey": "AntiCaptcha API key", + "saveApiKey": "Save API key", + "deleteApiKey": "Delete API key", + "apiKeyConfigured": "Stored securely in the system keyring.", + "apiKeyMissing": "No API key configured.", "manual": "Manual solver", "manualDescription": "Enter the answer shown in the challenge image.", "timeout": "Timeout (seconds)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b3d95bc5..edb0c03a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -22,9 +22,19 @@ "empty": "Aucun CAPTCHA en attente", "loading": "Chargement de la file CAPTCHA…", "error": "Impossible de charger la file CAPTCHA", + "browserTitle": "Vérification CAPTCHA humaine", + "browserUnavailable": "Ce CAPTCHA n’est plus en attente. Vous pouvez fermer cette fenêtre.", "history": "Défis récents", "download": "Téléchargement nº {{id}}", "answer": "Réponse au captcha", + "attempts": "Tentatives des solveurs", + "outcomes": { + "solved": "Résolu", + "unavailable": "Indisponible", + "rejected": "Rejeté", + "failed": "Échec", + "interaction_required": "Interaction humaine demandée" + }, "unsupported": "Ce défi exige une interaction navigateur et n’est pas encore pris en charge.", "actions": { "solve": "Résoudre", @@ -46,7 +56,21 @@ }, "settings": { "title": "Configuration des solveurs", - "description": "Les solveurs s’exécutent dans l’ordre. Seule la résolution manuelle est active dans cette version.", + "description": "Les solveurs actifs s’exécutent automatiquement dans l’ordre affiché.", + "automatic": "Cascade automatique", + "solvers": { + "ocr": "OCR Tesseract", + "antiCaptcha": "AntiCaptcha", + "browser": "Relais navigateur" + }, + "moveUp": "Monter {{solver}}", + "moveDown": "Descendre {{solver}}", + "tesseractDetection": "Tesseract est détecté dans les chemins système de confiance. S’il est absent, la cascade continue automatiquement.", + "apiKey": "Clé API AntiCaptcha", + "saveApiKey": "Enregistrer la clé API", + "deleteApiKey": "Supprimer la clé API", + "apiKeyConfigured": "Stockée de façon sécurisée dans le trousseau système.", + "apiKeyMissing": "Aucune clé API configurée.", "manual": "Solveur manuel", "manualDescription": "Saisissez la réponse affichée dans l’image du défi.", "timeout": "Délai d’expiration (secondes)", diff --git a/src/layouts/__tests__/AppLayout.test.tsx b/src/layouts/__tests__/AppLayout.test.tsx index 3137990c..35817e6b 100644 --- a/src/layouts/__tests__/AppLayout.test.tsx +++ b/src/layouts/__tests__/AppLayout.test.tsx @@ -35,6 +35,11 @@ const baseConfig: AppConfig = { dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, captchaTimeoutSeconds: 120, + captchaSolverOrder: [ + "vortex-mod-captcha-ocr", + "vortex-mod-captcha-anticaptcha", + "vortex-mod-captcha-browser", + ], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/stores/__tests__/settingsStore.test.ts b/src/stores/__tests__/settingsStore.test.ts index 2504a5a3..98b0d69a 100644 --- a/src/stores/__tests__/settingsStore.test.ts +++ b/src/stores/__tests__/settingsStore.test.ts @@ -31,6 +31,11 @@ const baseConfig: AppConfig = { dynamicSplitEnabled: true, dynamicSplitMinRemainingMb: 4, captchaTimeoutSeconds: 120, + captchaSolverOrder: [ + "vortex-mod-captcha-ocr", + "vortex-mod-captcha-anticaptcha", + "vortex-mod-captcha-browser", + ], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/types/captcha.ts b/src/types/captcha.ts index fe06a7c2..59931628 100644 --- a/src/types/captcha.ts +++ b/src/types/captcha.ts @@ -1,5 +1,18 @@ export type CaptchaType = "image" | "text_input" | "recaptcha_v2" | "recaptcha_v3" | "hcaptcha"; export type CaptchaStatus = "pending" | "solved" | "skipped" | "timed_out"; +export type CaptchaSolverAttemptOutcome = + | "solved" + | "unavailable" + | "rejected" + | "failed" + | "interaction_required"; + +export interface CaptchaSolverAttempt { + solver: string; + outcome: CaptchaSolverAttemptOutcome; + attemptedAt: number; + durationMs: number; +} export interface CaptchaChallengeView { id: string; @@ -11,6 +24,7 @@ export interface CaptchaChallengeView { status: CaptchaStatus; solver: string | null; attempts: number; + solverAttempts: CaptchaSolverAttempt[]; createdAt: number; expiresAt: number; resolvedAt: number | null; diff --git a/src/types/settings.ts b/src/types/settings.ts index fcf49af6..9a842bb1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -31,6 +31,7 @@ export interface AppConfig { dynamicSplitEnabled: boolean; dynamicSplitMinRemainingMb: number; captchaTimeoutSeconds: number; + captchaSolverOrder: string[]; // History historyRetentionDays: number; diff --git a/src/views/CaptchaBrowserWindow.tsx b/src/views/CaptchaBrowserWindow.tsx new file mode 100644 index 00000000..e0915ebd --- /dev/null +++ b/src/views/CaptchaBrowserWindow.tsx @@ -0,0 +1,22 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useTranslation } from "react-i18next"; +import { usePendingCaptcha } from "@/hooks/useCaptchaQueue"; +import { CaptchaChallengePanel } from "./CaptchaChallengePanel"; + +export function CaptchaBrowserWindow({ challengeId }: { challengeId: string }) { + const { t } = useTranslation(); + const { data: challenge, isLoading, error } = usePendingCaptcha(challengeId); + const close = () => { + void getCurrentWindow().close(); + }; + + return ( +
+

{t("captcha.browserTitle")}

+ {isLoading ?

{t("captcha.loading")}

: null} + {error ?

{t("captcha.error")}

: null} + {!isLoading && !error && !challenge ?

{t("captcha.browserUnavailable")}

: null} + {challenge ? : null} +
+ ); +} diff --git a/src/views/CaptchaChallengePanel.tsx b/src/views/CaptchaChallengePanel.tsx index e43bc33b..0c6d60f8 100644 --- a/src/views/CaptchaChallengePanel.tsx +++ b/src/views/CaptchaChallengePanel.tsx @@ -11,7 +11,27 @@ import type { CaptchaChallengeView } from "@/types/captcha"; const INVALIDATE_KEYS = [captchaQueries.all(), downloadQueries.all()] as const; -export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallengeView }) { +function solverLabel(solver: string, translate: (key: string) => string): string { + switch (solver) { + case "vortex-mod-captcha-ocr": + return translate("captcha.settings.solvers.ocr"); + case "vortex-mod-captcha-anticaptcha": + return translate("captcha.settings.solvers.antiCaptcha"); + case "vortex-mod-captcha-browser": + return translate("captcha.settings.solvers.browser"); + case "manual": + return translate("captcha.settings.manual"); + default: + return solver; + } +} + +interface CaptchaChallengePanelProps { + challenge: CaptchaChallengeView; + onResolved?: () => void; +} + +export function CaptchaChallengePanel({ challenge, onResolved }: CaptchaChallengePanelProps) { const { t } = useTranslation(); const [solution, setSolution] = useState(""); const countdown = useCountdown(challenge.expiresAt); @@ -25,9 +45,11 @@ export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallen const solve = useTauriMutation("captcha_solve", { invalidateKeys: INVALIDATE_KEYS, + onSuccess: onResolved, }); const skip = useTauriMutation("captcha_skip", { invalidateKeys: INVALIDATE_KEYS, + onSuccess: onResolved, }); const retry = useTauriMutation("captcha_retry", { invalidateKeys: INVALIDATE_KEYS, @@ -53,6 +75,16 @@ export function CaptchaChallengePanel({ challenge }: { challenge: CaptchaChallen src={`data:${challenge.imageMimeType};base64,${challenge.imageData}`} /> ) : null} + {challenge.solverAttempts.length > 0 ? ( +
+

{t("captcha.attempts")}

+ {challenge.solverAttempts.map((attempt, index) => ( +

+ {solverLabel(attempt.solver, t)} — {t(`captcha.outcomes.${attempt.outcome}`)} +

+ ))} +
+ ) : null} {acceptsText ? (