diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63433371..e3e1a089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,6 +229,23 @@ jobs: - name: npm ci run: npm ci + - name: Tesseract broker test (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + CARGO_TERM_COLOR: never + run: | + $output = & cargo test --manifest-path src-tauri/Cargo.toml --lib adapters::driven::plugin::tesseract_broker_tests::tesseract_windows_fixture_receives_only_fixed_arguments -- --exact 2>&1 + $exitCode = $LASTEXITCODE + $output | Write-Output + if ($exitCode -ne 0) { + exit $exitCode + } + $passed = @($output | Select-String -Pattern '^test result: ok\. 1 passed; 0 failed;') + if ($passed.Count -ne 1) { + throw 'The Windows Tesseract regression test did not run exactly once.' + } + - name: Tauri build id: tauri-build shell: bash 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..2bd126d2 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 @@ -23,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- CAPTCHA browser windows now close directly from persisted terminal command + flows instead of relying on a lossy event subscriber (MAT-141). +- The Windows Tesseract broker regression fixture now returns success after + emitting its simulated OCR solution (MAT-141). +- CAPTCHA solver review hardening now preserves the complete solver audit while + bounding UI, IPC, guest-memory, and WASM payloads; binds each popup to one + challenge; clears inherited Tesseract data paths; 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 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/captcha-browser.json b/src-tauri/capabilities/captcha-browser.json new file mode 100644 index 00000000..77aa9727 --- /dev/null +++ b/src-tauri/capabilities/captcha-browser.json @@ -0,0 +1,10 @@ +{ + "$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" + ] +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 85776dd7..2063dc32 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,6 +4,7 @@ "description": "Default capabilities for Vortex", "windows": ["main"], "permissions": [ + "main-window-commands", "core:default", "core:tray:default", "notification:allow-notify", diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 64670398..237e2215 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"clipboard-manager":{"default_permission":{"identifier":"default","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","permissions":[]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-read-image":{"identifier":"allow-read-image","description":"Enables the read_image command without any pre-configured scope.","commands":{"allow":["read_image"],"deny":[]}},"allow-read-text":{"identifier":"allow-read-text","description":"Enables the read_text command without any pre-configured scope.","commands":{"allow":["read_text"],"deny":[]}},"allow-write-html":{"identifier":"allow-write-html","description":"Enables the write_html command without any pre-configured scope.","commands":{"allow":["write_html"],"deny":[]}},"allow-write-image":{"identifier":"allow-write-image","description":"Enables the write_image command without any pre-configured scope.","commands":{"allow":["write_image"],"deny":[]}},"allow-write-text":{"identifier":"allow-write-text","description":"Enables the write_text command without any pre-configured scope.","commands":{"allow":["write_text"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-read-image":{"identifier":"deny-read-image","description":"Denies the read_image command without any pre-configured scope.","commands":{"allow":[],"deny":["read_image"]}},"deny-read-text":{"identifier":"deny-read-text","description":"Denies the read_text command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text"]}},"deny-write-html":{"identifier":"deny-write-html","description":"Denies the write_html command without any pre-configured scope.","commands":{"allow":[],"deny":["write_html"]}},"deny-write-image":{"identifier":"deny-write-image","description":"Denies the write_image command without any pre-configured scope.","commands":{"allow":[],"deny":["write_image"]}},"deny-write-text":{"identifier":"deny-write-text","description":"Denies the write_text command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"pilot":{"default_permission":{"identifier":"default","description":"Default permissions for tauri-plugin-pilot (allows eval callback)","permissions":["allow-callback"]},"permissions":{"allow---callback":{"identifier":"allow---callback","description":"Enables the __callback command without any pre-configured scope.","commands":{"allow":["__callback"],"deny":[]}},"allow-callback":{"identifier":"allow-callback","description":"Allow the internal __callback IPC command used by the eval engine","commands":{"allow":["__callback"],"deny":[]}},"deny---callback":{"identifier":"deny---callback","description":"Denies the __callback command without any pre-configured scope.","commands":{"allow":[],"deny":["__callback"]}}},"permission_sets":{},"global_scope_schema":null},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"captcha-browser-commands":{"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"],"deny":[]}},"main-window-commands":{"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","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"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"clipboard-manager":{"default_permission":{"identifier":"default","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","permissions":[]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-read-image":{"identifier":"allow-read-image","description":"Enables the read_image command without any pre-configured scope.","commands":{"allow":["read_image"],"deny":[]}},"allow-read-text":{"identifier":"allow-read-text","description":"Enables the read_text command without any pre-configured scope.","commands":{"allow":["read_text"],"deny":[]}},"allow-write-html":{"identifier":"allow-write-html","description":"Enables the write_html command without any pre-configured scope.","commands":{"allow":["write_html"],"deny":[]}},"allow-write-image":{"identifier":"allow-write-image","description":"Enables the write_image command without any pre-configured scope.","commands":{"allow":["write_image"],"deny":[]}},"allow-write-text":{"identifier":"allow-write-text","description":"Enables the write_text command without any pre-configured scope.","commands":{"allow":["write_text"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-read-image":{"identifier":"deny-read-image","description":"Denies the read_image command without any pre-configured scope.","commands":{"allow":[],"deny":["read_image"]}},"deny-read-text":{"identifier":"deny-read-text","description":"Denies the read_text command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text"]}},"deny-write-html":{"identifier":"deny-write-html","description":"Denies the write_html command without any pre-configured scope.","commands":{"allow":[],"deny":["write_html"]}},"deny-write-image":{"identifier":"deny-write-image","description":"Denies the write_image command without any pre-configured scope.","commands":{"allow":[],"deny":["write_image"]}},"deny-write-text":{"identifier":"deny-write-text","description":"Denies the write_text command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"pilot":{"default_permission":{"identifier":"default","description":"Default permissions for tauri-plugin-pilot (allows eval callback)","permissions":["allow-callback"]},"permissions":{"allow---callback":{"identifier":"allow---callback","description":"Enables the __callback command without any pre-configured scope.","commands":{"allow":["__callback"],"deny":[]}},"allow-callback":{"identifier":"allow-callback","description":"Allow the internal __callback IPC command used by the eval engine","commands":{"allow":["__callback"],"deny":[]}},"deny---callback":{"identifier":"deny---callback","description":"Denies the __callback command without any pre-configured scope.","commands":{"allow":[],"deny":["__callback"]}}},"permission_sets":{},"global_scope_schema":null},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index f4c20fca..882567c4 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 +{"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"]},"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"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index b221f975..5f11fbc7 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -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", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index b221f975..5f11fbc7 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -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", diff --git a/src-tauri/permissions/app.toml b/src-tauri/permissions/app.toml new file mode 100644 index 00000000..45b730ac --- /dev/null +++ b/src-tauri/permissions/app.toml @@ -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", + "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", +] diff --git a/src-tauri/src/adapters/captcha_browser.rs b/src-tauri/src/adapters/captcha_browser.rs new file mode 100644 index 00000000..35c12b1e --- /dev/null +++ b/src-tauri/src/adapters/captcha_browser.rs @@ -0,0 +1,8 @@ +use sha2::{Digest, Sha256}; + +const CAPTCHA_BROWSER_WINDOW_PREFIX: &str = "captcha-browser-"; + +pub(crate) fn browser_window_label(challenge_id: &str) -> String { + let digest = Sha256::digest(challenge_id.as_bytes()); + format!("{CAPTCHA_BROWSER_WINDOW_PREFIX}{}", hex::encode(digest)) +} 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..086c5247 --- /dev/null +++ b/src-tauri/src/adapters/driven/captcha_interaction.rs @@ -0,0 +1,140 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +use crate::adapters::captcha_browser::browser_window_label; +use crate::domain::error::DomainError; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId}; +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 dismiss(&self, challenge_id: &CaptchaId) -> Result<(), DomainError> { + let label = browser_window_label(challenge_id.as_str()); + if let Some(window) = self.app.get_webview_window(&label) { + window.close().map_err(close_window_error)?; + } + Ok(()) + } +} + +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()) +} + +fn close_window_error(_: tauri::Error) -> DomainError { + DomainError::PluginError("could not close 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 == '-') + ); + assert_eq!( + browser_window_label("captcha / ?").len(), + "captcha-browser-".len() + 64 + ); + } + + #[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"]) + ); + + 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() + ) + ); + } +} 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..4b0acb4d 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,72 @@ struct InstallState { count: AtomicUsize, } +const MAX_CAPTCHA_SOLVER_OUTPUT_BYTES: usize = 8 * 1024; +const CAPTCHA_PLUGIN_MEMORY_MAX_PAGES: u32 = 1024; + +fn runtime_manifest(wasm_bytes: Vec, category: PluginCategory) -> extism::Manifest { + let manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); + if category == PluginCategory::Captcha { + // One WebAssembly page is 64 KiB. This bounds allocations made while + // producing output; the registry's byte cap then prevents a large + // guest response from being copied into a host String. + manifest.with_memory_max(CAPTCHA_PLUGIN_MEMORY_MAX_PAGES) + } else { + manifest + } +} + +#[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 { @@ -364,7 +435,7 @@ impl PluginLoader for ExtismPluginLoader { &wasm_bytes, &manifest_bytes, ); - let extism_manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); + let extism_manifest = runtime_manifest(wasm_bytes, disk_manifest.info().category()); let (host_functions, credential_slot) = build_host_functions_for_instance(&disk_manifest, &self.shared_resources, grants); let plugin = extism::Plugin::new(&extism_manifest, host_functions, true) @@ -487,6 +558,66 @@ 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_capped( + plugin_name, + "can_solve", + &request, + MAX_CAPTCHA_SOLVER_OUTPUT_BYTES, + ) + .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_capped( + plugin_name, + "solve", + &request, + MAX_CAPTCHA_SOLVER_OUTPUT_BYTES, + ) + .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 +1054,7 @@ fn parse_validation_outcome(output: &str) -> Result Result { - let bytes: Vec = plugin.memory_get_val(&inputs[0])?; - String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("invalid utf-8 input: {e}")) + read_input_string_capped(plugin, inputs, usize::MAX, "plugin input") +} + +fn read_input_string_capped( + plugin: &mut extism::CurrentPlugin, + inputs: &[extism::Val], + limit: usize, + operation: &str, +) -> Result { + let offset = inputs + .first() + .and_then(extism::Val::i64) + .ok_or_else(|| anyhow::anyhow!("{operation}: invalid input pointer"))?; + let offset = + u64::try_from(offset).map_err(|_| anyhow::anyhow!("{operation}: invalid input pointer"))?; + let handle = plugin + .memory_handle(offset) + .ok_or_else(|| anyhow::anyhow!("{operation}: invalid input pointer"))?; + ensure_input_size(handle.len(), limit, operation)?; + let bytes = plugin.memory_bytes(handle)?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|error| anyhow::anyhow!("{operation}: invalid UTF-8 input: {error}")) +} + +fn ensure_input_size(length: usize, limit: usize, operation: &str) -> Result<(), extism::Error> { + if length > limit { + return Err(anyhow::anyhow!("{operation}: input exceeds safety limit")); + } + Ok(()) } fn write_output_string( @@ -420,6 +452,46 @@ pub fn make_run_ytdlp_function(user_data: extism::UserData) - ) } +/// 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_capped( + plugin, + inputs, + MAX_TESSERACT_REQUEST_BYTES, + "run_tesseract", + )?; + 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 @@ -558,4 +630,16 @@ mod tests { assert_eq!(message, "plugin log redacted after credential access"); assert!(!message.contains("retained secret")); } + + #[test] + fn tesseract_input_limit_is_checked_before_decoding() { + assert!( + ensure_input_size( + MAX_TESSERACT_REQUEST_BYTES + 1, + MAX_TESSERACT_REQUEST_BYTES, + "run_tesseract", + ) + .is_err() + ); + } } 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..52cae2b7 100644 --- a/src-tauri/src/adapters/driven/plugin/provenance.rs +++ b/src-tauri/src/adapters/driven/plugin/provenance.rs @@ -11,6 +11,7 @@ use crate::domain::error::DomainError; use crate::domain::ports::driven::plugin_store_client::OfficialPluginProvenance; use super::capabilities::HostFunctionGrants; +use super::tesseract_broker::OCR_PLUGIN_NAME; type ParentSync = fn(&Path) -> std::io::Result<()>; @@ -132,7 +133,10 @@ impl OfficialProvenanceStore { && entry.wasm_sha256 == digest(wasm_bytes) && entry.manifest_sha256 == digest(manifest_bytes) }); - HostFunctionGrants { ytdlp: verified } + HostFunctionGrants { + ytdlp: verified, + tesseract: verified && name == OCR_PLUGIN_NAME, + } } pub(super) fn revoke(&self, name: &str) -> Result<(), DomainError> { diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs index c4c71682..9d4e11be 100644 --- a/src-tauri/src/adapters/driven/plugin/registry.rs +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -131,7 +131,17 @@ impl PluginRegistry { } pub fn call_plugin(&self, name: &str, func: &str, input: &str) -> Result { - self.call_plugin_inner(name, func, input, None) + self.call_plugin_inner(name, func, input, None, None) + } + + pub(crate) fn call_plugin_capped( + &self, + name: &str, + func: &str, + input: &str, + output_limit: usize, + ) -> Result { + self.call_plugin_inner(name, func, input, None, Some(output_limit)) } pub fn call_plugin_with_credential( @@ -141,7 +151,7 @@ impl PluginRegistry { input: &str, credential: Credential, ) -> Result { - self.call_plugin_inner(name, func, input, Some(credential)) + self.call_plugin_inner(name, func, input, Some(credential), None) } /// Container plugins decode binary blobs (DLC / CCF / RSDF / Metalink); @@ -152,7 +162,7 @@ impl PluginRegistry { func: &str, input: &[u8], ) -> Result { - self.call_plugin_inner(name, func, input, None) + self.call_plugin_inner(name, func, input, None, None) } fn call_plugin_inner<'a, I>( @@ -161,6 +171,7 @@ impl PluginRegistry { func: &str, input: I, scoped_credential: Option, + output_limit: Option, ) -> Result where I: extism::convert::ToBytes<'a>, @@ -206,15 +217,28 @@ impl PluginRegistry { }; let fn_exists = plugin.function_exists(func); tracing::debug!(plugin = name, func, fn_exists, "plugin call pre-call"); - let result = plugin.call::(func, input).map_err(|e| { + let result = plugin.call::(func, input).map_err(|e| { DomainError::PluginError(format!( "plugin call failed (function_exists={fn_exists}): {e}" )) })?; - Ok(result.to_string()) + // `result` still borrows guest memory. Check its length before the + // only host allocation; CAPTCHA guests also have a runtime memory cap. + materialize_plugin_output(result, output_limit) } } +fn materialize_plugin_output(output: &[u8], limit: Option) -> Result { + if limit.is_some_and(|limit| output.len() > limit) { + return Err(DomainError::PluginError( + "plugin output exceeds safety limit".into(), + )); + } + std::str::from_utf8(output) + .map(str::to_owned) + .map_err(|_| DomainError::PluginError("plugin output is not valid UTF-8".into())) +} + impl Default for PluginRegistry { fn default() -> Self { Self::new() @@ -464,4 +488,16 @@ mod tests { let result = registry.function_exists("plug-a", "extract_links"); assert!(!result.unwrap()); } + + #[test] + fn capped_output_is_rejected_before_materialization() { + let output = vec![b'x'; 9]; + + let error = materialize_plugin_output(&output, Some(8)) + .expect_err("oversized output must be rejected"); + + assert!( + matches!(error, DomainError::PluginError(message) if message.contains("safety limit")) + ); + } } 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..0b293d66 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/tesseract_broker.rs @@ -0,0 +1,289 @@ +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, +}; + +pub(crate) const OCR_PLUGIN_NAME: &str = "vortex-mod-captcha-ocr"; +pub(crate) const MAX_TESSERACT_REQUEST_BYTES: usize = MAX_CAPTCHA_IMAGE_BYTES.div_ceil(3) * 4 + 64; +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(serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PluginTesseractRequest { + image_data: String, +} + +impl std::fmt::Debug for PluginTesseractRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PluginTesseractRequest") + .field("image_data", &"") + .finish() + } +} + +#[derive(serde::Serialize)] +pub(crate) struct TesseractResponse { + pub(crate) status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) solution: Option, +} + +impl std::fmt::Debug for TesseractResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TesseractResponse") + .field("status", &self.status) + .field("solution", &self.solution.as_ref().map(|_| "")) + .finish() + } +} + +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 = build_tesseract_command(binary); + 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 }) +} + +pub(crate) fn build_tesseract_command(binary: &Path) -> Command { + 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); + command +} + +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..d9d63c88 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/tesseract_broker_tests.rs @@ -0,0 +1,158 @@ +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use base64::Engine; + +use super::tesseract_broker::{ + PluginTesseractRequest, TesseractResponse, build_tesseract_command, 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 tesseract_debug_output_redacts_images_and_solutions() { + let request_debug = format!("{:?}", request()); + let response_debug = format!( + "{:?}", + TesseractResponse { + status: "solved", + solution: Some("secret-answer".into()), + } + ); + + assert!(!request_debug.contains("iVBOR")); + assert!(request_debug.contains("")); + assert!(!response_debug.contains("secret-answer")); + assert!(response_debug.contains("")); +} + +#[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"); + let wc = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join("wc")) + .find(|candidate| candidate.is_file()) + .expect("wc on the test PATH"); + std::fs::write( + &binary, + format!( + "#!/bin/sh\n[ \"$#\" -eq 6 ] && [ \"$1\" = stdin ] && [ \"$2\" = stdout ] && [ \"$3\" = -l ] && [ \"$4\" = eng ] && [ \"$5\" = --psm ] && [ \"$6\" = 7 ] || exit 9\n[ \"$(\"{}\" -c)\" -eq 24 ] || exit 10\nprintf ' ABC123 \\n'\n", + wc.display() + ), + ) + .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_command_does_not_inherit_tessdata_prefix() { + let command = build_tesseract_command(std::path::Path::new("/approved/tesseract")); + + assert!( + command + .get_envs() + .all(|(name, _)| name != "TESSDATA_PREFIX") + ); +} + +#[cfg(windows)] +#[test] +fn tesseract_windows_fixture_receives_only_fixed_arguments() { + let temp = tempfile::tempdir().expect("tempdir"); + let binary = temp.path().join("tesseract.bat"); + std::fs::write( + &binary, + "@echo off\r\nif not \"%~7\"==\"\" exit /b 9\r\nif not \"%~1\"==\"stdin\" exit /b 9\r\nif not \"%~2\"==\"stdout\" exit /b 9\r\nif not \"%~3\"==\"-l\" exit /b 9\r\nif not \"%~4\"==\"eng\" exit /b 9\r\nif not \"%~5\"==\"--psm\" exit /b 9\r\nif not \"%~6\"==\"7\" exit /b 9\r\n%SystemRoot%\\System32\\more.com >NUL\r\n 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" + }, + ) + .context(INSTALL_REMEDIATION) +} + +#[cfg(unix)] +const INSTALL_REMEDIATION: &str = "yt-dlp not found in approved locations; install or update it at ~/.local/bin/yt-dlp or with a supported system package"; + +#[cfg(windows)] +const INSTALL_REMEDIATION: &str = "yt-dlp not found in approved locations; install it with WinGet or at %LOCALAPPDATA%\\Programs\\yt-dlp\\yt-dlp.exe"; + +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 +231,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 +240,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/plugin/ytdlp_broker/tests/platform.rs b/src-tauri/src/adapters/driven/plugin/ytdlp_broker/tests/platform.rs index 67200fce..ace25260 100644 --- a/src-tauri/src/adapters/driven/plugin/ytdlp_broker/tests/platform.rs +++ b/src-tauri/src/adapters/driven/plugin/ytdlp_broker/tests/platform.rs @@ -31,6 +31,13 @@ mod unix { assert!(find_approved_binary(&[binary], &[approved.path().to_path_buf()]).is_err()); } + #[test] + fn missing_binary_error_keeps_install_remediation() { + let error = find_approved_binary(&[], &[]).expect_err("missing yt-dlp"); + + assert!(error.to_string().contains("~/.local/bin/yt-dlp")); + } + #[test] fn binary_below_group_writable_parent_is_rejected() { let approved = tempfile::tempdir().unwrap(); 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..ddfdc912 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -6,9 +6,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::State; +use tauri::{State, WebviewWindow}; use tracing; +use crate::adapters::captcha_browser::browser_window_label; use crate::adapters::driven::logging::download_log_store::DownloadLogStore; use crate::adapters::driven::network::WaitManager; use crate::application::command_bus::CommandBus; @@ -17,20 +18,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::{ @@ -180,17 +182,20 @@ pub async fn download_skip_wait(state: State<'_, AppState>, id: u64) -> Result<( #[tauri::command] pub async fn captcha_solve( + window: WebviewWindow, state: State<'_, AppState>, challenge_id: String, solution: String, ) -> Result<(), String> { + let challenge_id = parse_captcha_id(challenge_id)?; + authorize_captcha_window(window.label(), Some(&challenge_id))?; if solution.trim().is_empty() || solution.len() > MAX_CAPTCHA_SOLUTION_BYTES { return Err("CAPTCHA solution is empty or exceeds safety limits".into()); } state .command_bus .handle_captcha_solve(SolveCaptchaCommand { - challenge_id: parse_captcha_id(challenge_id)?, + challenge_id, solution, }) .await @@ -198,23 +203,31 @@ pub async fn captcha_solve( } #[tauri::command] -pub async fn captcha_skip(state: State<'_, AppState>, challenge_id: String) -> Result<(), String> { +pub async fn captcha_skip( + window: WebviewWindow, + state: State<'_, AppState>, + challenge_id: String, +) -> Result<(), String> { + let challenge_id = parse_captcha_id(challenge_id)?; + authorize_captcha_window(window.label(), Some(&challenge_id))?; state .command_bus - .handle_captcha_skip(SkipCaptchaCommand { - challenge_id: parse_captcha_id(challenge_id)?, - }) + .handle_captcha_skip(SkipCaptchaCommand { challenge_id }) .await .map_err(|error| error.to_string()) } #[tauri::command] -pub async fn captcha_retry(state: State<'_, AppState>, challenge_id: String) -> Result<(), String> { +pub async fn captcha_retry( + window: WebviewWindow, + state: State<'_, AppState>, + challenge_id: String, +) -> Result<(), String> { + let challenge_id = parse_captcha_id(challenge_id)?; + authorize_captcha_window(window.label(), Some(&challenge_id))?; state .command_bus - .handle_captcha_retry(RetryCaptchaCommand { - challenge_id: parse_captcha_id(challenge_id)?, - }) + .handle_captcha_retry(RetryCaptchaCommand { challenge_id }) .await .map_err(|error| error.to_string()) } @@ -223,6 +236,21 @@ fn parse_captcha_id(value: String) -> Result { CaptchaId::try_new(value).map_err(|error| error.to_string()) } +fn authorize_captcha_window( + window_label: &str, + challenge_id: Option<&CaptchaId>, +) -> Result<(), String> { + if window_label == "main" { + return Ok(()); + } + let challenge_id = challenge_id + .ok_or_else(|| "CAPTCHA browser window must specify its challenge".to_string())?; + if window_label != browser_window_label(challenge_id.as_str()) { + return Err("CAPTCHA browser window is not authorized for this challenge".into()); + } + Ok(()) +} + #[tauri::command] pub async fn captcha_list(state: State<'_, AppState>) -> Result, String> { state @@ -234,10 +262,12 @@ pub async fn captcha_list(state: State<'_, AppState>) -> Result, challenge_id: Option, ) -> Result, String> { let challenge_id = challenge_id.map(parse_captcha_id).transpose()?; + authorize_captcha_window(window.label(), challenge_id.as_ref())?; state .query_bus .handle_captcha_get_pending(CaptchaGetPendingQuery { challenge_id }) @@ -245,6 +275,45 @@ 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() + .await + .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 +1307,7 @@ pub struct SettingsDto { // CAPTCHA pub captcha_timeout_seconds: u32, + pub captcha_solver_order: Vec, // History pub history_retention_days: i64, @@ -1304,6 +1374,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, @@ -1329,6 +1400,9 @@ impl From for SettingsDto { } } +const MAX_CAPTCHA_SOLVER_ORDER_ENTRIES: usize = 3; +const MAX_CAPTCHA_SOLVER_IDENTIFIER_BYTES: usize = 128; + #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfigPatchDto { @@ -1355,6 +1429,7 @@ pub struct ConfigPatchDto { // CAPTCHA pub captcha_timeout_seconds: Option, + pub captcha_solver_order: Option>, // History pub history_retention_days: Option, @@ -1398,6 +1473,17 @@ impl TryFrom for ConfigPatch { type Error = String; fn try_from(d: ConfigPatchDto) -> Result { + if let Some(order) = &d.captcha_solver_order { + if order.len() > MAX_CAPTCHA_SOLVER_ORDER_ENTRIES { + return Err("CAPTCHA solver order exceeds safety limits".to_string()); + } + if order + .iter() + .any(|solver| solver.len() > MAX_CAPTCHA_SOLVER_IDENTIFIER_BYTES) + { + return Err("CAPTCHA solver identifier exceeds safety limits".to_string()); + } + } let account_selection_strategy = match d.account_selection_strategy.as_deref() { Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?), None => None, @@ -1421,6 +1507,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, @@ -3590,11 +3677,12 @@ pub async fn package_find_by_external_id( mod tests { use super::{ DEFAULT_DOWNLOAD_LOG_LIMIT, StreamResolution, ValidationOutcomeView, - configured_download_destination, configured_status_bar_path, extract_hostname_from_url, - load_plugin_media_metadata, parse_plugin_video_metadata, parse_soundcloud_metadata, - parse_soundcloud_playlist_targets, parse_stats_period, read_available_space, - resolve_download_log_limit, resolve_existing_disk_path, resolve_media_stream, - sanitize_extension, sanitize_filename, soundcloud_track_download_title, unique_destination, + authorize_captcha_window, configured_download_destination, configured_status_bar_path, + extract_hostname_from_url, load_plugin_media_metadata, parse_plugin_video_metadata, + parse_soundcloud_metadata, parse_soundcloud_playlist_targets, parse_stats_period, + read_available_space, resolve_download_log_limit, resolve_existing_disk_path, + resolve_media_stream, sanitize_extension, sanitize_filename, + soundcloud_track_download_title, unique_destination, }; use crate::adapters::driven::logging::download_log_store::DownloadLogStore; use crate::application::commands::ValidationOutcomeDto; @@ -4856,4 +4944,57 @@ mod tests { let patch: ConfigPatch = dto.try_into().expect("None strategy is valid"); assert!(patch.account_selection_strategy.is_none()); } + + #[test] + fn config_patch_dto_rejects_oversized_captcha_solver_order() { + use super::{ConfigPatch, ConfigPatchDto}; + + let dto = ConfigPatchDto { + captcha_solver_order: Some(vec!["solver".to_string(); 4]), + ..Default::default() + }; + + let result: Result = dto.try_into(); + assert!( + result + .expect_err("oversized order must be rejected") + .contains("solver order") + ); + } + + #[test] + fn config_patch_dto_rejects_oversized_captcha_solver_identifier() { + use super::{ConfigPatch, ConfigPatchDto}; + + let dto = ConfigPatchDto { + captcha_solver_order: Some(vec!["x".repeat(129)]), + ..Default::default() + }; + + let result: Result = dto.try_into(); + assert!( + result + .expect_err("oversized solver identifier must be rejected") + .contains("solver identifier") + ); + } + + #[test] + fn captcha_browser_window_is_bound_to_its_challenge() { + let own_id = crate::domain::model::captcha::CaptchaId::new("captcha-1"); + let other_id = crate::domain::model::captcha::CaptchaId::new("captcha-2"); + let label = crate::adapters::captcha_browser::browser_window_label(own_id.as_str()); + + assert!(authorize_captcha_window(&label, Some(&own_id)).is_ok()); + assert!(authorize_captcha_window(&label, Some(&other_id)).is_err()); + assert!(authorize_captcha_window(&label, None).is_err()); + } + + #[test] + fn main_window_can_manage_any_captcha() { + let id = crate::domain::model::captcha::CaptchaId::new("captcha-1"); + + assert!(authorize_captcha_window("main", Some(&id)).is_ok()); + assert!(authorize_captcha_window("main", None).is_ok()); + } } diff --git a/src-tauri/src/adapters/mod.rs b/src-tauri/src/adapters/mod.rs index d7a4872c..d6e5b762 100644 --- a/src-tauri/src/adapters/mod.rs +++ b/src-tauri/src/adapters/mod.rs @@ -1,4 +1,5 @@ //! Adapter layer — infrastructure implementations of domain ports. +pub(crate) mod captcha_browser; pub mod driven; pub mod driving; diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index 12f34f59..f937293a 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -408,6 +408,10 @@ impl CommandBus { self.credential_store.as_ref() } + pub(crate) fn credential_store_arc(&self) -> Arc { + Arc::clone(&self.credential_store) + } + pub fn clipboard_observer(&self) -> &dyn ClipboardObserver { self.clipboard_observer.as_ref() } diff --git a/src-tauri/src/application/commands/captcha.rs b/src-tauri/src/application/commands/captcha.rs index 7625725d..3fd86538 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,158 @@ 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(); + } + } + + fn dismiss_interaction(&self, id: &CaptchaId) { + if let Some(interaction) = &self.interaction + && let Err(error) = interaction.dismiss(id) + { + tracing::warn!(error = %error, "failed to dismiss CAPTCHA interaction"); + } + } + + 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(); + let cascade_stopped = token.is_cancelled() + || challenge.status() != CaptchaStatus::Pending + || challenge.is_expired(now); + challenge.record_solver_attempt(CaptchaSolverAttempt::new( + solver_name.clone(), + attempt_outcome, + attempted_at, + duration_ms, + ))?; + if cascade_stopped { + self.captchas.save(&challenge)?; + return Ok(()); + } + 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.dismiss_interaction(challenge.id()); + 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 +344,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 +396,7 @@ impl CaptchaCommandHandler { download_id: command.download_id, }); self.schedule_timeout(&challenge); - Ok(challenge.id().clone()) + Ok((challenge.id().clone(), true)) } } @@ -264,35 +407,40 @@ impl CommandHandler for CaptchaCommandHandler { let _guard = self.mutation_lock.lock().await; let mut challenge = self.find(&command.challenge_id)?; let now = self.clock.now_unix_ms(); + if challenge.status() != CaptchaStatus::Pending { + return Err(DomainError::ValidationError( + "CAPTCHA challenge is no longer pending".into(), + )); + } if challenge.is_expired(now) { return Err(DomainError::ValidationError( "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(), + )) } } @@ -406,7 +554,9 @@ impl CaptchaCommandHandler { } return Err(error); } + self.dismiss_interaction(challenge.id()); 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 +591,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 +667,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..60b326ae --- /dev/null +++ b/src-tauri/src/application/commands/captcha_credential.rs @@ -0,0 +1,186 @@ +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().to_string(); + 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(), + )); + } + let store = self.credential_store_arc(); + tokio::task::spawn_blocking(move || { + store.store( + CAPTCHA_SOLVER_ANTICAPTCHA, + &Credential::new("api-key", api_key), + ) + }) + .await + .map_err(|error| AppError::Storage(format!("credential task failed: {error}")))??; + Ok(()) + } + + pub async fn handle_delete_captcha_credential( + &self, + _command: DeleteCaptchaCredentialCommand, + ) -> Result<(), AppError> { + let store = self.credential_store_arc(); + tokio::task::spawn_blocking(move || store.delete(CAPTCHA_SOLVER_ANTICAPTCHA)) + .await + .map_err(|error| AppError::Storage(format!("credential task failed: {error}")))??; + Ok(()) + } + + pub async fn captcha_credential_configured(&self) -> Result { + let store = self.credential_store_arc(); + let credential = tokio::task::spawn_blocking(move || store.get(CAPTCHA_SOLVER_ANTICAPTCHA)) + .await + .map_err(|error| AppError::Storage(format!("credential task failed: {error}")))??; + Ok(credential.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().await.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().await.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); + } + + struct ThreadRecordingCredentialStore { + inner: InMemoryCredentialStore, + threads: std::sync::Mutex>, + } + + impl ThreadRecordingCredentialStore { + fn new() -> Self { + Self { + inner: InMemoryCredentialStore::new(), + threads: std::sync::Mutex::new(Vec::new()), + } + } + + fn record_thread(&self) { + self.threads + .lock() + .expect("thread log") + .push(std::thread::current().id()); + } + } + + impl CredentialStore for ThreadRecordingCredentialStore { + fn get( + &self, + service: &str, + ) -> Result, crate::domain::DomainError> + { + self.record_thread(); + self.inner.get(service) + } + + fn store( + &self, + service: &str, + credential: &crate::domain::model::credential::Credential, + ) -> Result<(), crate::domain::DomainError> { + self.record_thread(); + self.inner.store(service, credential) + } + + fn delete(&self, service: &str) -> Result<(), crate::domain::DomainError> { + self.record_thread(); + self.inner.delete(service) + } + } + + #[tokio::test(flavor = "current_thread")] + async fn credential_store_io_runs_outside_the_async_runtime_thread() { + let credentials = Arc::new(ThreadRecordingCredentialStore::new()); + let bus = build_credential_bus(credentials.clone()); + let runtime_thread = std::thread::current().id(); + + bus.handle_set_captcha_credential(SetCaptchaCredentialCommand { + api_key: "secret-api-key".into(), + }) + .await + .expect("store credential"); + assert!(bus.captcha_credential_configured().await.expect("status")); + bus.handle_delete_captcha_credential(DeleteCaptchaCredentialCommand) + .await + .expect("delete credential"); + + let io_threads = credentials.threads.lock().expect("thread log"); + assert_eq!(io_threads.len(), 3); + assert!(io_threads.iter().all(|thread| *thread != runtime_thread)); + } +} diff --git a/src-tauri/src/application/commands/captcha_tests.rs b/src-tauri/src/application/commands/captcha_tests.rs index 17018176..560a0538 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,99 @@ impl CaptchaSolver for FailingCaptchaSolver { } } +struct RecordedCaptchaSolver { + name: &'static str, + outcome: CaptchaSolverOutcome, + calls: Arc>>, +} + +struct BlockingCaptchaSolver { + started: Arc, + release: Arc, +} + +struct BlockingSolverRelease(Arc); + +impl BlockingSolverRelease { + fn release(&self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl Drop for BlockingSolverRelease { + fn drop(&mut self) { + self.release(); + } +} + +impl CaptchaSolver for BlockingCaptchaSolver { + fn name(&self) -> &str { + CAPTCHA_SOLVER_OCR + } + + fn solve( + &self, + _challenge: &CaptchaChallenge, + _solution: &str, + ) -> Result { + self.started.store(true, Ordering::SeqCst); + while !self.release.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + Ok(CaptchaSolverOutcome::Rejected) + } +} + +#[derive(Default)] +struct RecordingCaptchaInteraction { + requested: Mutex>, + dismissed: Mutex>, +} + +impl CaptchaInteraction for RecordingCaptchaInteraction { + fn request(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError> { + self.requested.lock().unwrap().push(challenge.id().clone()); + Ok(()) + } + + fn dismiss(&self, challenge_id: &CaptchaId) -> Result<(), DomainError> { + self.dismissed.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 +291,235 @@ 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_BROWSER, + outcome: CaptchaSolverOutcome::InteractionRequired, + 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_OCR, + outcome: CaptchaSolverOutcome::Unavailable, + 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 late_automatic_result_is_logged_after_manual_resolution() { + let started = Arc::new(AtomicBool::new(false)); + let release = Arc::new(AtomicBool::new(false)); + let release_guard = BlockingSolverRelease(release.clone()); + let config = AppConfig { + captcha_solver_order: vec![CAPTCHA_SOLVER_OCR.into()], + ..AppConfig::default() + }; + let (handler, captchas, _, _, _) = fixture_with_config_and_solvers( + config, + vec![Arc::new(BlockingCaptchaSolver { + started: started.clone(), + release: release.clone(), + })], + ); + let id = enqueue(&handler).await; + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !started.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("automatic solver should start"); + + >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "manual-answer".into(), + }, + ) + .await + .expect("manual resolution"); + release_guard.release(); + wait_for_solver_attempts(&captchas, &id, 2).await; + + let stored = captchas.find_by_id(&id).unwrap().unwrap(); + assert_eq!(stored.status(), CaptchaStatus::Solved); + assert_eq!(stored.solver(), Some("manual")); + assert_eq!( + stored + .solver_attempts() + .iter() + .map(|attempt| (attempt.solver(), attempt.outcome())) + .collect::>(), + vec![ + ("manual", CaptchaSolverAttemptOutcome::Solved), + (CAPTCHA_SOLVER_OCR, CaptchaSolverAttemptOutcome::Rejected), + ] + ); +} + +#[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.solver(), attempt.outcome())) + .collect::>(), + vec![ + ("failing", CaptchaSolverAttemptOutcome::Failed), + ( + CAPTCHA_SOLVER_ANTICAPTCHA, + CaptchaSolverAttemptOutcome::Rejected, + ), + ( + CAPTCHA_SOLVER_BROWSER, + CaptchaSolverAttemptOutcome::InteractionRequired, + ), + ] + ); + assert_eq!( + downloads + .find_by_id(DownloadId(42)) + .unwrap() + .unwrap() + .state(), + DownloadState::Waiting + ); +} + +#[tokio::test] +async fn browser_fallback_is_requested_and_dismissed_by_the_terminal_flow() { + 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.requested.lock().unwrap().as_slice() == [id.clone()] { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("browser interaction should be requested"); + + >::handle( + &handler, + SkipCaptchaCommand { + challenge_id: id.clone(), + }, + ) + .await + .expect("terminal flow succeeds"); + + assert_eq!(interaction.dismissed.lock().unwrap().as_slice(), [id]); +} + +#[tokio::test] +async fn solved_flow_dismisses_the_assisted_window_directly() { + let interaction = Arc::new(RecordingCaptchaInteraction::default()); + let (handler, _, _, _, _) = fixture(); + let handler = handler.with_interaction(interaction.clone()); + let id = enqueue(&handler).await; + + >::handle( + &handler, + SolveCaptchaCommand { + challenge_id: id.clone(), + solution: "manual-answer".into(), + }, + ) + .await + .expect("manual resolution succeeds"); + + assert_eq!(interaction.dismissed.lock().unwrap().as_slice(), [id]); +} + #[tokio::test] async fn enqueue_failure_restores_the_download_and_emits_a_safe_failure() { let (handler, captchas, downloads, events, _) = fixture(); @@ -250,6 +584,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..a7c0d97a 100644 --- a/src-tauri/src/application/read_models/captcha_view.rs +++ b/src-tauri/src/application/read_models/captcha_view.rs @@ -1,7 +1,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::Serialize; -use crate::domain::model::captcha::{CaptchaChallenge, captcha_image_mime_type}; +use crate::domain::model::captcha::{ + CaptchaChallenge, MAX_EXPOSED_CAPTCHA_SOLVER_ATTEMPTS, captcha_image_mime_type, +}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -15,6 +17,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 +25,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) @@ -38,6 +50,10 @@ impl CaptchaViewDto { .then(|| challenge.image_data().and_then(captcha_image_mime_type)) .flatten() .map(str::to_string); + let solver_attempts = challenge.solver_attempts(); + let exposed_solver_attempts = &solver_attempts[solver_attempts + .len() + .saturating_sub(MAX_EXPOSED_CAPTCHA_SOLVER_ATTEMPTS)..]; Self { id: challenge.id().to_string(), download_id: challenge.download_id().0, @@ -50,6 +66,15 @@ impl CaptchaViewDto { status: challenge.status().to_string(), solver: challenge.solver().map(str::to_string), attempts: challenge.attempts(), + solver_attempts: exposed_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 +87,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 +117,48 @@ 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()); + } + + #[test] + fn solver_attempt_projection_keeps_only_the_latest_entries() { + let mut challenge = challenge(); + for index in 0..=64 { + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + format!("solver-{index}"), + CaptchaSolverAttemptOutcome::Failed, + 1_100 + index, + 25, + )) + .expect("attempt"); + } + + let detail = CaptchaViewDto::from(challenge); + + assert_eq!(detail.solver_attempts.len(), 64); + assert_eq!(detail.solver_attempts[0].solver, "solver-1"); + assert_eq!(detail.solver_attempts[63].solver, "solver-64"); + } } diff --git a/src-tauri/src/domain/model/captcha.rs b/src-tauri/src/domain/model/captcha.rs index 75163309..1980bd5b 100644 --- a/src-tauri/src/domain/model/captcha.rs +++ b/src-tauri/src/domain/model/captcha.rs @@ -9,6 +9,8 @@ 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; +pub const MAX_EXPOSED_CAPTCHA_SOLVER_ATTEMPTS: usize = 64; const REDACTED_CAPTCHA_URL: &str = "[redacted]"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -38,6 +40,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 +140,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 +226,7 @@ pub struct CaptchaChallenge { status: CaptchaStatus, solver: Option, attempts: u32, + solver_attempts: Vec, created_at: u64, expires_at: u64, resolved_at: Option, @@ -138,6 +243,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 +278,7 @@ impl CaptchaChallenge { status: CaptchaStatus::Pending, solver: None, attempts: 0, + solver_attempts: Vec::new(), created_at, expires_at, resolved_at: None, @@ -192,6 +299,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 +364,19 @@ impl CaptchaChallenge { Ok(()) } + pub fn record_solver_attempt( + &mut self, + attempt: CaptchaSolverAttempt, + ) -> Result<(), DomainError> { + 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,127 @@ 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 solver_attempt_history_keeps_every_audit_entry() { + let mut challenge = make_challenge(); + + for index in 0..=64 { + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + format!("solver-{index}"), + CaptchaSolverAttemptOutcome::Failed, + 1_100 + index, + 10, + )) + .expect("record solver attempt"); + } + + assert_eq!(challenge.solver_attempts().len(), 65); + assert_eq!(challenge.solver_attempts()[0].solver(), "solver-0"); + assert_eq!(challenge.solver_attempts()[64].solver(), "solver-64"); + } + + #[test] + fn late_solver_attempt_can_be_recorded_after_manual_resolution() { + let mut challenge = make_challenge(); + challenge + .solve(1_100, "manual") + .expect("manual resolution succeeds"); + + challenge + .record_solver_attempt(CaptchaSolverAttempt::new( + "vortex-mod-captcha-ocr", + CaptchaSolverAttemptOutcome::Rejected, + 1_050, + 100, + )) + .expect("completed automatic attempt remains auditable"); + + assert_eq!(challenge.solver_attempts().len(), 1); + assert_eq!( + challenge.solver_attempts()[0].outcome(), + CaptchaSolverAttemptOutcome::Rejected + ); + } + #[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..18292ee2 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,41 @@ 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 { + if raw.is_empty() { + return Vec::new(); + } + 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()); + } + } + if normalized.is_empty() { + default_captcha_solver_order() + } else { + normalized + } +} /// Lower bound for `link_check_parallelism`. Below 1 the queue stalls. pub const MIN_LINK_CHECK_PARALLELISM: u32 = 1; @@ -234,6 +272,7 @@ pub struct ConfigPatch { // CAPTCHA pub captcha_timeout_seconds: Option, + pub captcha_solver_order: Option>, // History pub history_retention_days: Option, @@ -363,6 +402,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 +585,71 @@ 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 nonempty_invalid_captcha_solver_order_falls_back_to_defaults() { + let mut config = AppConfig::default(); + apply_patch( + &mut config, + &ConfigPatch { + captcha_solver_order: Some(vec!["unknown".to_string(), "also-unknown".to_string()]), + ..Default::default() + }, + ); + + assert_eq!(config.captcha_solver_order, default_captcha_solver_order()); + } + #[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..cdb37ee4 --- /dev/null +++ b/src-tauri/src/domain/ports/driven/captcha_interaction.rs @@ -0,0 +1,8 @@ +use crate::domain::error::DomainError; +use crate::domain::model::captcha::{CaptchaChallenge, CaptchaId}; + +/// Controls the local human-assisted UI for a CAPTCHA challenge. +pub trait CaptchaInteraction: Send + Sync { + fn request(&self, challenge: &CaptchaChallenge) -> Result<(), DomainError>; + fn dismiss(&self, challenge_id: &CaptchaId) -> 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..e22ecdf8 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 availability is checked automatically. 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..a670e974 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": "La disponibilité de Tesseract est vérifiée automatiquement. 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 ? (