Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .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
6 changes: 6 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 All @@ -23,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- CAPTCHA solver review hardening now bounds attempt, IPC, and WASM payloads,
preserves late solver audit entries and Tesseract data discovery, isolates
popup permissions, and keeps keyring I/O off async workers (MAT-141).
- CAPTCHA recovery now re-arms every pending timeout, and download removal
atomically coordinates engine cancellation with challenge cleanup (MAT-140).
- Contributor Automation: `actions/first-interaction` v3 requires both
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
12 changes: 12 additions & 0 deletions src-tauri/capabilities/captcha-browser.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"identifier": "captcha-browser",
"description": "Minimal capabilities for assisted CAPTCHA browser windows",
"windows": ["captcha-browser-*"],
"permissions": [
"captcha-browser-commands",
"core:window:allow-close",
"core:event:allow-listen",
Comment thread
mpiton marked this conversation as resolved.
Outdated
"core:event:allow-unlisten"
]
}
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "Default capabilities for Vortex",
"windows": ["main"],
"permissions": [
"main-window-commands",
"core:default",
"core:tray:default",
"notification:allow-notify",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/gen/schemas/acl-manifests.json

Large diffs are not rendered by default.

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"]}}
{"captcha-browser":{"identifier":"captcha-browser","description":"Minimal capabilities for assisted CAPTCHA browser windows","local":true,"windows":["captcha-browser-*"],"permissions":["captcha-browser-commands","core:window:allow-close","core:event:allow-listen","core:event:allow-unlisten"]},"default":{"identifier":"default","description":"Default capabilities for Vortex","local":true,"windows":["main"],"permissions":["main-window-commands","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"]}}
12 changes: 12 additions & 0 deletions src-tauri/gen/schemas/desktop-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Allows assisted CAPTCHA windows to read and resolve only their challenge.",
"type": "string",
"const": "captcha-browser-commands",
"markdownDescription": "Allows assisted CAPTCHA windows to read and resolve only their challenge."
},
{
"description": "Allows the main window to invoke all Vortex application commands.",
"type": "string",
"const": "main-window-commands",
"markdownDescription": "Allows the main window to invoke all Vortex application commands."
},
{
"description": "No features are enabled by default, as we believe\nthe clipboard can be inherently dangerous and it is \napplication specific if read and/or write access is needed.\n\nClipboard interaction needs to be explicitly enabled.\n",
"type": "string",
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/gen/schemas/linux-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Allows assisted CAPTCHA windows to read and resolve only their challenge.",
"type": "string",
"const": "captcha-browser-commands",
"markdownDescription": "Allows assisted CAPTCHA windows to read and resolve only their challenge."
},
{
"description": "Allows the main window to invoke all Vortex application commands.",
"type": "string",
"const": "main-window-commands",
"markdownDescription": "Allows the main window to invoke all Vortex application commands."
},
{
"description": "No features are enabled by default, as we believe\nthe clipboard can be inherently dangerous and it is \napplication specific if read and/or write access is needed.\n\nClipboard interaction needs to be explicitly enabled.\n",
"type": "string",
Expand Down
107 changes: 107 additions & 0 deletions src-tauri/permissions/app.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
[[permission]]
identifier = "main-window-commands"
description = "Allows the main window to invoke all Vortex application commands."
commands.allow = [
"download_start",
"download_pause",
"download_resume",
"download_cancel",
"download_skip_wait",
"captcha_solve",
"captcha_skip",
"captcha_retry",
"captcha_list",
"captcha_get_pending",
Comment thread
mpiton marked this conversation as resolved.
"captcha_credential_status",
"captcha_credential_set",
"captcha_credential_delete",
"download_change_directory",
"download_change_directory_bulk",
"download_retry",
"download_redownload",
"download_verify_checksum",
"download_open_file",
"download_open_folder",
"download_pause_all",
"download_resume_all",
"download_set_priority",
"download_move_to_top",
"download_move_to_bottom",
"download_reorder_queue",
"download_remove",
"download_clear_completed",
"download_clear_failed",
"download_list",
"download_detail",
"download_logs",
"download_count_by_state",
"plugin_install",
"plugin_uninstall",
"plugin_enable",
"plugin_disable",
"plugin_list",
"plugin_store_list",
"plugin_store_refresh",
"plugin_store_install",
"plugin_store_update",
"plugin_config_get",
"plugin_config_update",
"plugin_report_broken",
"link_resolve",
"link_check_online",
"link_detect_duplicates",
"link_group_playlists",
"link_group_split_archives",
"link_import_container",
"clipboard_toggle",
"clipboard_state",
"settings_get",
"settings_update",
"status_bar_get",
"command_get_media_metadata",
"download_media_start",
"history_list",
"history_search",
"history_get_by_id",
"history_export",
"history_delete_entry",
"history_clear",
"history_purge_older_than",
"reveal_in_folder",
"stats_get",
"stats_top_modules",
"browse_folder",
"browse_file",
"account_add",
"account_update",
"account_delete",
"account_validate",
"account_export",
"account_import",
"account_list",
"account_get",
"account_traffic_get",
"package_create",
"package_update",
"package_delete",
"package_set_password",
"package_set_priority",
"package_move_to_folder",
"package_toggle_auto_extract",
"package_add_download",
"package_remove_download",
"package_list",
"package_get",
"package_list_downloads",
"package_find_by_external_id",
]

[[permission]]
identifier = "captcha-browser-commands"
description = "Allows assisted CAPTCHA windows to read and resolve only their challenge."
commands.allow = [
"captcha_get_pending",
"captcha_solve",
"captcha_skip",
"captcha_retry",
]
137 changes: 137 additions & 0 deletions src-tauri/src/adapters/driven/captcha_interaction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 std::path::Path;

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 == '-')
);
}

#[test]
fn popup_uses_a_dedicated_minimal_capability() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let default: serde_json::Value = serde_json::from_slice(
&std::fs::read(manifest_dir.join("capabilities/default.json"))
.expect("read default capability"),
)
.expect("parse default capability");
let browser: serde_json::Value = serde_json::from_slice(
&std::fs::read(manifest_dir.join("capabilities/captcha-browser.json"))
.expect("read CAPTCHA browser capability"),
)
.expect("parse CAPTCHA browser capability");

assert_eq!(default["windows"], serde_json::json!(["main"]));
assert!(
default["permissions"]
.as_array()
.expect("default permissions")
.contains(&serde_json::json!("main-window-commands"))
);
assert_eq!(browser["windows"], serde_json::json!(["captcha-browser-*"]));
assert_eq!(
browser["permissions"],
serde_json::json!([
"captcha-browser-commands",
"core:window:allow-close",
"core:event:allow-listen",
"core:event:allow-unlisten"
])
);

let permission_source = std::fs::read_to_string(manifest_dir.join("permissions/app.toml"))
.expect("read application permissions");
let permissions: toml::Value =
toml::from_str(&permission_source).expect("parse application permissions");
let browser_commands = permissions["permission"]
.as_array()
.expect("permission entries")
.iter()
.find(|permission| {
permission["identifier"].as_str() == Some("captcha-browser-commands")
})
.expect("CAPTCHA browser command permission");
assert_eq!(
browser_commands["commands"]["allow"],
toml::Value::Array(
[
"captcha_get_pending",
"captcha_solve",
"captcha_skip",
"captcha_retry",
]
.into_iter()
.map(|command| toml::Value::String(command.to_string()))
.collect()
)
);
}
}
Loading
Loading