Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/plugin-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
;;
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions registry/registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"identifier": "default",
"description": "Default capabilities for Vortex",
"windows": ["main"],
"windows": ["main", "captcha-browser-*"],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
"permissions": [
"core:default",
"core:tray:default",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/gen/schemas/capabilities.json
Original file line number Diff line number Diff line change
@@ -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"]}}
{"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"]}}
75 changes: 75 additions & 0 deletions src-tauri/src/adapters/driven/captcha_interaction.rs
Original file line number Diff line number Diff line change
@@ -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 == '-')
);
}
}
35 changes: 33 additions & 2 deletions src-tauri/src/adapters/driven/config/toml_config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -165,6 +165,7 @@ struct ConfigDto {

// CAPTCHA
captcha_timeout_seconds: u32,
captcha_solver_order: Vec<String>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// History
history_retention_days: i64,
Expand Down Expand Up @@ -230,6 +231,7 @@ impl From<AppConfig> 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,
Expand Down Expand Up @@ -291,6 +293,7 @@ impl TryFrom<ConfigDto> 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,
Expand Down Expand Up @@ -319,7 +322,10 @@ impl TryFrom<ConfigDto> 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.
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/adapters/driven/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Driven adapters — implementations of domain port traits.

pub mod captcha_interaction;
pub mod clipboard;
pub mod config;
pub mod credential;
Expand Down
71 changes: 69 additions & 2 deletions src-tauri/src/adapters/driven/plugin/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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());
Expand Down
33 changes: 33 additions & 0 deletions src-tauri/src/adapters/driven/plugin/captcha_solver.rs
Original file line number Diff line number Diff line change
@@ -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<dyn PluginLoader>,
}

impl PluginCaptchaSolver {
pub fn new(plugin_name: impl Into<String>, loader: Arc<dyn PluginLoader>) -> 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<CaptchaSolverOutcome, DomainError> {
self.loader.solve_captcha(&self.plugin_name, challenge)
}
}
Loading
Loading