From bbd86fbdaf8925bcd58556c249267bfeef330193 Mon Sep 17 00:00:00 2001 From: JasonHonKL Date: Sat, 4 Apr 2026 14:54:16 +0800 Subject: [PATCH 1/2] tauri-dev --- crates/pardus-cdp/src/domain/pardus_ext.rs | 79 +++++++++++----------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/crates/pardus-cdp/src/domain/pardus_ext.rs b/crates/pardus-cdp/src/domain/pardus_ext.rs index c880d92..ce85005 100644 --- a/crates/pardus-cdp/src/domain/pardus_ext.rs +++ b/crates/pardus-cdp/src/domain/pardus_ext.rs @@ -307,63 +307,66 @@ async fn handle_interact( fields_param: &Option, ctx: &DomainContext, ) -> Value { + // Resolve selector: if it looks like #N (element_id from semantic tree), + // use find_by_element_id. Otherwise treat as CSS selector. + let page_data = match get_page_data(ctx, target_id).await { + Some(d) => d, + None => return serde_json::json!({ "success": false, "error": "No active page" }), + }; + let (html_str, url) = &page_data; + let page = pardus_core::Page::from_html(html_str, url); + + // Try element_id lookup first when selector is "#N" + let handle = if let Some(num) = selector.strip_prefix('#') { + if let Ok(id) = num.parse::() { + page.find_by_element_id(id) + } else { + page.query(selector) + } + } else { + page.query(selector) + }; + match action { "click" => { - let page_data = get_page_data(ctx, target_id).await; - let href = page_data.as_ref().and_then(|(html_str, url)| { - let page = pardus_core::Page::from_html(&html_str, &url); - page.query(selector).and_then(|el| el.href.clone()) - }); - - if let Some(href) = href { - match ctx.navigate(target_id, &href).await { + let Some(h) = handle else { + return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); + }; + if let Some(href) = &h.href { + match ctx.navigate(target_id, href).await { Ok(()) => serde_json::json!({ "success": true, "action": "click", "selector": selector }), Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), } - } else if let Some((html_str, url)) = page_data { - let page = pardus_core::Page::from_html(&html_str, &url); - let exists = page.query(selector).is_some(); - if exists { - serde_json::json!({ "success": true, "action": "click", "selector": selector, "note": "Element exists but is not a link" }) + } else { + // Non-link element: check if interactive + if h.action.is_some() { + serde_json::json!({ "success": true, "action": "click", "selector": selector, "tag": h.tag }) } else { - serde_json::json!({ "success": false, "error": "Element not found" }) + serde_json::json!({ "success": true, "action": "click", "selector": selector, "note": "Element exists but is not a link" }) } - } else { - serde_json::json!({ "success": false, "error": "No active page" }) } } "type" => { - match get_page_data(ctx, target_id).await { - Some((html_str, url)) => { - let page = pardus_core::Page::from_html(&html_str, &url); - match page.query(selector) { - Some(handle) => { - match pardus_core::interact::actions::type_text(&page, &handle, value) { - Ok(_) => serde_json::json!({ "success": true, "action": "type", "selector": selector }), - Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), - } - } - None => serde_json::json!({ "success": false, "error": "Element not found" }), - } - } - None => serde_json::json!({ "success": false, "error": "No active page" }), + let Some(h) = handle else { + return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); + }; + match pardus_core::interact::actions::type_text(&page, &h, value) { + Ok(_) => serde_json::json!({ "success": true, "action": "type", "selector": selector }), + Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), } } "submit" => { - let form_found = get_page_data(ctx, target_id).await - .map(|(html_str, url)| { - let page = pardus_core::Page::from_html(&html_str, &url); - page.query(selector).is_some() - }) - .unwrap_or(false); - - if form_found { + if handle.is_some() { let _ = fields_param; serde_json::json!({ "success": true, "action": "submit", "selector": selector, "note": "Form element found" }) } else { serde_json::json!({ "success": false, "error": "Form not found" }) } } + "scroll" => { + // Scroll is handled client-side; just acknowledge + serde_json::json!({ "success": true, "action": "scroll" }) + } _ => serde_json::json!({ "success": false, "error": format!("Unknown action '{}'", action) From b2fbe70872a7d5f1b8071bcb9e1550e549a30fc5 Mon Sep 17 00:00:00 2001 From: JasonHonKL Date: Tue, 7 Apr 2026 16:07:11 +0800 Subject: [PATCH 2/2] update --- .editorconfig | 27 + .github/workflows/ci.yml | 56 +- .gitignore | 13 +- .nvmrc | 1 + Cargo.lock | 8431 +++++++++++++++++ ROADMAP.md | 40 +- adapters/node/pardus-playwright/src/index.ts | 33 +- adapters/node/pardus-puppeteer/src/index.ts | 36 +- ai-agent/pardus-browser/eslint.config.js | 26 + ai-agent/pardus-browser/package-lock.json | 1063 +++ ai-agent/pardus-browser/src/agent/Agent.ts | 18 + .../src/core/BrowserInstance.ts | 19 +- .../pardus-browser/src/core/CookieStore.ts | 7 +- ai-agent/pardus-browser/src/index.ts | 10 +- ai-agent/pardus-browser/src/llm/client.ts | 18 +- ai-agent/pardus-browser/src/llm/context.ts | 37 +- ai-agent/pardus-browser/src/llm/prompts.ts | 2 +- ai-agent/pardus-browser/src/sidecar-test.ts | 232 + ai-agent/pardus-browser/src/sidecar.ts | 225 + .../pardus-browser/src/tools/definitions.ts | 122 +- ai-agent/pardus-browser/src/tools/executor.ts | 626 +- ai-agent/pardus-browser/src/tools/types.ts | 17 +- clippy.toml | 15 + crates/pardus-cdp/Cargo.toml | 3 + crates/pardus-cdp/src/domain/mod.rs | 9 + crates/pardus-cdp/src/domain/oauth.rs | 551 ++ crates/pardus-cdp/src/domain/page.rs | 149 +- crates/pardus-cdp/src/domain/pardus_ext.rs | 342 +- crates/pardus-cdp/src/domain/target.rs | 1 + crates/pardus-cdp/src/server.rs | 8 +- .../pardus-cdp/tests/domain_context_test.rs | 100 +- .../tests/domain_context_unit_test.rs | 16 +- .../pardus-cdp/tests/pardus_interact_test.rs | 582 ++ crates/pardus-cli/src/commands/clean.rs | 9 +- crates/pardus-cli/src/commands/interact.rs | 25 +- crates/pardus-cli/src/commands/map.rs | 1 - crates/pardus-cli/src/commands/mod.rs | 4 +- crates/pardus-cli/src/commands/navigate.rs | 45 +- crates/pardus-cli/src/commands/repl.rs | 456 +- crates/pardus-cli/src/commands/screenshot.rs | 12 +- crates/pardus-cli/src/commands/serve.rs | 7 +- crates/pardus-cli/src/commands/tab.rs | 13 +- crates/pardus-cli/src/config.rs | 3 +- crates/pardus-cli/src/context.rs | 75 - crates/pardus-cli/src/logging.rs | 23 - crates/pardus-cli/src/main.rs | 307 +- crates/pardus-core/Cargo.toml | 2 + crates/pardus-core/src/app.rs | 130 +- crates/pardus-core/src/browser/helpers.rs | 43 +- crates/pardus-core/src/browser/history.rs | 43 +- crates/pardus-core/src/browser/interact.rs | 125 +- crates/pardus-core/src/browser/mod.rs | 88 +- crates/pardus-core/src/browser/navigate.rs | 77 +- crates/pardus-core/src/browser/tabs.rs | 28 +- crates/pardus-core/src/browser/traits.rs | 16 +- crates/pardus-core/src/config.rs | 36 +- crates/pardus-core/src/frame/mod.rs | 139 +- crates/pardus-core/src/interact/element.rs | 116 +- crates/pardus-core/src/interact/form.rs | 5 +- .../pardus-core/src/interact/js_interact.rs | 134 +- crates/pardus-core/src/interact/scroll.rs | 16 +- crates/pardus-core/src/interact/wait.rs | 100 +- crates/pardus-core/src/intercept/builtins.rs | 141 +- crates/pardus-core/src/intercept/rules.rs | 31 +- crates/pardus-core/src/js/dom.rs | 503 + crates/pardus-core/src/js/fetch.rs | 237 + crates/pardus-core/src/js/runtime.rs | 454 +- crates/pardus-core/src/js/snapshot.rs | 13 +- crates/pardus-core/src/js/timer.rs | 188 + crates/pardus-core/src/lib.rs | 9 +- crates/pardus-core/src/oauth/flow.rs | 240 + crates/pardus-core/src/oauth/mod.rs | 18 + crates/pardus-core/src/oauth/oidc.rs | 94 + crates/pardus-core/src/oauth/pkce.rs | 87 + crates/pardus-core/src/oauth/store.rs | 395 + crates/pardus-core/src/oauth/token.rs | 240 + crates/pardus-core/src/page.rs | 968 +- crates/pardus-core/src/parser/mod.rs | 2 + .../src/parser/streaming_semantic.rs | 440 + crates/pardus-core/src/resource/fetcher.rs | 101 +- crates/pardus-core/src/resource/mod.rs | 29 +- crates/pardus-core/src/resource/pool.rs | 36 +- crates/pardus-core/src/resource/priority.rs | 47 +- crates/pardus-core/src/resource/scheduler.rs | 199 +- crates/pardus-core/src/semantic/extract.rs | 291 + crates/pardus-core/src/semantic/mod.rs | 7 + crates/pardus-core/src/semantic/selector.rs | 115 + crates/pardus-core/src/semantic/tree.rs | 410 +- crates/pardus-core/src/session.rs | 47 +- crates/pardus-core/src/tab/manager.rs | 121 +- crates/pardus-core/src/tab/tab.rs | 72 +- crates/pardus-core/tests/config_test.rs | 6 +- crates/pardus-core/tests/csp_test.rs | 272 + .../pardus-core/tests/dedup_extended_test.rs | 216 + crates/pardus-core/tests/feed_test.rs | 97 + .../tests/semantic_extract_test.rs | 564 ++ crates/pardus-kg/src/crawler.rs | 46 +- crates/pardus-server/src/router.rs | 26 +- crates/pardus-server/src/state.rs | 182 +- .../pardus-tauri/frontend/package-lock.json | 2893 ++++++ crates/pardus-tauri/frontend/src/App.tsx | 192 +- crates/pardus-tauri/frontend/src/api/tauri.ts | 103 + .../frontend/src/components/ActionLog.tsx | 78 +- .../frontend/src/components/AgentCard.tsx | 126 + .../frontend/src/components/AgentGrid.tsx | 75 + .../frontend/src/components/AgentSidebar.tsx | 191 +- .../src/components/ChallengePanel.tsx | 105 +- .../frontend/src/components/ChatPanel.tsx | 319 + .../src/components/GlobalActionStream.tsx | 86 + .../src/components/InstanceHeader.tsx | 133 +- .../src/components/InteractionBar.tsx | 323 +- .../frontend/src/components/TakeOverBar.tsx | 62 + .../frontend/src/components/TreeViewer.tsx | 200 +- .../frontend/src/context/AgentContext.tsx | 755 +- crates/pardus-tauri/frontend/src/index.css | 1110 ++- crates/pardus-tauri/frontend/src/types.ts | 107 + .../frontend/src/utils/classifyEvent.ts | 113 + .../frontend/tsconfig.tsbuildinfo | 2 +- .../src-tauri/src/agent_bridge.rs | 477 + .../src-tauri/src/browser_window.rs | 295 +- .../pardus-tauri/src-tauri/src/cdp_bridge.rs | 37 +- .../pardus-tauri/src-tauri/src/challenge.rs | 109 +- crates/pardus-tauri/src-tauri/src/commands.rs | 395 +- .../src-tauri/src/cookie_bridge.rs | 5 +- crates/pardus-tauri/src-tauri/src/lib.rs | 219 +- crates/pardus-tauri/src/events.ts | 27 +- rust-toolchain.toml | 3 +- rustfmt.toml | 13 + web/src/App.css | 184 - web/src/App.tsx | 7 +- web/src/assets/react.svg | 1 - web/src/assets/vite.svg | 1 - web/src/components/CookieInspector.tsx | 4 +- web/src/components/NetworkLog.tsx | 4 +- web/src/components/TreeViewer.tsx | 4 +- web/src/hooks/useEvents.ts | 8 +- 136 files changed, 26447 insertions(+), 4176 deletions(-) create mode 100644 .editorconfig create mode 100644 .nvmrc create mode 100644 Cargo.lock create mode 100644 ai-agent/pardus-browser/eslint.config.js create mode 100644 ai-agent/pardus-browser/package-lock.json create mode 100644 ai-agent/pardus-browser/src/sidecar-test.ts create mode 100644 ai-agent/pardus-browser/src/sidecar.ts create mode 100644 clippy.toml create mode 100644 crates/pardus-cdp/src/domain/oauth.rs create mode 100644 crates/pardus-cdp/tests/pardus_interact_test.rs delete mode 100644 crates/pardus-cli/src/context.rs delete mode 100644 crates/pardus-cli/src/logging.rs create mode 100644 crates/pardus-core/src/oauth/flow.rs create mode 100644 crates/pardus-core/src/oauth/mod.rs create mode 100644 crates/pardus-core/src/oauth/oidc.rs create mode 100644 crates/pardus-core/src/oauth/pkce.rs create mode 100644 crates/pardus-core/src/oauth/store.rs create mode 100644 crates/pardus-core/src/oauth/token.rs create mode 100644 crates/pardus-core/src/parser/streaming_semantic.rs create mode 100644 crates/pardus-core/src/semantic/extract.rs create mode 100644 crates/pardus-core/src/semantic/selector.rs create mode 100644 crates/pardus-core/tests/csp_test.rs create mode 100644 crates/pardus-core/tests/dedup_extended_test.rs create mode 100644 crates/pardus-core/tests/feed_test.rs create mode 100644 crates/pardus-core/tests/semantic_extract_test.rs create mode 100644 crates/pardus-tauri/frontend/package-lock.json create mode 100644 crates/pardus-tauri/frontend/src/components/AgentCard.tsx create mode 100644 crates/pardus-tauri/frontend/src/components/AgentGrid.tsx create mode 100644 crates/pardus-tauri/frontend/src/components/ChatPanel.tsx create mode 100644 crates/pardus-tauri/frontend/src/components/GlobalActionStream.tsx create mode 100644 crates/pardus-tauri/frontend/src/components/TakeOverBar.tsx create mode 100644 crates/pardus-tauri/frontend/src/utils/classifyEvent.ts create mode 100644 crates/pardus-tauri/src-tauri/src/agent_bridge.rs create mode 100644 rustfmt.toml delete mode 100644 web/src/App.css delete mode 100644 web/src/assets/react.svg delete mode 100644 web/src/assets/vite.svg diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5ce84da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{ts,tsx,js,jsx,mjs,cjs}] +indent_size = 2 + +[*.{rs,toml}] +indent_size = 4 + +[*.{py,pyi}] +indent_size = 4 + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ded9737..690c1e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,35 +7,71 @@ on: branches: [main, master] jobs: - test: + rust-lint: + name: rust lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --all -- --check + - name: Run clippy + run: cargo clippy --all-features -- -D warnings + + rust-test: name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: rust-lint strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v4 - - - name: Install Rust (nightly) - uses: dtolnay/rust-toolchain@nightly - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.os }} - - name: Install system dependencies (linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends libssl-dev pkg-config libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev - - name: Install LLVM (macOS) if: runner.os == 'macOS' run: brew install llvm - - name: Run tests env: LIBCLANG_PATH: ${{ runner.os == 'macOS' && '/opt/homebrew/opt/llvm/lib' || '' }} run: cargo test --all-features + + ts-lint-test: + name: ts lint & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install ai-agent dependencies + run: npm ci + working-directory: ai-agent/pardus-browser + - name: Typecheck ai-agent + run: npm run lint + working-directory: ai-agent/pardus-browser + - name: Test ai-agent + run: npm test + working-directory: ai-agent/pardus-browser + + security-audit: + name: security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-audit + run: cargo install cargo-audit + - name: Run cargo audit + run: cargo audit --ignore RUSTSEC-2024-0438 diff --git a/.gitignore b/.gitignore index a203ad1..adb3732 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ /target **/*.rs.bk **/*.bak -Cargo.lock .DS_Store **/.DS_Store @@ -29,8 +28,8 @@ web/dist/ ai-agent/pardus-browser/node_modules/ ai-agent/pardus-browser/dist/ -# Lockfiles -**/package-lock.json +# Lockfiles (committed for reproducibility) +# If you need to regenerate: rm **/package-lock.json && npm install .env .env.* @@ -38,3 +37,11 @@ ai-agent/pardus-browser/dist/ # Research benchmark data research/ +bench-results/ + +# Tool caches +.ruff_cache/ + +# AI coding assistant configs (local only) +.opencode/ +.claude/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..05e51fd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8431 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "antidote" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34fde25430d87a9388dadbe6e34d7f72a462c8b43ac8d309b42b0a8505d7e2a5" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.28.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "boring-sys2" +version = "4.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6c876a958d17057d9d3a31075bb9609237413a1fe665432782c31ef596eb6b" +dependencies = [ + "autocfg", + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "boring2" +version = "4.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4470e96bd94533c2f88c08be95a8e6d2d37a3b497a773b0a46273a376978f00" +dependencies = [ + "bitflags 2.11.0", + "boring-sys2", + "brotli", + "flate2", + "foreign-types", + "libc", + "openssl-macros", + "zstd", +] + +[[package]] +name = "boxed_error" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d4f95e880cfd28c4ca5a006cf7f6af52b4bcb7b5866f573b2faa126fb7affb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "capacity_builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2d24a6dcf0cd402a21b65d35340f3a49ff3475dc5fdac91d22d2733e6641c6" +dependencies = [ + "capacity_builder_macros", + "itoa", +] + +[[package]] +name = "capacity_builder_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chromiumoxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ed067eb6c1f660bdb87c05efb964421d2ca262bae0296cdfe38cf0cd949a3e" +dependencies = [ + "async-tungstenite", + "base64 0.22.1", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "which 8.0.2", + "windows-registry 0.6.1", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a6a03a7ebac4ea85308f285d6959a3e6b2ce32a0c9465dc7a7b1db0144eec7" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c602dea92337bc4d824668d78c5b79c3b4ddb29b40dd7218282bbe8fd3fc2091" +dependencies = [ + "chromiumoxide_types", + "either", + "heck 0.5.0", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678d5146e74f16fc4a41978b275af572cd913de1f10270d2b93b6c276bc57d80" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cooked-waker" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "deno_core" +version = "0.396.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c94ad6b829e0dbb2304eea3e1646a8d8fa473cac1fd56d644756eef7b492b98" +dependencies = [ + "anyhow", + "az", + "bincode", + "bit-set", + "bit-vec", + "boxed_error", + "bytes", + "capacity_builder", + "cooked-waker", + "deno_core_icudata", + "deno_error", + "deno_ops", + "deno_path_util", + "deno_unsync", + "futures", + "indexmap 2.13.1", + "inventory", + "libc", + "parking_lot", + "percent-encoding", + "pin-project", + "serde", + "serde_json", + "serde_v8", + "smallvec", + "sourcemap", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "url", + "v8", + "wasm_dep_analyzer", +] + +[[package]] +name = "deno_core_icudata" +version = "0.77.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9efff8990a82c1ae664292507e1a5c6749ddd2312898cdf9cd7cb1fd4bc64c6" + +[[package]] +name = "deno_error" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfafd2219b29886a71aecbb3449e462deed1b2c474dc5b12f855f0e58c478931" +dependencies = [ + "deno_error_macro", + "libc", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "deno_error_macro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deno_ops" +version = "0.272.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a24aef19861bde7045a990f24fa74a3448cda0bfe8a43f6bf287089f1e3d95" +dependencies = [ + "indexmap 2.13.1", + "proc-macro2", + "quote", + "stringcase", + "strum", + "strum_macros", + "syn 2.0.117", + "syn-match", + "thiserror 2.0.18", +] + +[[package]] +name = "deno_path_util" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c7e98943f0d068928906db0c7bde89de684fa32c6a8018caacc4cee2cdd72b" +dependencies = [ + "deno_error", + "percent-encoding", + "sys_traits", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "deno_unsync" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6742a724e8becb372a74c650a1aefb8924a5b8107f7d75b3848763ea24b27a87" +dependencies = [ + "futures-util", + "parking_lot", + "tokio", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "diplomat" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diplomat-runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" + +[[package]] +name = "diplomat_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 2.0.117", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "feed-rs" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" +dependencies = [ + "chrono", + "mediatype", + "quick-xml 0.37.5", + "regex", + "serde", + "serde_json", + "siphasher 1.0.2", + "url", + "uuid", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http2" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e23815f8ec982e1452e1d0fda921ec20a9187fb610ad003c90cc5abd65b2c4" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyper2" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a1bb25e0cbdbe7b21f8ef6c3c4785f147d79e7ded438b5ee2b70e380183294" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "http2", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_calendar" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ixdtf" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ceaf4c6c48465bead8cb6a0b7c4ee0c86ecbb31239032b9c66ab9a08d2f3ee1" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.52.0", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.1", + "selectors 0.24.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lol_html" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "memchr", + "mime", + "precomputed-hash", + "selectors 0.33.0", + "thiserror 2.0.18", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap 2.13.1", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + +[[package]] +name = "lopdf" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f560f57dfb9142a02d673e137622fd515d4231e51feb8b4af28d92647d83f35b" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap 2.13.1", + "itoa", + "jiff", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror 2.0.18", + "time", + "ttf-parser", + "weezl", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "mediatype" +version = "0.19.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +dependencies = [ + "serde", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.2", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pardus-cdp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "futures-util", + "mockito", + "pardus-core", + "pardus-debug", + "parking_lot", + "rquest", + "scraper", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.26.2", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "pardus-challenge" +version = "0.1.0" +dependencies = [ + "async-trait", + "pardus-core", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "pardus-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "pardus-cdp", + "pardus-core", + "pardus-debug", + "pardus-kg", + "rustyline", + "serde_json", + "shell-words", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pardus-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "blake3", + "bumpalo", + "bytes", + "chromiumoxide", + "chrono", + "cookie_store", + "dashmap", + "deno_core", + "dirs", + "encoding_rs", + "fastrand", + "feed-rs", + "futures-util", + "getrandom 0.3.4", + "h2", + "http", + "image", + "lol_html", + "lopdf 0.39.0", + "lru 0.12.5", + "mime_guess", + "pardus-debug", + "parking_lot", + "pdf-extract", + "regex", + "rquest", + "rquest-util", + "rustls", + "scraper", + "serde", + "serde_json", + "sha2", + "smallvec", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-tungstenite 0.26.2", + "tracing", + "tungstenite 0.26.2", + "url", + "which 7.0.3", +] + +[[package]] +name = "pardus-debug" +version = "0.1.0" +dependencies = [ + "chrono", + "http", + "rquest", + "scraper", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "pardus-kg" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "chrono", + "futures", + "pardus-core", + "pardus-debug", + "scraper", + "serde", + "serde_json", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "pardus-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "futures-util", + "include_dir", + "pardus-core", + "pardus-debug", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pardus-tauri" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures-util", + "pardus-cdp", + "pardus-challenge", + "pardus-core", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-shell", + "tokio", + "tokio-tungstenite 0.26.2", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf 0.38.0", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.1", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.10+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" +dependencies = [ + "potential_utf", + "serde_core", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rquest" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "821ab2b3866cc43b553364566edf7387aea98b28b87213d8a889fc2504b3b8b0" +dependencies = [ + "antidote", + "arc-swap", + "async-compression", + "base64 0.22.1", + "boring2", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper2", + "ipnet", + "linked_hash_set", + "log", + "lru 0.13.0", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "socket2 0.5.10", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-boring2", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "typed-builder", + "url", + "webpki-root-certs 0.26.11", + "windows-registry 0.5.3", +] + +[[package]] +name = "rquest-util" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e3762f6e3e0d1674a6e74ebcbcb3b8d56c895757fc2cc2aa0d5e62ccfcb21e" +dependencies = [ + "rquest", + "typed-builder", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.59.0", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" +dependencies = [ + "cssparser 0.34.0", + "ego-tree", + "getopts", + "html5ever 0.29.1", + "precomputed-hash", + "selectors 0.26.0", + "tendril 0.4.3", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.34.0", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.11.3", + "phf_codegen 0.11.3", + "precomputed-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.13.1", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_v8" +version = "0.305.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfad8addfd4ccfc315cb382b5feae9b4694f0c075123c519bdbd8c6530a466a6" +dependencies = [ + "deno_error", + "num-bigint", + "serde", + "smallvec", + "thiserror 2.0.18", + "v8", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "sourcemap" +version = "9.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "314d62a489431668f719ada776ca1d49b924db951b7450f8974c9ae51ab05ad7" +dependencies = [ + "base64-simd", + "bitvec", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringcase" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72abeda133c49d7bddece6c154728f83eec8172380c80ab7096da9487e20d27c" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-match" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sys_traits" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a79feaa49de4a6c8191bdbd5fb3eada50671e9367d874d1c12e3d36db131414" +dependencies = [ + "sys_traits_macros", +] + +[[package]] +name = "sys_traits_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "temporal_capi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.1.2" +dependencies = [ + "core_maths", + "icu_calendar", + "icu_locale", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "timezone_provider" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-boring2" +version = "4.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3866209c48e3d69e709a9d6105d7e2aa34e4374bc5dcaec32e0f8e53a28db14a" +dependencies = [ + "boring-sys2", + "boring2", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.1", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.1", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap 2.13.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + +[[package]] +name = "typed-builder" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v8" +version = "147.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98a7a54a2cbb941924658340efeff052840ab2dcb925c34260142ba85310023" +dependencies = [ + "bindgen", + "bitflags 2.11.0", + "fslock", + "gzip-header", + "home", + "miniz_oxide", + "paste", + "temporal_capi", + "which 6.0.3", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.1", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_dep_analyzer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a10e6b67c951a84de7029487e0e0a496860dae49f6699edd279d5ff35b8fbf54" +dependencies = [ + "deno_error", + "thiserror 2.0.18", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.6", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.1", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.1", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.1", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zoneinfo64" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/ROADMAP.md b/ROADMAP.md index 6333b13..b1bc7db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,18 +41,34 @@ Core engine, CLI, and all major subsystems are stable. Summary of shipped featur **Phase 1 — Semantic Tree Viewer + CAPTCHA Handoff (current)** - [ ] Semantic tree viewer panel — render ARIA role tree with interactive nodes in Tauri dashboard -- [ ] Per-instance controls — URL bar, navigate, agent status (idle/running/waiting-challenge) -- [ ] CAPTCHA handoff — when agent hits a challenge, popup OS webview (WKWebView/WebKitGTK/WebView2) for user to solve, then sync cookies back to headless browser via CDP `Network.setCookie` -- [ ] Cookie bridge — `tokio-tungstenite` WebSocket client to inject cookies into headless CDP server -- [ ] Agent action log — real-time log of agent actions (navigate, click, type, wait) streamed from CDP events -- [ ] Cross-platform — dashboard is pure HTML/CSS (no OS webview dependency for primary view); CAPTCHA popup uses OS webview only when needed - -**Phase 2 — Multi-Agent Dashboard** -- [ ] Multiple concurrent agent instances — spawn/manage N agents in one window -- [ ] Agent status grid — see all agents at a glance with status indicators (running, idle, stuck, CAPTCHA) -- [ ] Live agent action streaming — watch each agent's actions in real-time via CDP event bus -- [ ] Take-over button — pause agent, let user manually interact, then resume agent -- [ ] Agent conversation panel — show the LLM conversation alongside browser actions +- [x] Per-instance controls — URL bar, navigate, agent status (idle/running/waiting-challenge) +- [x] CAPTCHA handoff — when agent hits a challenge, popup OS webview (WKWebView/WebKitGTK/WebView2) for user to solve, then sync cookies back to headless browser via CDP `Network.setCookie` +- [x] Cookie bridge — `tokio-tungstenite` WebSocket client to inject cookies into headless CDP server +- [x] Agent action log — real-time log of agent actions (navigate, click, type, wait) streamed from CDP events +- [x] Cross-platform — dashboard is pure HTML/CSS (no OS webview dependency for primary view); CAPTCHA popup uses OS webview only when needed + +**Phase 1.5 — Webview ↔ Headless Browser Sync (current)** +- [x] Click interceptor — JS injected into OS webview captures clicks on links, buttons, inputs and forwards to Pardus headless browser via Tauri events +- [x] CSS selector generator — produces unique selectors for any clicked DOM element (ID, name, type, nth-of-type) +- [x] Form input sync — debounced `input` event tracking syncs typed values to headless browser form state +- [x] Select/checkbox/radio change tracking — `change` events forwarded as `select`/`toggle` CDP actions +- [x] Form submission interception — `submit` events captured and forwarded as `submit` CDP actions +- [x] Navigation sync — when headless browser navigates after a forwarded action, OS webview is updated to the new URL +- [x] Href fallback — when CSS selector doesn't match in headless browser, falls back to direct URL navigation +- [x] Action log events — `webview-action-log` Tauri events emitted for frontend action log integration +- [x] Pardus UI guard — toolbar and challenge banner clicks are not intercepted + +**Phase 2 — Multi-Agent Dashboard (current)** +- [x] Multiple concurrent agent instances — spawn/manage N agents in one window +- [x] Agent status grid — grid view with AgentCard components showing status, URL, last action per instance +- [x] Live agent action streaming — GlobalActionStream component aggregates actions across all instances with color-coding +- [x] Take-over button — pause agent (stop_agent), open browser window for manual interaction, resume via resume_agent command +- [x] Agent conversation panel — ChatPanel shows LLM conversation per instance (already existed) +- [x] Per-instance state — AgentContext restructured to Map with per-instance messages, events, tree +- [x] Grid/detail view modes — grid overview (default for multi-instance) + drill-down detail view +- [x] View mode toggle — sidebar toggle between grid and detail views +- [x] Backend resume_agent command — Tauri command that sends message to agent sidecar and updates status +- [x] TakeOverBar component — orange banner during manual control with Resume/Open Browser buttons **Phase 3 — Rendered View (Optional)** - [ ] Rendered page tab — OS webview shows actual page pixels (WKWebView on macOS, WebKitGTK on Linux, WebView2 on Windows) diff --git a/adapters/node/pardus-playwright/src/index.ts b/adapters/node/pardus-playwright/src/index.ts index d2a4f73..b6cd126 100644 --- a/adapters/node/pardus-playwright/src/index.ts +++ b/adapters/node/pardus-playwright/src/index.ts @@ -24,6 +24,7 @@ class PardusLauncher { private port: number; private timeout: number; private binaryPath: string; + private killTimer: ReturnType | null = null; constructor(options: PardusLaunchOptions = {}) { this.host = options.host ?? "127.0.0.1"; @@ -68,6 +69,15 @@ class PardusLauncher { stdio: ["pipe", "pipe", "pipe"], }); + this.process.stdout?.on("data", () => {}); + this.process.stderr?.on("data", () => {}); + + this.process.on("error", (err) => { + if (!this.process?.killed) { + this.stop(); + } + }); + this._cdpUrl = `http://${this.host}:${this.port}`; await this.waitForReady(); @@ -87,8 +97,17 @@ class PardusLauncher { http.get(`${this._cdpUrl}/json/version`, (res) => { let data = ""; - res.on("data", (chunk: Buffer) => { data += chunk; }); - res.on("end", () => resolve()); + res.on("data", (_chunk: Buffer) => { data += _chunk; }); + res.on("end", () => { + if (res.statusCode === 200) { + resolve(); + } else if (Date.now() < deadline) { + setTimeout(check, 200); + } else { + this.stop(); + reject(new Error(`pardus-browser did not start within ${this.timeout}s`)); + } + }); }).on("error", () => { if (Date.now() < deadline) { setTimeout(check, 200); @@ -104,10 +123,16 @@ class PardusLauncher { } stop(): void { + if (this.killTimer) { + clearTimeout(this.killTimer); + this.killTimer = null; + } + if (this.process && !this.process.killed) { - this.process.kill("SIGTERM"); const pid = this.process.pid; - setTimeout(() => { + this.process.kill("SIGTERM"); + this.killTimer = setTimeout(() => { + this.killTimer = null; try { process.kill(pid!, "SIGKILL"); } catch { diff --git a/adapters/node/pardus-puppeteer/src/index.ts b/adapters/node/pardus-puppeteer/src/index.ts index e7e78f5..b35d8cf 100644 --- a/adapters/node/pardus-puppeteer/src/index.ts +++ b/adapters/node/pardus-puppeteer/src/index.ts @@ -11,7 +11,7 @@ export interface PardusLaunchOptions { binaryPath?: string; } -export interface PardusPardusExtension { +export interface PardusExtension { semanticTree(): Promise>; navigationGraph(): Promise>; detectActions(): Promise>>; @@ -27,6 +27,7 @@ class PardusLauncher { private port: number; private timeout: number; private binaryPath: string; + private killTimer: ReturnType | null = null; constructor(options: PardusLaunchOptions = {}) { this.host = options.host ?? "127.0.0.1"; @@ -71,6 +72,15 @@ class PardusLauncher { stdio: ["pipe", "pipe", "pipe"], }); + this.process.stdout?.on("data", () => {}); + this.process.stderr?.on("data", () => {}); + + this.process.on("error", (_err) => { + if (!this.process?.killed) { + this.stop(); + } + }); + this._cdpUrl = `http://${this.host}:${this.port}`; await this.waitForReady(); @@ -89,9 +99,17 @@ class PardusLauncher { } http.get(`${this._cdpUrl}/json/version`, (res) => { - let data = ""; - res.on("data", (chunk: Buffer) => { data += chunk; }); - res.on("end", () => resolve()); + res.on("data", () => {}); + res.on("end", () => { + if (res.statusCode === 200) { + resolve(); + } else if (Date.now() < deadline) { + setTimeout(check, 200); + } else { + this.stop(); + reject(new Error(`pardus-browser did not start within ${this.timeout}s`)); + } + }); }).on("error", () => { if (Date.now() < deadline) { setTimeout(check, 200); @@ -107,10 +125,16 @@ class PardusLauncher { } stop(): void { + if (this.killTimer) { + clearTimeout(this.killTimer); + this.killTimer = null; + } + if (this.process && !this.process.killed) { - this.process.kill("SIGTERM"); const pid = this.process.pid; - setTimeout(() => { + this.process.kill("SIGTERM"); + this.killTimer = setTimeout(() => { + this.killTimer = null; try { process.kill(pid!, "SIGKILL"); } catch { diff --git a/ai-agent/pardus-browser/eslint.config.js b/ai-agent/pardus-browser/eslint.config.js new file mode 100644 index 0000000..d3d78c7 --- /dev/null +++ b/ai-agent/pardus-browser/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + globals: globals.node, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-argument": "warn", + }, + }, + { + ignores: ["dist/**", "node_modules/**"], + } +); diff --git a/ai-agent/pardus-browser/package-lock.json b/ai-agent/pardus-browser/package-lock.json new file mode 100644 index 0000000..71bbadf --- /dev/null +++ b/ai-agent/pardus-browser/package-lock.json @@ -0,0 +1,1063 @@ +{ + "name": "pardus-browser-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pardus-browser-agent", + "version": "0.1.0", + "dependencies": { + "openai": "^4.28.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/ws": "^8.5.10", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/ai-agent/pardus-browser/src/agent/Agent.ts b/ai-agent/pardus-browser/src/agent/Agent.ts index 28148f7..8b51297 100644 --- a/ai-agent/pardus-browser/src/agent/Agent.ts +++ b/ai-agent/pardus-browser/src/agent/Agent.ts @@ -39,6 +39,7 @@ export class Agent { private isRunning = false; private toolConfig: AgentOptions['toolConfig']; private contextConfig: ContextConfig; + private abortController: AbortController | null = null; constructor(browserManager: BrowserManager, options: AgentOptions) { this.browserManager = browserManager; @@ -93,6 +94,10 @@ export class Agent { // Keep looping while the LLM wants to make tool calls while (rounds < this.maxRounds) { + if (this.abortController?.signal.aborted) { + return '[Agent stopped by user]'; + } + rounds++; // Call LLM @@ -162,6 +167,7 @@ export class Agent { return ''; } finally { this.isRunning = false; + this.abortController = null; } } @@ -248,6 +254,7 @@ export class Agent { } this.isRunning = true; + this.abortController = new AbortController(); try { this.messages.push({ role: 'user', content: userMessage }); @@ -255,6 +262,10 @@ export class Agent { let rounds = 0; while (rounds < this.maxRounds) { + if (this.abortController.signal.aborted) { + return '[Agent stopped by user]'; + } + rounds++; const result = await this.llm.streamChat(this.messages); @@ -323,6 +334,7 @@ export class Agent { return limitMsg; } finally { this.isRunning = false; + this.abortController = null; } } @@ -361,4 +373,10 @@ export class Agent { getToolConfig(): AgentOptions['toolConfig'] { return { ...this.toolConfig }; } + + stop(): void { + if (this.abortController) { + this.abortController.abort(); + } + } } diff --git a/ai-agent/pardus-browser/src/core/BrowserInstance.ts b/ai-agent/pardus-browser/src/core/BrowserInstance.ts index 13ab858..b0b5902 100644 --- a/ai-agent/pardus-browser/src/core/BrowserInstance.ts +++ b/ai-agent/pardus-browser/src/core/BrowserInstance.ts @@ -178,6 +178,10 @@ export class BrowserInstance extends EventEmitter { this.ws.on('close', () => { this.connected = false; + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('WebSocket connection closed')); + this.pendingRequests.delete(id); + } this.emit('disconnected'); this.attemptReconnect(); }); @@ -261,7 +265,11 @@ export class BrowserInstance extends EventEmitter { } } - private sendCommand(method: string, params?: Record, timeout?: number): Promise { + public sendCdpCommand(method: string, params?: Record, timeout?: number): Promise { + return this.sendCommand(method, params, timeout); + } + + sendCommand(method: string, params?: Record, timeout?: number): Promise { return new Promise((resolve, reject) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { reject(new Error('WebSocket not connected')); @@ -847,8 +855,11 @@ export class BrowserInstance extends EventEmitter { async extractText(selector?: string): Promise { try { - const scopeExpr = selector - ? `document.querySelector("${selector.replace(/"/g, '\\"')}")` + const escapedSelector = selector + ? JSON.stringify(selector) + : undefined; + const scopeExpr = escapedSelector + ? `document.querySelector(${escapedSelector})` : 'document.body'; const result = await this.sendCommand( @@ -856,7 +867,7 @@ export class BrowserInstance extends EventEmitter { { expression: `(function() { const root = ${scopeExpr}; - if (!root) return { error: "Element not found: ${selector?.replace(/"/g, '\\"')}" }; + if (!root) return { error: "Element not found" }; const clone = root.cloneNode(true); const remove = ['script','style','noscript','svg','iframe','nav','footer','header','aside','[role="navigation"]','[role="banner"]','[role="contentinfo"]','[role="complementary"]','.ad','.ads','.advertisement','.sidebar','.cookie-banner','.popup','.modal']; for (const sel of remove) { diff --git a/ai-agent/pardus-browser/src/core/CookieStore.ts b/ai-agent/pardus-browser/src/core/CookieStore.ts index 8a3d8e5..f4d0431 100644 --- a/ai-agent/pardus-browser/src/core/CookieStore.ts +++ b/ai-agent/pardus-browser/src/core/CookieStore.ts @@ -1,8 +1,10 @@ import { mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import type { Cookie } from './types.js'; +const VALID_PROFILE_RE = /^[a-zA-Z0-9_-]+$/; + interface PersistedCookieData { cookies: Cookie[]; savedAt: number; @@ -17,6 +19,9 @@ export class CookieStore { saveCookies(profile: string, cookies: Cookie[]): void { if (!profile || cookies.length === 0) return; + if (!VALID_PROFILE_RE.test(profile)) { + throw new Error(`Invalid profile name: "${profile}". Only alphanumeric, underscore, and hyphen characters are allowed.`); + } const profileDir = join(this.profilesDir, profile); mkdirSync(profileDir, { recursive: true }); diff --git a/ai-agent/pardus-browser/src/index.ts b/ai-agent/pardus-browser/src/index.ts index ed22c19..9271412 100755 --- a/ai-agent/pardus-browser/src/index.ts +++ b/ai-agent/pardus-browser/src/index.ts @@ -165,16 +165,14 @@ async function main(): Promise { }); // Handle Ctrl+C gracefully - process.on('SIGINT', async () => { + process.on('SIGINT', () => { console.log('\n\nReceived SIGINT, cleaning up...'); - await agent.cleanup(); - process.exit(0); + agent.cleanup().finally(() => process.exit(0)); }); - process.on('SIGTERM', async () => { + process.on('SIGTERM', () => { console.log('\n\nReceived SIGTERM, cleaning up...'); - await agent.cleanup(); - process.exit(0); + agent.cleanup().finally(() => process.exit(0)); }); const query = process.argv[2]; diff --git a/ai-agent/pardus-browser/src/llm/client.ts b/ai-agent/pardus-browser/src/llm/client.ts index 4db8d11..b147748 100644 --- a/ai-agent/pardus-browser/src/llm/client.ts +++ b/ai-agent/pardus-browser/src/llm/client.ts @@ -72,11 +72,19 @@ export class LLMClient { const message = choice.message; // Parse tool calls if present - const toolCalls = message.tool_calls?.map((call) => ({ - id: call.id, - name: call.function.name, - arguments: JSON.parse(call.function.arguments) as Record, - })); + const toolCalls = message.tool_calls?.map((call) => { + let arguments_: Record = {}; + try { + arguments_ = JSON.parse(call.function.arguments) as Record; + } catch { + arguments_ = {}; + } + return { + id: call.id, + name: call.function.name, + arguments: arguments_, + }; + }); return { content: message.content, diff --git a/ai-agent/pardus-browser/src/llm/context.ts b/ai-agent/pardus-browser/src/llm/context.ts index e7593e7..0fa298b 100644 --- a/ai-agent/pardus-browser/src/llm/context.ts +++ b/ai-agent/pardus-browser/src/llm/context.ts @@ -139,9 +139,42 @@ export function compactMessages( // Drop from the summarized section until we fit let dropFrom = 1; // start after system message while (dropFrom < result.length - cfg.keepRecentMessages && finalTokens > cfg.maxTokens) { - const dropped = result.splice(dropFrom, 1)[0]; + const dropped = result[dropFrom]; + + if (dropped.role === 'assistant' && dropped.tool_calls && dropped.tool_calls.length > 0) { + const toolCallCount = dropped.tool_calls.length; + const removed = result.splice(dropFrom, 1 + toolCallCount); + let removedTokens = 0; + for (const msg of removed) { + removedTokens += estimateTokens( + (msg.content ?? '') + (msg.tool_calls?.map(tc => tc.function.arguments).join('') ?? ''), + cfg.charsPerToken + ); + } + finalTokens -= removedTokens; + continue; + } + + if (dropped.role === 'tool' && dropFrom > 1) { + const prev = result[dropFrom - 1]; + if (prev.role === 'assistant' && prev.tool_calls && prev.tool_calls.length > 0) { + const toolCallCount = prev.tool_calls.length; + const removed = result.splice(dropFrom - 1, 1 + toolCallCount); + let removedTokens = 0; + for (const msg of removed) { + removedTokens += estimateTokens( + (msg.content ?? '') + (msg.tool_calls?.map(tc => tc.function.arguments).join('') ?? ''), + cfg.charsPerToken + ); + } + finalTokens -= removedTokens; + continue; + } + } + + const removed = result.splice(dropFrom, 1); finalTokens -= estimateTokens( - (dropped.content ?? '') + (dropped.tool_calls?.map(tc => tc.function.arguments).join('') ?? ''), + (removed[0].content ?? '') + (removed[0].tool_calls?.map(tc => tc.function.arguments).join('') ?? ''), cfg.charsPerToken ); } diff --git a/ai-agent/pardus-browser/src/llm/prompts.ts b/ai-agent/pardus-browser/src/llm/prompts.ts index adb4896..6665a56 100644 --- a/ai-agent/pardus-browser/src/llm/prompts.ts +++ b/ai-agent/pardus-browser/src/llm/prompts.ts @@ -28,7 +28,7 @@ Pages are returned as semantic trees with element IDs in brackets: - Use browser_get_action_plan when unsure what to do next - Scroll with browser_scroll(direction) to see more content — scroll returns the updated tree -Tools (40): browser_new, browser_navigate, browser_click, browser_fill, browser_submit, browser_scroll, browser_close, browser_list, browser_get_state, browser_get_action_plan, browser_auto_fill, browser_wait, browser_get/set/delete_cookies, browser_get/set/delete/clear_storage, browser_extract_text, browser_extract_links, browser_find, browser_extract_table, browser_extract_metadata, browser_screenshot, browser_select, browser_press_key, browser_hover, browser_tab_new/switch/close, browser_download, browser_upload, browser_pdf_extract, browser_feed_parse, browser_network_block, browser_network_log, browser_iframe_enter/exit, browser_diff.`; + Tools (42): browser_new, browser_navigate, browser_click, browser_fill, browser_submit, browser_scroll, browser_close, browser_list, browser_get_state, browser_get_action_plan, browser_auto_fill, browser_wait, browser_get/set/delete_cookies, browser_get/set/delete/clear_storage, browser_extract_text, browser_extract_links, browser_find, browser_extract_table, browser_extract_metadata, browser_screenshot, browser_select, browser_press_key, browser_hover, browser_tab_new/switch/close, browser_download, browser_upload, browser_pdf_extract, browser_feed_parse, browser_network_block, browser_network_log, browser_iframe_enter/exit, browser_diff, browser_oauth_set_provider/start/complete/status.`; /** Extended prompt — used for the first few rounds, then compacted */ export const EXTENDED_PROMPT = ` diff --git a/ai-agent/pardus-browser/src/sidecar-test.ts b/ai-agent/pardus-browser/src/sidecar-test.ts new file mode 100644 index 0000000..1850caf --- /dev/null +++ b/ai-agent/pardus-browser/src/sidecar-test.ts @@ -0,0 +1,232 @@ +// Manual integration test for sidecar JSON-RPC protocol. +// Run: node dist/sidecar-test.js < input.jsonl +// +// Input format: one JSON-RPC request per line (no trailing newline on last line) + +import { execSync } from "child_process"; + +const sidecarPath = process.argv[2] || "node dist/sidecar.js"; + +const tests = [ + { + name: "agent.init with valid config", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "agent.init", + params: { + apiKey: "test-key", + model: "gpt-4", + baseURL: "https://api.openai.com/v1", + temperature: 0.5, + maxTokens: 1000, + maxRounds: 10, + }, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 1) return `expected id=1, got ${resp.id}`; + if (resp.result?.ok !== true) return `expected ok=true, got ${JSON.stringify(resp.result)}`; + return null; + }, + }, + { + name: "agent.init without apiKey should fail", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "agent.init", + params: {}, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 2) return `expected id=2, got ${resp.id}`; + if (!resp.error) return `expected error, got ${JSON.stringify(resp)}`; + return null; + }, + }, + { + name: "agent.init with extra init message (id=1 reserved)", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "agent.init", + params: { + apiKey: "test-key", + model: "gpt-4", + }, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 1) return `expected id=1, got ${resp.id}`; + if (resp.result?.ok !== true) return `expected ok=true, got ${JSON.stringify(resp.result)}`; + return null; + }, + }, + { + name: "agent.stop after init", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "agent.stop", + params: {}, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 3) return `expected id=3, got ${resp.id}`; + if (resp.result?.ok !== true) return `expected ok=true, got ${JSON.stringify(resp.result)}`; + return null; + }, + }, + { + name: "unknown method returns error", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 4, + method: "agent.unknown", + params: {}, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 4) return `expected id=4, got ${resp.id}`; + if (!resp.error) return `expected error, got ${JSON.stringify(resp)}`; + if (resp.error.code !== -32601) return `expected code -32601, got ${resp.error.code}`; + return null; + }, + }, + { + name: "chat without init should fail", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 5, + method: "agent.chat", + params: { message: "hello" }, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 5) return `expected id=5, got ${resp.id}`; + if (!resp.error) return `expected error, got ${JSON.stringify(resp)}`; + return null; + }, + }, + { + name: "clearHistory without init should fail", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 6, + method: "agent.clearHistory", + params: {}, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 6) return `expected id=6, got ${resp.id}`; + if (!resp.error) return `expected error, got ${JSON.stringify(resp)}`; + return null; + }, + }, + { + name: "getHistory without init should fail", + input: JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "agent.getHistory", + params: {}, + }), + expect: (lines) => { + const resp = JSON.parse(lines[0]); + if (resp.id !== 7) return `expected id=7, got ${resp.id}`; + if (!resp.error) return `expected error, got ${JSON.stringify(resp)}`; + return null; + }, + }, + { + name: "malformed JSON is ignored", + input: "not json at all\n" + JSON.stringify({ + jsonrpc: "2.0", + id: 8, + method: "agent.init", + params: { apiKey: "test-key", model: "gpt-4" }, + }), + expect: (lines) => { + if (lines.length < 2) return `expected 2 lines (first ignored), got ${lines.length}`; + const resp = JSON.parse(lines[1]); + if (resp.id !== 8) return `expected id=8, got ${resp.id}`; + if (resp.result?.ok !== true) return `expected ok=true, got ${JSON.stringify(resp.result)}`; + return null; + }, + }, + { + name: "empty lines are ignored", + input: "\n\n" + JSON.stringify({ + jsonrpc: "2.0", + id: 9, + method: "agent.init", + params: { apiKey: "test-key", model: "gpt-4" }, + }) + "\n\n", + expect: (lines) => { + if (lines.length < 1) return `expected at least 1 line, got ${lines.length}`; + const resp = JSON.parse(lines[0]); + if (resp.id !== 9) return `expected id=9, got ${resp.id}`; + if (resp.result?.ok !== true) return `expected ok=true, got ${JSON.stringify(resp.result)}`; + return null; + }, + }, + { + name: "notification without id is handled", + input: JSON.stringify({ + jsonrpc: "2.0", + method: "agent.init", + params: { apiKey: "test-key", model: "gpt-4" }, + }), + expect: (lines) => { + // Notification (no id) should not produce a response, so lines is empty + if (lines.length !== 0) return `expected 0 lines (notification), got ${lines.length}: ${lines.join(", ")}`; + return null; + }, + }, +]; + +let passed = 0; +let failed = 0; +const errors: string[] = []; + +for (const test of tests) { + console.log(`\n ${test.name}...`); + + try { + const child = execSync(`node ${sidecarPath}`, { + input: test.input + "\n", + timeout: 5000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + + const stdout = child.stdout?.toString().trim(); + const lines = stdout.split("\n").filter((l) => l.length > 0); + + const err = test.expect(lines); + if (err) { + console.log(` FAIL: ${err}`); + if (stdout) console.log(` stdout: ${stdout.slice(0, 200)}`); + failed++; + errors.push(test.name); + } else { + console.log(` OK`); + passed++; + } + } catch (e) { + // test 9 and 11 (init-dependent) might fail because init was already called + // but since we spawn a new process each time, they should all work + console.log(` FAIL: ${e instanceof Error ? e.message : String(e)}`); + failed++; + errors.push(test.name); + } +} + +console.log(`\n${"=".repeat(50)}`); +console.log(` Results: ${passed} passed, ${failed} failed, ${tests.length} total`); +if (errors.length > 0) { + console.log(` Failed: ${errors.join(", ")}`); +} + +process.exit(failed > 0 ? 1 : 0); diff --git a/ai-agent/pardus-browser/src/sidecar.ts b/ai-agent/pardus-browser/src/sidecar.ts new file mode 100644 index 0000000..afed4f0 --- /dev/null +++ b/ai-agent/pardus-browser/src/sidecar.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env node + +import { Agent } from "./agent/index.js"; +import { BrowserManager } from "./core/index.js"; +import { LLMConfig } from "./llm/index.js"; + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params: Record; +} + +type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse; + +let agent: Agent | null = null; +let browserManager: BrowserManager | null = null; +const pendingRequests = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } +>(); + +function send(message: JsonRpcResponse | JsonRpcNotification): void { + const line = JSON.stringify(message); + process.stdout.write(line + "\n"); +} + +function notify(method: string, params: Record): void { + send({ jsonrpc: "2.0", method, params }); +} + +function respond(id: number, result: unknown): void { + send({ jsonrpc: "2.0", id, result }); +} + +function respondError(id: number, code: number, message: string): void { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +async function handleRequest(req: JsonRpcRequest): Promise { + const id = req.id ?? 0; + const params = req.params ?? {}; + + switch (req.method) { + case "agent.init": { + try { + if (agent) { + respondError(id, -32000, "Agent already initialized"); + return; + } + + const apiKey = params.apiKey as string | undefined; + if (!apiKey) { + respondError(id, -32000, "Missing required param: apiKey"); + return; + } + + const llmConfig: LLMConfig = { + apiKey, + baseURL: (params.baseURL as string) || undefined, + model: (params.model as string) || "gpt-4", + temperature: (params.temperature as number) ?? 0.7, + maxTokens: (params.maxTokens as number) ?? 4000, + }; + + browserManager = new BrowserManager(); + agent = new Agent(browserManager, { + llmConfig, + maxRounds: (params.maxRounds as number) ?? 50, + customInstructions: (params.customInstructions as string) || undefined, + }); + + respond(id, { ok: true }); + } catch (e) { + respondError(id, -32001, e instanceof Error ? e.message : String(e)); + } + break; + } + + case "agent.chat": { + if (!agent) { + respondError(id, -32002, "Agent not initialized. Call agent.init first."); + return; + } + + const message = params.message as string; + if (!message) { + respondError(id, -32003, "Missing required param: message"); + return; + } + + (async () => { + try { + notify("agent.status", { status: "thinking" }); + + const gen = agent!.streamChat(message); + let fullContent = ""; + + for await (const chunk of gen) { + fullContent += chunk; + notify("agent.thinking", { chunk }); + } + + respond(id, { content: fullContent }); + notify("agent.status", { status: "idle" }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + respondError(id, -32004, msg); + notify("agent.error", { message: msg }); + notify("agent.status", { status: "error" }); + } + })(); + + break; + } + + case "agent.stop": { + if (!agent) { + respondError(id, -32005, "Agent not initialized"); + return; + } + agent.stop(); + respond(id, { ok: true }); + notify("agent.status", { status: "idle" }); + break; + } + + case "agent.clearHistory": { + if (!agent) { + respondError(id, -32006, "Agent not initialized"); + return; + } + agent.clearHistory(); + respond(id, { ok: true }); + notify("agent.history_cleared", {}); + break; + } + + case "agent.getHistory": { + if (!agent) { + respondError(id, -32007, "Agent not initialized"); + return; + } + const history = agent.getHistory().map((m) => ({ + role: m.role, + content: m.content, + tool_calls: m.tool_calls?.map((tc) => ({ + id: tc.id, + type: tc.type, + name: tc.function.name, + arguments: tc.function.arguments, + })), + tool_call_id: m.tool_call_id, + name: m.name, + })); + respond(id, { history }); + break; + } + + case "agent.shutdown": { + respond(id, { ok: true }); + (async () => { + if (browserManager) { + await browserManager.closeAll(); + } + setTimeout(() => process.exit(0), 100); + })(); + break; + } + + default: + respondError(id, -32601, `Method not found: ${req.method}`); + } +} + +process.stdin.setEncoding("utf-8"); +process.stdin.resume(); + +let buffer = ""; + +process.stdin.on("data", (data: string) => { + buffer += data; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const msg = JSON.parse(trimmed) as JsonRpcMessage; + + if ("id" in msg && msg.id !== undefined && "method" in msg) { + handleRequest(msg as JsonRpcRequest); + } + } catch { + // ignore malformed lines + } + } +}); + +process.stdin.on("close", () => { + browserManager?.closeAll().finally(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + browserManager?.closeAll().finally(() => process.exit(0)); +}); + +process.on("SIGTERM", () => { + browserManager?.closeAll().finally(() => process.exit(0)); +}); diff --git a/ai-agent/pardus-browser/src/tools/definitions.ts b/ai-agent/pardus-browser/src/tools/definitions.ts index 27aacff..ae13b3e 100644 --- a/ai-agent/pardus-browser/src/tools/definitions.ts +++ b/ai-agent/pardus-browser/src/tools/definitions.ts @@ -907,6 +907,122 @@ export const browserTools: ToolDefinition[] = [ }, }, }, + // --- OAuth 2.0 / OIDC tools --- + { + type: 'function', + function: { + name: 'browser_oauth_set_provider', + description: + 'Register an OAuth 2.0 provider configuration. Must be called before starting an OAuth flow. If only issuer is provided (no explicit endpoints), OIDC auto-discovery is used.', + parameters: { + type: 'object', + properties: { + instance_id: { type: 'string', description: 'The browser instance ID' }, + name: { + type: 'string', + description: 'Logical name for this provider (e.g., "google", "github")', + }, + client_id: { type: 'string', description: 'OAuth client ID' }, + client_secret: { + type: 'string', + description: 'Optional client secret (not needed for pure PKCE)', + }, + issuer: { + type: 'string', + description: + 'OIDC issuer URL for auto-discovery (e.g., "https://accounts.google.com")', + }, + authorization_endpoint: { + type: 'string', + description: 'Authorization endpoint URL (required if no issuer)', + }, + token_endpoint: { + type: 'string', + description: 'Token endpoint URL (required if no issuer)', + }, + scopes: { + type: 'array', + items: { type: 'string' }, + description: 'OAuth scopes (e.g., ["openid", "profile", "email"])', + }, + redirect_uri: { + type: 'string', + description: + 'Redirect URI registered with the provider (default: http://localhost:8080/callback)', + }, + }, + required: ['instance_id', 'name', 'client_id', 'scopes'], + }, + }, + }, + { + type: 'function', + function: { + name: 'browser_oauth_start', + description: + "Start an OAuth 2.0 authorization code flow with PKCE. Navigates to the provider's authorization URL and captures the redirect callback. If login is required, the page content is returned for interaction. Call browser_oauth_complete after login succeeds.", + parameters: { + type: 'object', + properties: { + instance_id: { type: 'string', description: 'The browser instance ID' }, + provider: { + type: 'string', + description: 'Provider name (from browser_oauth_set_provider)', + }, + scopes: { + type: 'array', + items: { type: 'string' }, + description: 'Override scopes for this flow', + }, + extra_params: { + type: 'object', + description: + 'Extra query parameters for the authorization URL (e.g., {"prompt": "consent"})', + }, + }, + required: ['instance_id', 'provider'], + }, + }, + }, + { + type: 'function', + function: { + name: 'browser_oauth_complete', + description: + 'Complete the OAuth flow by exchanging the captured authorization code for tokens. On success, tokens are stored and automatically injected into future requests to the provider domain.', + parameters: { + type: 'object', + properties: { + instance_id: { type: 'string', description: 'The browser instance ID' }, + provider: { type: 'string', description: 'Provider name' }, + code: { + type: 'string', + description: 'Optional: explicit authorization code (if not captured from redirect)', + }, + }, + required: ['instance_id', 'provider'], + }, + }, + }, + { + type: 'function', + function: { + name: 'browser_oauth_status', + description: + 'Get the status of OAuth sessions for the browser instance. Shows active providers, token expiry, and whether auto-injection is active.', + parameters: { + type: 'object', + properties: { + instance_id: { type: 'string', description: 'The browser instance ID' }, + provider: { + type: 'string', + description: 'Optional: get status for a specific provider only', + }, + }, + required: ['instance_id'], + }, + }, + }, ]; export type BrowserToolName = @@ -949,4 +1065,8 @@ export type BrowserToolName = | 'browser_network_log' | 'browser_iframe_enter' | 'browser_iframe_exit' - | 'browser_diff'; + | 'browser_diff' + | 'browser_oauth_set_provider' + | 'browser_oauth_start' + | 'browser_oauth_complete' + | 'browser_oauth_status'; diff --git a/ai-agent/pardus-browser/src/tools/executor.ts b/ai-agent/pardus-browser/src/tools/executor.ts index 15be9d4..b761540 100644 --- a/ai-agent/pardus-browser/src/tools/executor.ts +++ b/ai-agent/pardus-browser/src/tools/executor.ts @@ -1,5 +1,5 @@ import { BrowserManager, BrowserInstance } from '../core/index.js'; -import { ToolResult } from '../core/types.js'; +import type { ToolResult } from '../core/types.js'; import { BrowserToolName } from './definitions.js'; import { ToolExecutionConfig, @@ -52,6 +52,17 @@ interface ToolCallArgs { // Feed args // Network args resource_types?: string[]; + // OAuth args + provider?: string; + issuer?: string; + client_id?: string; + client_secret?: string; + authorization_endpoint?: string; + token_endpoint?: string; + redirect_uri?: string; + scopes?: string[]; + extra_params?: Record; + code?: string; } /** @@ -69,6 +80,24 @@ const DEFAULT_RETRY_CONFIG: Required = { export class ToolExecutor { constructor(private browserManager: BrowserManager) {} + private getInstance(args: Record): BrowserInstance | null { + const id = args.instance_id as string | undefined; + if (!id) return null; + return this.browserManager.getInstance(id) ?? null; + } + + private resolveElementId(args: ToolCallArgs): string | undefined { + const id = args.element_id as string | undefined; + if (!id) return undefined; + return id.startsWith('#') ? id.slice(1) : id; + } + + private resolveFormElementId(args: ToolCallArgs): string | undefined { + const id = args.form_element_id as string | undefined; + if (!id) return undefined; + return id.startsWith('#') ? id.slice(1) : id; + } + /** * Execute a single browser tool call with retry logic and timeout */ @@ -128,15 +157,15 @@ export class ToolExecutor { args: ToolCallArgs, timeout: number ): Promise { - return Promise.race([ - this.executeToolInternal(name, args), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`TimeoutError: Tool ${name} timed out after ${timeout}ms`)), - timeout - ) - ), - ]); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout( + () => reject(new Error(`TimeoutError: Tool ${name} timed out after ${timeout}ms`)), + timeout + ); + this.executeToolInternal(name, args) + .then(result => { clearTimeout(timeoutId); resolve(result); }) + .catch(error => { clearTimeout(timeoutId); reject(error); }); + }); } /** @@ -295,89 +324,95 @@ export class ToolExecutor { name: BrowserToolName, args: ToolCallArgs ): Promise { - const typedArgs = args; - switch (name) { case 'browser_new': - return this.handleNew(typedArgs); + return this.handleNew(args); case 'browser_navigate': - return this.handleNavigate(typedArgs); + return this.handleNavigate(args); case 'browser_click': - return this.handleClick(typedArgs); + return this.handleClick(args); case 'browser_fill': - return this.handleFill(typedArgs); + return this.handleFill(args); case 'browser_submit': - return this.handleSubmit(typedArgs); + return this.handleSubmit(args); case 'browser_scroll': - return this.handleScroll(typedArgs); + return this.handleScroll(args); case 'browser_get_cookies': - return this.handleGetCookies(typedArgs); + return this.handleGetCookies(args); case 'browser_set_cookie': - return this.handleSetCookie(typedArgs); + return this.handleSetCookie(args); case 'browser_delete_cookie': - return this.handleDeleteCookie(typedArgs); + return this.handleDeleteCookie(args); case 'browser_get_storage': - return this.handleGetStorage(typedArgs); + return this.handleGetStorage(args); case 'browser_set_storage': - return this.handleSetStorage(typedArgs); + return this.handleSetStorage(args); case 'browser_delete_storage': - return this.handleDeleteStorage(typedArgs); + return this.handleDeleteStorage(args); case 'browser_clear_storage': - return this.handleClearStorage(typedArgs); + return this.handleClearStorage(args); case 'browser_get_action_plan': - return this.handleGetActionPlan(typedArgs); + return this.handleGetActionPlan(args); case 'browser_auto_fill': - return this.handleAutoFill(typedArgs); + return this.handleAutoFill(args); case 'browser_wait': - return this.handleWait(typedArgs); + return this.handleWait(args); case 'browser_close': - return this.handleClose(typedArgs); + return this.handleClose(args); case 'browser_list': return this.handleList(); case 'browser_get_state': - return this.handleGetState(typedArgs); + return this.handleGetState(args); case 'browser_extract_text': - return this.handleExtractText(typedArgs); + return this.handleExtractText(args); case 'browser_extract_links': - return this.handleExtractLinks(typedArgs); + return this.handleExtractLinks(args); case 'browser_find': - return this.handleFind(typedArgs); + return this.handleFind(args); case 'browser_extract_table': - return this.handleExtractTable(typedArgs); + return this.handleExtractTable(args); case 'browser_extract_metadata': - return this.handleExtractMetadata(typedArgs); + return this.handleExtractMetadata(args); case 'browser_screenshot': - return this.handleScreenshot(typedArgs); + return this.handleScreenshot(args); case 'browser_select': - return this.handleSelect(typedArgs); + return this.handleSelect(args); case 'browser_press_key': - return this.handlePressKey(typedArgs); + return this.handlePressKey(args); case 'browser_hover': - return this.handleHover(typedArgs); + return this.handleHover(args); case 'browser_tab_new': - return this.handleTabNew(typedArgs); + return this.handleTabNew(args); case 'browser_tab_switch': - return this.handleTabSwitch(typedArgs); + return this.handleTabSwitch(args); case 'browser_tab_close': - return this.handleTabClose(typedArgs); + return this.handleTabClose(args); case 'browser_download': - return this.handleDownload(typedArgs); + return this.handleDownload(args); case 'browser_upload': - return this.handleUpload(typedArgs); + return this.handleUpload(args); case 'browser_pdf_extract': - return this.handlePdfExtract(typedArgs); + return this.handlePdfExtract(args); case 'browser_feed_parse': - return this.handleFeedParse(typedArgs); + return this.handleFeedParse(args); case 'browser_network_block': - return this.handleNetworkBlock(typedArgs); + return this.handleNetworkBlock(args); case 'browser_network_log': - return this.handleNetworkLog(typedArgs); + return this.handleNetworkLog(args); case 'browser_iframe_enter': - return this.handleIframeEnter(typedArgs); + return this.handleIframeEnter(args); case 'browser_iframe_exit': - return this.handleIframeExit(typedArgs); + return this.handleIframeExit(args); case 'browser_diff': - return this.handleDiff(typedArgs); + return this.handleDiff(args); + case 'browser_oauth_set_provider': + return this.handleOAuthSetProvider(args); + case 'browser_oauth_start': + return this.handleOAuthStart(args); + case 'browser_oauth_complete': + return this.handleOAuthComplete(args); + case 'browser_oauth_status': + return this.handleOAuthStatus(args); default: return { success: false, @@ -387,6 +422,17 @@ export class ToolExecutor { } } + private requireInstance(args: ToolCallArgs): { instance: BrowserInstance; error?: undefined } | { instance?: undefined; error: ToolResult } { + if (!args.instance_id) { + return { error: { success: false, content: '', error: 'Missing instance_id' } }; + } + const instance = this.browserManager.getInstance(args.instance_id); + if (!instance) { + return { error: { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` } }; + } + return { instance }; + } + private async handleNew(args: ToolCallArgs): Promise { try { const instance = await this.browserManager.createInstance({ @@ -416,17 +462,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing url' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.navigate(args.url, { + const result = await inst.instance.navigate(args.url, { waitMs: args.wait_ms, interactiveOnly: args.interactive_only, headers: args.headers, @@ -466,17 +506,13 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing element_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.click(args.element_id); + const result = await inst.instance.click(elementId!); if (!result.success) { return { @@ -487,7 +523,7 @@ export class ToolExecutor { } let content = `## Click Result\n\n` + - `- **Element**: ${args.element_id}\n` + + `- **Element**: #${elementId}\n` + `- **Navigated**: ${result.navigated ? 'Yes' : 'No'}\n`; if (result.navigated && result.url) { @@ -519,17 +555,13 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing value' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.fill(args.element_id, args.value); + const result = await inst.instance.fill(elementId!, args.value); if (!result.success) { return { @@ -541,7 +573,7 @@ export class ToolExecutor { return { success: true, - content: `Filled ${args.element_id} with: ${args.value}`, + content: `Filled #${elementId} with: ${args.value}`, }; } catch (error) { return { @@ -557,17 +589,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.submit(args.form_element_id); + const result = await inst.instance.submit(this.resolveFormElementId(args)); if (!result.success) { return { @@ -606,17 +632,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing direction' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.scroll(args.direction); + const result = await inst.instance.scroll(args.direction); if (!result.success) { return { @@ -648,17 +668,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.getCookies(args.url); + const result = await inst.instance.getCookies(args.url); if (!result.success) { return { @@ -695,17 +709,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing cookie value' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.setCookie(args.name, args.value, { + const result = await inst.instance.setCookie(args.name, args.value, { url: args.url, domain: args.domain, path: args.path, @@ -744,17 +752,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing cookie name' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.deleteCookie(args.name, args.url); + const result = await inst.instance.deleteCookie(args.name, args.url); if (!result.success) { return { @@ -786,17 +788,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing storage_type' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.getStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key); + const result = await inst.instance.getStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key); if (!result.success) { return { @@ -836,17 +832,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing value' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.setStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key, args.value); + const result = await inst.instance.setStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key, args.value); if (!result.success) { return { @@ -880,17 +870,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing key' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.deleteStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key); + const result = await inst.instance.deleteStorage(args.storage_type as 'localStorage' | 'sessionStorage', args.key); if (!result.success) { return { @@ -921,17 +905,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing storage_type' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.clearStorage(args.storage_type); + const result = await inst.instance.clearStorage(args.storage_type); if (!result.success) { return { @@ -959,17 +937,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.getActionPlan(); + const result = await inst.instance.getActionPlan(); if (!result.success) { return { @@ -1028,17 +1000,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing fields (array of {key, value} pairs)' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.autoFill(args.fields); + const result = await inst.instance.autoFill(args.fields); if (!result.success) { return { @@ -1092,14 +1058,8 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing condition (contentLoaded, contentStable, networkIdle, minInteractive, or selector)' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { const validConditions = ['contentLoaded', 'contentStable', 'networkIdle', 'minInteractive', 'selector'] as const; @@ -1112,7 +1072,7 @@ export class ToolExecutor { return { success: false, content: '', error: 'selector is required when condition is "selector"' }; } - const result = await instance.wait(condition, { + const result = await inst.instance.wait(condition, { selector: args.selector, minCount: args.min_count, timeoutMs: args.timeout_ms, @@ -1191,17 +1151,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { - success: false, - content: '', - error: `Browser instance "${args.instance_id}" not found`, - }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const state = await instance.getCurrentState(); + const state = await inst.instance.getCurrentState(); const content = `## Current State\n\n` + `- **URL**: ${state.url}\n` + @@ -1227,13 +1181,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.extractText(args.selector); + const result = await inst.instance.extractText(args.selector); if (!result.success) { return { success: false, content: '', error: result.error || 'Text extraction failed' }; @@ -1255,13 +1207,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.extractLinks(args.filter, args.domain); + const result = await inst.instance.extractLinks(args.filter, args.domain); if (!result.success) { return { success: false, content: '', error: result.error || 'Link extraction failed' }; @@ -1290,13 +1240,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing query' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.find(args.query, args.case_sensitive); + const result = await inst.instance.find(args.query, args.case_sensitive); if (!result.success) { return { success: false, content: '', error: result.error || 'Find failed' }; @@ -1323,13 +1271,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.extractTable(args.selector); + const result = await inst.instance.extractTable(args.selector); if (!result.success) { return { success: false, content: '', error: result.error || 'Table extraction failed' }; @@ -1353,13 +1299,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.extractMetadata(); + const result = await inst.instance.extractMetadata(); if (!result.success) { return { success: false, content: '', error: result.error || 'Metadata extraction failed' }; @@ -1401,13 +1345,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing instance_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.screenshot(); + const result = await inst.instance.screenshot(); if (!result.success) { return { success: false, content: '', error: result.error || 'Screenshot failed' }; @@ -1437,13 +1379,13 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing value' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.selectOption(args.element_id, args.value); + const result = await inst.instance.selectOption(elementId!, args.value); if (!result.success) { return { success: false, content: '', error: result.error || 'Select failed' }; @@ -1451,7 +1393,7 @@ export class ToolExecutor { return { success: true, - content: `Selected "${result.selected_value}" in dropdown ${args.element_id}`, + content: `Selected "${result.selected_value}" in dropdown #${elementId}`, }; } catch (error) { return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; @@ -1466,13 +1408,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing key' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.pressKey(args.key); + const result = await inst.instance.pressKey(args.key); if (!result.success) { return { success: false, content: '', error: result.error || 'Press key failed' }; @@ -1495,13 +1435,13 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing element_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.hover(args.element_id); + const result = await inst.instance.hover(elementId!); if (!result.success) { return { success: false, content: '', error: result.error || 'Hover failed' }; @@ -1509,7 +1449,7 @@ export class ToolExecutor { return { success: true, - content: `Hovered over element ${args.element_id}`, + content: `Hovered over element #${elementId}`, }; } catch (error) { return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; @@ -1526,13 +1466,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing url' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.newTab(args.url); + const result = await inst.instance.newTab(args.url); if (!result.success) { return { success: false, content: '', error: result.error || 'Failed to create tab' }; @@ -1555,13 +1493,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing target_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.switchTab(args.target_id); + const result = await inst.instance.switchTab(args.target_id); if (!result.success) { return { success: false, content: '', error: result.error || 'Failed to switch tab' }; @@ -1584,13 +1520,11 @@ export class ToolExecutor { return { success: false, content: '', error: 'Missing target_id' }; } - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) { - return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; - } + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.closeTab(args.target_id); + const result = await inst.instance.closeTab(args.target_id); if (!result.success) { return { success: false, content: '', error: result.error || 'Failed to close tab' }; @@ -1611,11 +1545,11 @@ export class ToolExecutor { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; if (!args.url) return { success: false, content: '', error: 'Missing url' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.download(args.url, args.filename); + const result = await inst.instance.download(args.url, args.filename); if (!result.success) return { success: false, content: '', error: result.error || 'Download failed' }; const content = `## Download Complete\n\n` + @@ -1635,14 +1569,16 @@ export class ToolExecutor { if (!args.element_id) return { success: false, content: '', error: 'Missing element_id' }; if (!args.file_path) return { success: false, content: '', error: 'Missing file_path' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.upload(args.element_id, args.file_path); + const result = await inst.instance.upload(elementId!, args.file_path); if (!result.success) return { success: false, content: '', error: result.error || 'Upload failed' }; - return { success: true, content: `Uploaded "${args.file_path}" to element ${args.element_id}` }; + return { success: true, content: `Uploaded "${args.file_path}" to element #${elementId}` }; } catch (error) { return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; } @@ -1654,11 +1590,11 @@ export class ToolExecutor { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; if (!args.url) return { success: false, content: '', error: 'Missing url' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.pdfExtract(args.url); + const result = await inst.instance.pdfExtract(args.url); if (!result.success) return { success: false, content: '', error: result.error || 'PDF extraction failed' }; let content = `## PDF Extract\n\n` + @@ -1682,11 +1618,11 @@ export class ToolExecutor { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; if (!args.url) return { success: false, content: '', error: 'Missing url' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.feedParse(args.url); + const result = await inst.instance.feedParse(args.url); if (!result.success) return { success: false, content: '', error: result.error || 'Feed parse failed' }; let content = `## ${result.feed_type.toUpperCase()} Feed\n\n` + @@ -1715,11 +1651,11 @@ export class ToolExecutor { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; if (!args.resource_types) return { success: false, content: '', error: 'Missing resource_types' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.networkBlock(args.resource_types); + const result = await inst.instance.networkBlock(args.resource_types); if (!result.success) return { success: false, content: '', error: result.error || 'Network block failed' }; const content = args.resource_types.length === 0 @@ -1735,11 +1671,11 @@ export class ToolExecutor { private async handleNetworkLog(args: ToolCallArgs): Promise { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.networkLog(args.filter); + const result = await inst.instance.networkLog(args.filter); if (!result.success) return { success: false, content: '', error: result.error || 'Network log failed' }; let content = `## Network Log (${result.count} requests)\n\n`; @@ -1762,14 +1698,16 @@ export class ToolExecutor { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; if (!args.element_id) return { success: false, content: '', error: 'Missing element_id' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const elementId = this.resolveElementId(args); + + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.iframeEnter(args.element_id); + const result = await inst.instance.iframeEnter(elementId!); if (!result.success) return { success: false, content: '', error: result.error || 'Failed to enter iframe' }; - return { success: true, content: `Entered iframe ${args.element_id}. Subsequent commands now operate within the iframe.` }; + return { success: true, content: `Entered iframe #${elementId}. Subsequent commands now operate within the iframe.` }; } catch (error) { return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; } @@ -1778,11 +1716,11 @@ export class ToolExecutor { private async handleIframeExit(args: ToolCallArgs): Promise { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.iframeExit(); + const result = await inst.instance.iframeExit(); if (!result.success) return { success: false, content: '', error: result.error || 'Failed to exit iframe' }; return { success: true, content: 'Returned to parent page context.' }; @@ -1796,11 +1734,11 @@ export class ToolExecutor { private async handleDiff(args: ToolCallArgs): Promise { if (!args.instance_id) return { success: false, content: '', error: 'Missing instance_id' }; - const instance = this.browserManager.getInstance(args.instance_id); - if (!instance) return { success: false, content: '', error: `Browser instance "${args.instance_id}" not found` }; + const inst = this.requireInstance(args); + if (inst.error) return inst.error; try { - const result = await instance.diff(); + const result = await inst.instance.diff(); if (!result.success) return { success: false, content: '', error: result.error || 'Diff failed' }; let content = `## Page Diff (${result.change_count} changes)\n\n`; @@ -1826,6 +1764,118 @@ export class ToolExecutor { } } + // --------------------------------------------------------------------------- + // OAuth 2.0 / OIDC handlers + // --------------------------------------------------------------------------- + + private async handleOAuthSetProvider(args: ToolCallArgs): Promise { + const inst = this.requireInstance(args); + if (inst.error) return inst.error; + + try { + const data = await inst.instance.sendCommand('OAuth.setProvider', { + name: args.provider || args.name, + client_id: args.client_id, + client_secret: args.client_secret, + issuer: args.issuer, + authorization_endpoint: args.authorization_endpoint, + token_endpoint: args.token_endpoint, + scopes: args.scopes, + redirect_uri: args.redirect_uri, + }) as Record; + + return { + success: true, + content: `OAuth provider "${data?.provider}" registered successfully.${data?.discovered ? ' Endpoints auto-discovered via OIDC.' : ''}`, + }; + } catch (error) { + return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; + } + } + + private async handleOAuthStart(args: ToolCallArgs): Promise { + const inst = this.requireInstance(args); + if (inst.error) return inst.error; + + try { + const navData = await inst.instance.sendCommand('OAuth.navigateForAuth', { + provider: args.provider, + }) as Record; + const status = navData?.status as string; + + if (status === 'callback_captured') { + return { + success: true, + content: `OAuth callback captured. Authorization code received. Call browser_oauth_complete to exchange for tokens.`, + }; + } + + return { + success: true, + content: `OAuth flow started. Landed on login/consent page at: ${navData?.url}.\n\nUse browser_fill and browser_submit to log in, then call browser_oauth_complete to finish the flow.`, + }; + } catch (error) { + return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; + } + } + + private async handleOAuthComplete(args: ToolCallArgs): Promise { + const inst = this.requireInstance(args); + if (inst.error) return inst.error; + + try { + const data = await inst.instance.sendCommand('OAuth.completeFlow', { + provider: args.provider, + code: args.code, + }) as Record; + + let content = `OAuth flow completed successfully.\n`; + content += `- Has refresh token: ${data?.hasRefreshToken ? 'yes' : 'no'}\n`; + if (data?.expiresAt) content += `- Expires at: ${new Date((data.expiresAt as number) * 1000).toISOString()}\n`; + if (data?.scopes) content += `- Scopes: ${(data.scopes as string[]).join(', ')}\n`; + if (data?.idTokenClaims) { + const claims = data.idTokenClaims as Record; + content += `- User: ${claims.email || claims.sub}\n`; + } + content += `\nTokens will be automatically injected into future requests to this provider's domain.`; + + return { success: true, content }; + } catch (error) { + return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; + } + } + + private async handleOAuthStatus(args: ToolCallArgs): Promise { + const inst = this.requireInstance(args); + if (inst.error) return inst.error; + + try { + const data = await inst.instance.sendCommand('OAuth.listSessions', {}) as Record; + const sessions = (data?.sessions || []) as Array>; + + if (sessions.length === 0) { + return { success: true, content: 'No OAuth sessions registered. Use browser_oauth_set_provider to register one.' }; + } + + let content = `## OAuth Sessions (${sessions.length})\n\n`; + for (const session of sessions) { + const icon = session.status === 'active' ? '✓' : session.status === 'authorization_pending' ? '⏳' : '○'; + content += `${icon} **${session.provider}** — ${session.status}\n`; + content += ` Access token: ${session.has_access_token ? 'yes' : 'no'} | Refresh token: ${session.has_refresh_token ? 'yes' : 'no'}\n`; + if (session.expires_at) { + const expired = (session.expires_at as number) * 1000 < Date.now(); + content += ` Expires: ${new Date((session.expires_at as number) * 1000).toISOString()} ${expired ? '(EXPIRED)' : ''}\n`; + } + if (session.scopes) content += ` Scopes: ${session.scopes}\n`; + content += '\n'; + } + + return { success: true, content }; + } catch (error) { + return { success: false, content: '', error: error instanceof Error ? error.message : String(error) }; + } + } + private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/ai-agent/pardus-browser/src/tools/types.ts b/ai-agent/pardus-browser/src/tools/types.ts index 1cf4b9c..84fe590 100644 --- a/ai-agent/pardus-browser/src/tools/types.ts +++ b/ai-agent/pardus-browser/src/tools/types.ts @@ -73,7 +73,22 @@ export function canExecuteInParallel( } // Same instance - check if operations are read-only - const readOnlyTools = ['browser_get_state', 'browser_list', 'browser_get_cookies', 'browser_get_storage', 'browser_get_action_plan']; + const readOnlyTools: string[] = [ + 'browser_get_state', + 'browser_list', + 'browser_get_cookies', + 'browser_get_storage', + 'browser_get_action_plan', + 'browser_extract_text', + 'browser_extract_links', + 'browser_find', + 'browser_extract_table', + 'browser_extract_metadata', + 'browser_screenshot', + 'browser_oauth_status', + 'browser_network_log', + 'browser_diff', + ]; const isReadOnly1 = readOnlyTools.includes(tool1.name); const isReadOnly2 = readOnlyTools.includes(tool2.name); diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e490caf --- /dev/null +++ b/clippy.toml @@ -0,0 +1,15 @@ +# Warn on all pedantic lints except those that are too noisy +warn-on-all-wildcard-imports = true + +# MSRV — we use nightly, but document the effective minimum +msrv = "1.85.0" + +# Categorize common lints +[cognitive-complexity] +threshold = 25 + +[too-many-arguments] +threshold = 8 + +[type-complexity] +threshold = 300 diff --git a/crates/pardus-cdp/Cargo.toml b/crates/pardus-cdp/Cargo.toml index 8fb4859..a6a184d 100644 --- a/crates/pardus-cdp/Cargo.toml +++ b/crates/pardus-cdp/Cargo.toml @@ -27,3 +27,6 @@ chrono = { workspace = true } default = [] screenshot = ["pardus-core/screenshot"] tls-pinning = ["pardus-core/tls-pinning"] + +[dev-dependencies] +mockito = "1" diff --git a/crates/pardus-cdp/src/domain/mod.rs b/crates/pardus-cdp/src/domain/mod.rs index 86e905b..78f45a5 100644 --- a/crates/pardus-cdp/src/domain/mod.rs +++ b/crates/pardus-cdp/src/domain/mod.rs @@ -20,6 +20,8 @@ pub struct TargetEntry { pub js_enabled: bool, /// Serialized `FrameTree` JSON. Populated when iframe parsing is enabled. pub frame_tree_json: Option, + /// Accumulated form field values from `type` commands, keyed by field name. + pub form_state: std::collections::HashMap, } /// Shared state available to all domain handlers. All fields are Send+Sync. @@ -33,6 +35,8 @@ pub struct DomainContext { pub event_bus: Arc, /// Node map for this session (backendNodeId <-> selector). pub node_map: Arc>, + /// OAuth session manager shared across all CDP connections. + pub oauth_sessions: Arc>, /// Screenshot handle for captureScreenshot support (feature-gated). #[cfg(feature = "screenshot")] pub screenshot_handle: pardus_core::screenshot::ScreenshotHandle, @@ -45,6 +49,7 @@ impl DomainContext { targets: Arc>>, event_bus: Arc, node_map: Arc>, + oauth_sessions: Arc>, ) -> Self { #[cfg(feature = "screenshot")] let screenshot_handle = { @@ -60,6 +65,7 @@ impl DomainContext { targets, event_bus, node_map, + oauth_sessions, #[cfg(feature = "screenshot")] screenshot_handle, } @@ -119,6 +125,7 @@ impl DomainContext { title, js_enabled: true, frame_tree_json, + form_state: std::collections::HashMap::new(), }); Ok(()) } @@ -143,6 +150,7 @@ impl DomainContext { title, js_enabled: false, frame_tree_json: None, + form_state: std::collections::HashMap::new(), }); } @@ -200,6 +208,7 @@ pub mod emulation; pub mod input; pub mod log; pub mod network; +pub mod oauth; pub mod pardus_ext; pub mod page; pub mod performance; diff --git a/crates/pardus-cdp/src/domain/oauth.rs b/crates/pardus-cdp/src/domain/oauth.rs new file mode 100644 index 0000000..070366c --- /dev/null +++ b/crates/pardus-cdp/src/domain/oauth.rs @@ -0,0 +1,551 @@ +//! CDP domain handler for OAuth 2.0 / OIDC operations. + +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashMap; + +use crate::domain::{method_not_found, CdpDomainHandler, DomainContext, HandleResult}; +use crate::error::SERVER_ERROR; +use crate::protocol::message::CdpErrorResponse; +use crate::protocol::target::CdpSession; + +pub struct OAuthDomain; + +fn resolve_target_id(session: &CdpSession) -> &str { + session.target_id.as_deref().unwrap_or("default") +} + +#[async_trait(?Send)] +impl CdpDomainHandler for OAuthDomain { + fn domain_name(&self) -> &'static str { + "OAuth" + } + + async fn handle( + &self, + method: &str, + params: Value, + session: &mut CdpSession, + ctx: &DomainContext, + ) -> HandleResult { + match method { + "enable" => { + session.enable_domain("OAuth"); + HandleResult::Ack + } + "disable" => { + session.disable_domain("OAuth"); + HandleResult::Ack + } + "setProvider" => handle_set_provider(params, ctx).await, + "startFlow" => handle_start_flow(params, ctx).await, + "navigateForAuth" => { + handle_navigate_for_auth(params, session, ctx).await + } + "completeFlow" => handle_complete_flow(params, ctx).await, + "getTokens" => handle_get_tokens(params, ctx).await, + "refreshTokens" => handle_refresh_tokens(params, ctx).await, + "listSessions" => handle_list_sessions(ctx).await, + "removeSession" => handle_remove_session(params, ctx).await, + _ => method_not_found("OAuth", method), + } + } +} + +// --------------------------------------------------------------------------- +// setProvider — register an OAuth provider configuration +// --------------------------------------------------------------------------- + +async fn handle_set_provider(params: Value, ctx: &DomainContext) -> HandleResult { + let name = match params["name"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response( + crate::error::INVALID_PARAMS, + "missing 'name' parameter", + ); + } + }; + + let client_id = params["client_id"].as_str().unwrap_or("").to_string(); + let client_secret = params["client_secret"].as_str().map(String::from); + let scopes = params["scopes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + let redirect_uri = params["redirect_uri"] + .as_str() + .unwrap_or("http://localhost:8080/callback") + .to_string(); + + let mut auth_endpoint = params["authorization_endpoint"] + .as_str() + .unwrap_or("") + .to_string(); + let mut token_endpoint = params["token_endpoint"] + .as_str() + .unwrap_or("") + .to_string(); + let issuer = params["issuer"].as_str().map(String::from); + let mut userinfo_endpoint = params["userinfo_endpoint"].as_str().map(String::from); + + // If issuer is provided and endpoints are missing, try OIDC discovery + let discovered = if let Some(issuer_url) = &issuer { + if auth_endpoint.is_empty() || token_endpoint.is_empty() { + match pardus_core::oauth::oidc::discover(&ctx.app.http_client, issuer_url).await { + Ok(config) => { + if auth_endpoint.is_empty() { + auth_endpoint = config.authorization_endpoint; + } + if token_endpoint.is_empty() { + token_endpoint = config.token_endpoint; + } + if userinfo_endpoint.is_none() { + userinfo_endpoint = config.userinfo_endpoint; + } + true + } + Err(e) => { + return err_response(SERVER_ERROR, &format!("OIDC discovery failed: {e}")); + } + } + } else { + false + } + } else { + false + }; + + if auth_endpoint.is_empty() || token_endpoint.is_empty() { + return err_response( + crate::error::INVALID_PARAMS, + "authorization_endpoint and token_endpoint are required (or provide issuer for OIDC discovery)", + ); + } + + let provider_config = pardus_core::oauth::OAuthProviderConfig { + name: name.clone(), + authorization_endpoint: auth_endpoint, + token_endpoint, + client_id, + client_secret, + scopes, + redirect_uri, + issuer, + userinfo_endpoint, + }; + + let mut sessions = ctx.oauth_sessions.lock().await; + sessions.register_provider(provider_config); + + HandleResult::Success(serde_json::json!({ + "success": true, + "provider": name, + "discovered": discovered, + })) +} + +// --------------------------------------------------------------------------- +// startFlow — generate authorization URL with PKCE +// --------------------------------------------------------------------------- + +async fn handle_start_flow(params: Value, ctx: &DomainContext) -> HandleResult { + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"), + }; + + let scopes = params["scopes"].as_array().map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }); + + let extra_params: HashMap = params["extra_params"] + .as_object() + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + // Get provider config + let config = { + let sessions = ctx.oauth_sessions.lock().await; + match sessions.get_provider(&provider_name) { + Some(c) => c.clone(), + None => { + return err_response( + SERVER_ERROR, + &format!("provider '{}' not registered", provider_name), + ); + } + } + }; + + let result = pardus_core::oauth::start_authorization( + &config, + scopes.as_deref(), + &extra_params, + ); + + let auth_url = result.authorization_url.clone(); + let state = result.state.clone(); + let nonce = result.nonce.clone(); + + let mut sessions = ctx.oauth_sessions.lock().await; + match sessions.start_flow( + &provider_name, + result.state, + result.nonce, + result.pkce, + result.authorization_url, + ) { + Ok(()) => HandleResult::Success(serde_json::json!({ + "authorizationUrl": auth_url, + "state": state, + "nonce": nonce, + })), + Err(e) => err_response(SERVER_ERROR, &e.to_string()), + } +} + +// --------------------------------------------------------------------------- +// navigateForAuth — navigate to auth URL and capture redirect +// --------------------------------------------------------------------------- + +async fn handle_navigate_for_auth( + params: Value, + session: &mut CdpSession, + ctx: &DomainContext, +) -> HandleResult { + let target_id = resolve_target_id(session); + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"); + } + }; + + // Get provider config for callback URL + let callback_url = { + let sessions = ctx.oauth_sessions.lock().await; + match sessions.get_provider(&provider_name) { + Some(c) => c.redirect_uri.clone(), + None => { + return err_response( + SERVER_ERROR, + &format!("provider '{}' not registered", provider_name), + ); + } + } + }; + + // Re-build the auth URL from stored session PKCE + let auth_url = { + let sessions = ctx.oauth_sessions.lock().await; + let config = sessions.get_provider(&provider_name).unwrap().clone(); + // Note: PKCE is already stored from startFlow; we reconstruct the URL + let result = pardus_core::oauth::start_authorization(&config, None, &HashMap::new()); + result.authorization_url + }; + + // Navigate with redirect capture + match pardus_core::Page::navigate_with_redirect_capture( + &ctx.app, + &auth_url, + &callback_url, + ) + .await + { + Ok(pardus_core::OAuthNavigateResult::Callback { url, code, state }) => { + // Validate state parameter + let expected_state = { + let sessions = ctx.oauth_sessions.lock().await; + sessions.get_state(&provider_name) + }; + + if let Some(expected) = expected_state { + if state != expected { + return err_response( + SERVER_ERROR, + &format!("state mismatch: expected '{}' got '{}'", expected, state), + ); + } + } + + // Store the captured code + let mut sessions = ctx.oauth_sessions.lock().await; + let _ = sessions.set_pending_code(&provider_name, code.clone()); + + HandleResult::Success(serde_json::json!({ + "status": "callback_captured", + "code": code, + "state": state, + "url": url, + })) + } + Ok(pardus_core::OAuthNavigateResult::Page(page)) => { + // Landed on a login/consent page — update the target + let html_str = page.html.html().to_string(); + let final_url = page.url.clone(); + let title = page.title(); + + let mut targets = ctx.targets.lock().await; + targets.insert( + target_id.to_string(), + crate::domain::TargetEntry { + url: final_url.clone(), + html: Some(html_str), + title, + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }, + ); + + HandleResult::Success(serde_json::json!({ + "status": "login_required", + "url": final_url, + })) + } + Err(e) => err_response(SERVER_ERROR, &format!("OAuth navigation failed: {e}")), + } +} + +// --------------------------------------------------------------------------- +// completeFlow — exchange authorization code for tokens +// --------------------------------------------------------------------------- + +async fn handle_complete_flow(params: Value, ctx: &DomainContext) -> HandleResult { + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"); + } + }; + + let explicit_code = params["code"].as_str().map(String::from); + + // Get code, verifier, and provider config + let (code, code_verifier, config) = { + let sessions = ctx.oauth_sessions.lock().await; + let config = match sessions.get_provider(&provider_name) { + Some(c) => c.clone(), + None => { + return err_response( + SERVER_ERROR, + &format!("provider '{}' not registered", provider_name), + ); + } + }; + + let code = explicit_code.unwrap_or_else(|| { + sessions + .get_pending_code(&provider_name) + .unwrap_or_default() + }); + + let code_verifier = match sessions.get_code_verifier(&provider_name) { + Ok(v) => v, + Err(e) => return err_response(SERVER_ERROR, &e.to_string()), + }; + + (code, code_verifier, config) + }; + + if code.is_empty() { + return err_response( + SERVER_ERROR, + "no authorization code available — call navigateForAuth first or provide 'code' parameter", + ); + } + + // Exchange code for tokens + match pardus_core::oauth::exchange_code( + &ctx.app.http_client, + &config, + &code, + &code_verifier, + ) + .await + { + Ok(tokens) => { + let has_refresh = tokens.refresh_token.is_some(); + let expires_at = tokens.expires_at; + let scopes = tokens.scope.clone(); + + // Validate ID token if present + let id_token_claims = if let Some(id_token) = &tokens.id_token { + pardus_core::oauth::validate_id_token( + id_token, + config.issuer.as_deref(), + &config.client_id, + None, + ) + .ok() + } else { + None + }; + + // Store tokens + let mut sessions = ctx.oauth_sessions.lock().await; + sessions.complete_flow(&provider_name, tokens); + + HandleResult::Success(serde_json::json!({ + "success": true, + "hasRefreshToken": has_refresh, + "expiresAt": expires_at, + "scopes": scopes, + "idTokenClaims": id_token_claims, + })) + } + Err(e) => { + let mut sessions = ctx.oauth_sessions.lock().await; + sessions.fail_flow(&provider_name, e.to_string()); + + err_response(SERVER_ERROR, &format!("token exchange failed: {e}")) + } + } +} + +// --------------------------------------------------------------------------- +// getTokens — retrieve stored tokens +// --------------------------------------------------------------------------- + +async fn handle_get_tokens(params: Value, ctx: &DomainContext) -> HandleResult { + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"); + } + }; + + let sessions = ctx.oauth_sessions.lock().await; + match sessions.get_tokens(&provider_name) { + Some(tokens) => HandleResult::Success(serde_json::json!({ + "accessToken": tokens.access_token, + "tokenType": tokens.token_type, + "expiresAt": tokens.expires_at, + "scope": tokens.scope, + "hasRefreshToken": tokens.refresh_token.is_some(), + })), + None => err_response( + SERVER_ERROR, + &format!("no tokens for provider '{}'", provider_name), + ), + } +} + +// --------------------------------------------------------------------------- +// refreshTokens — refresh access token +// --------------------------------------------------------------------------- + +async fn handle_refresh_tokens(params: Value, ctx: &DomainContext) -> HandleResult { + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"); + } + }; + + let (refresh_token, config) = { + let sessions = ctx.oauth_sessions.lock().await; + let config = match sessions.get_provider(&provider_name) { + Some(c) => c.clone(), + None => { + return err_response( + SERVER_ERROR, + &format!("provider '{}' not registered", provider_name), + ); + } + }; + let refresh_token = sessions + .get_tokens(&provider_name) + .and_then(|t| t.refresh_token.clone()); + + (refresh_token, config) + }; + + let refresh_token = match refresh_token { + Some(rt) => rt, + None => return err_response(SERVER_ERROR, "no refresh token available"), + }; + + match pardus_core::oauth::refresh_tokens( + &ctx.app.http_client, + &config, + &refresh_token, + ) + .await + { + Ok(tokens) => { + let expires_at = tokens.expires_at; + let scopes = tokens.scope.clone(); + let has_refresh = tokens.refresh_token.is_some(); + + let mut sessions = ctx.oauth_sessions.lock().await; + sessions.complete_flow(&provider_name, tokens); + + HandleResult::Success(serde_json::json!({ + "success": true, + "hasRefreshToken": has_refresh, + "expiresAt": expires_at, + "scopes": scopes, + })) + } + Err(e) => err_response(SERVER_ERROR, &format!("token refresh failed: {e}")), + } +} + +// --------------------------------------------------------------------------- +// listSessions — list all OAuth sessions +// --------------------------------------------------------------------------- + +async fn handle_list_sessions(ctx: &DomainContext) -> HandleResult { + let sessions = ctx.oauth_sessions.lock().await; + let list = sessions.get_all_sessions(); + HandleResult::Success(serde_json::json!({ + "sessions": list, + })) +} + +// --------------------------------------------------------------------------- +// removeSession — remove a session +// --------------------------------------------------------------------------- + +async fn handle_remove_session(params: Value, ctx: &DomainContext) -> HandleResult { + let provider_name = match params["provider"].as_str() { + Some(n) => n.to_string(), + None => { + return err_response(crate::error::INVALID_PARAMS, "missing 'provider' parameter"); + } + }; + + let mut sessions = ctx.oauth_sessions.lock().await; + let removed = sessions.remove_session(&provider_name); + + HandleResult::Success(serde_json::json!({ + "success": removed, + })) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn err_response(code: i64, message: &str) -> HandleResult { + HandleResult::Error(CdpErrorResponse { + id: 0, + error: crate::error::CdpErrorBody { + code, + message: message.to_string(), + }, + session_id: None, + }) +} diff --git a/crates/pardus-cdp/src/domain/page.rs b/crates/pardus-cdp/src/domain/page.rs index e1881c4..97fe1b6 100644 --- a/crates/pardus-cdp/src/domain/page.rs +++ b/crates/pardus-cdp/src/domain/page.rs @@ -1,10 +1,14 @@ use async_trait::async_trait; use serde_json::Value; -use crate::domain::{method_not_found, CdpDomainHandler, DomainContext, HandleResult}; -use crate::error::{CdpError, CdpErrorBody}; -use crate::protocol::message::{CdpErrorResponse, CdpEvent}; -use crate::protocol::target::CdpSession; +use crate::{ + domain::{CdpDomainHandler, DomainContext, HandleResult, method_not_found}, + error::{CdpError, CdpErrorBody}, + protocol::{ + message::{CdpErrorResponse, CdpEvent}, + target::CdpSession, + }, +}; pub struct PageDomain; @@ -38,9 +42,7 @@ fn resolve_target_id(session: &CdpSession) -> &str { #[async_trait(?Send)] impl CdpDomainHandler for PageDomain { - fn domain_name(&self) -> &'static str { - "Page" - } + fn domain_name(&self) -> &'static str { "Page" } async fn handle( &self, @@ -69,7 +71,10 @@ impl CdpDomainHandler for PageDomain { match ctx.navigate(&target_id, url).await { Ok(()) => { - let final_url = ctx.get_url(&target_id).await.unwrap_or_else(|| url.to_string()); + let final_url = ctx + .get_url(&target_id) + .await + .unwrap_or_else(|| url.to_string()); let title = ctx.get_title(&target_id).await; let _ = ctx.event_bus.send(CdpEvent { @@ -118,7 +123,10 @@ impl CdpDomainHandler for PageDomain { let target_id = resolve_target_id(session).to_string(); let url = { let targets = ctx.targets.lock().await; - targets.get(&target_id).map(|t| t.url.clone()).unwrap_or_else(|| "about:blank".to_string()) + targets + .get(&target_id) + .map(|t| t.url.clone()) + .unwrap_or_else(|| "about:blank".to_string()) }; match ctx.navigate(&target_id, &url).await { Ok(()) => { @@ -160,8 +168,16 @@ impl CdpDomainHandler for PageDomain { let targets = ctx.targets.lock().await; let entry = targets.get(&target_id); let (frame_id, url, _title) = entry - .map(|t| (target_id.clone(), t.url.clone(), t.title.clone().unwrap_or_default())) - .unwrap_or_else(|| ("main".to_string(), "about:blank".to_string(), String::new())); + .map(|t| { + ( + target_id.clone(), + t.url.clone(), + t.title.clone().unwrap_or_default(), + ) + }) + .unwrap_or_else(|| { + ("main".to_string(), "about:blank".to_string(), String::new()) + }); let frame_tree_json = entry.and_then(|e| e.frame_tree_json.clone()); let child_frames = if let Some(json_str) = &frame_tree_json { @@ -187,7 +203,8 @@ impl CdpDomainHandler for PageDomain { "addScriptToEvaluateOnNewDocument" => { let _source = params["source"].as_str().unwrap_or(""); let world_name = params["worldName"].as_str().unwrap_or(""); - let include_command_line_api = params["includeCommandLineAPI"].as_bool().unwrap_or(false); + let include_command_line_api = + params["includeCommandLineAPI"].as_bool().unwrap_or(false); let _ = (world_name, include_command_line_api); HandleResult::Success(serde_json::json!({ @@ -233,9 +250,16 @@ impl CdpDomainHandler for PageDomain { let _frame_id = params["frameId"].as_str().unwrap_or(""); let url = params["url"].as_str().unwrap_or(""); - let html = ctx.get_html(resolve_target_id(session)).await + let html = ctx + .get_html(resolve_target_id(session)) + .await .unwrap_or_default(); - let content = if url == ctx.get_url(resolve_target_id(session)).await.unwrap_or_default() { + let content = if url + == ctx + .get_url(resolve_target_id(session)) + .await + .unwrap_or_default() + { html } else { String::new() @@ -258,15 +282,14 @@ impl CdpDomainHandler for PageDomain { let format_str = params["format"].as_str().unwrap_or("png"); let quality = params["quality"].as_u64().map(|q| q as u8); let has_clip = !params["clip"].is_null(); - let full_page = params["captureBeyondViewport"].as_bool() + let full_page = params["captureBeyondViewport"] + .as_bool() .unwrap_or(has_clip); let screenshot_format = match format_str { - "jpeg" => { - pardus_core::screenshot::ScreenshotFormat::Jpeg { - quality: quality.unwrap_or(85), - } - } + "jpeg" => pardus_core::screenshot::ScreenshotFormat::Jpeg { + quality: quality.unwrap_or(85), + }, _ => pardus_core::screenshot::ScreenshotFormat::Png, }; @@ -299,22 +322,25 @@ impl CdpDomainHandler for PageDomain { id: 0, error: CdpErrorBody { code: crate::error::SERVER_ERROR, - message: "Screenshots not supported. PardusBrowser is a semantic-only browser (no rendering engine). Rebuild with --features screenshot to enable.".to_string(), + message: "Screenshots not supported. PardusBrowser is a semantic-only \ + browser (no rendering engine). Rebuild with --features \ + screenshot to enable." + .to_string(), }, session_id: None, }) } } - "printToPDF" => { - HandleResult::Error(CdpErrorResponse { - id: 0, - error: CdpErrorBody { - code: crate::error::SERVER_ERROR, - message: "PDF generation not supported. PardusBrowser is a semantic-only browser (no rendering engine).".to_string(), - }, - session_id: None, - }) - } + "printToPDF" => HandleResult::Error(CdpErrorResponse { + id: 0, + error: CdpErrorBody { + code: crate::error::SERVER_ERROR, + message: "PDF generation not supported. PardusBrowser is a semantic-only \ + browser (no rendering engine)." + .to_string(), + }, + session_id: None, + }), "startScreencast" => HandleResult::Ack, "stopScreencast" => HandleResult::Ack, "screencastFrameAck" => HandleResult::Ack, @@ -323,23 +349,21 @@ impl CdpDomainHandler for PageDomain { let _behavior = params["behavior"].as_str().unwrap_or("deny"); HandleResult::Ack } - "getFileChooser" => { - HandleResult::Error(CdpErrorResponse { - id: 0, - error: CdpErrorBody { - code: crate::error::SERVER_ERROR, - message: "File chooser not supported".to_string(), - }, - session_id: None, - }) - } - "getInstallabilityError" => HandleResult::Success(serde_json::json!({ "installabilityErrors": [] })), - "getAppManifest" => { - HandleResult::Success(serde_json::json!({ - "url": Value::Null, - "errors": [], - })) + "getFileChooser" => HandleResult::Error(CdpErrorResponse { + id: 0, + error: CdpErrorBody { + code: crate::error::SERVER_ERROR, + message: "File chooser not supported".to_string(), + }, + session_id: None, + }), + "getInstallabilityError" => { + HandleResult::Success(serde_json::json!({ "installabilityErrors": [] })) } + "getAppManifest" => HandleResult::Success(serde_json::json!({ + "url": Value::Null, + "errors": [], + })), "getOriginTrialInfo" => HandleResult::Success(serde_json::json!({ "origins": [] })), "setInterceptFileChooserDialog" => HandleResult::Ack, "toggleInterceptFileChooserDialog" => HandleResult::Ack, @@ -351,7 +375,8 @@ impl CdpDomainHandler for PageDomain { "createIsolatedWorld" => { let frame_id = params["frameId"].as_str().unwrap_or("main"); let _world_name = params["worldName"].as_str().unwrap_or(""); - let _grant_univeral_access = params["grantUniveralAccess"].as_bool().unwrap_or(false); + let _grant_universal_access = + params["grantUniversalAccess"].as_bool().unwrap_or(false); let ctx_id = session.create_execution_context(frame_id.to_string(), "".to_string()); HandleResult::Success(serde_json::json!({ "executionContextId": ctx_id, @@ -364,13 +389,11 @@ impl CdpDomainHandler for PageDomain { let _height = params["height"].as_u64().unwrap_or(720); HandleResult::Ack } - "getFrameResource" => { - HandleResult::Success(serde_json::json!({ - "content": "", - "mimeType": "", - "statusCode": 200, - })) - } + "getFrameResource" => HandleResult::Success(serde_json::json!({ + "content": "", + "mimeType": "", + "statusCode": 200, + })), "getFrameResourceTree" => { let target_id = resolve_target_id(session).to_string(); let targets = ctx.targets.lock().await; @@ -394,13 +417,13 @@ impl CdpDomainHandler for PageDomain { } })) } - "searchInResource" => { - HandleResult::Success(serde_json::json!({ "result": [] })) - } + "searchInResource" => HandleResult::Success(serde_json::json!({ "result": [] })), "setWebLifecycleState" => HandleResult::Ack, "enableLifecycleEvents" => HandleResult::Ack, "setPrerenderingAllowed" => HandleResult::Ack, - "getBackForwardCache" => HandleResult::Success(serde_json::json!({ "prerenderInfo": [] })), + "getBackForwardCache" => { + HandleResult::Success(serde_json::json!({ "prerenderInfo": [] })) + } "registerNonTrackedLoadEventFired" => HandleResult::Ack, "attemptNavigation" => HandleResult::Ack, _ => method_not_found("Page", method), @@ -411,8 +434,12 @@ impl CdpDomainHandler for PageDomain { fn frame_data_to_cdp(frame: &serde_json::Value) -> serde_json::Value { let id = frame["id"].as_str().unwrap_or_default(); let url = frame["url"].as_str().unwrap_or_default(); - let has_error = frame.get("load_error").and_then(|e| e.as_str()).map_or(false, |s| !s.is_empty()); - let child_frames: Vec = frame.get("child_frames") + let has_error = frame + .get("load_error") + .and_then(|e| e.as_str()) + .map_or(false, |s| !s.is_empty()); + let child_frames: Vec = frame + .get("child_frames") .and_then(|c| c.as_array()) .map(|arr| arr.iter().map(|f| frame_data_to_cdp(f)).collect()) .unwrap_or_default(); diff --git a/crates/pardus-cdp/src/domain/pardus_ext.rs b/crates/pardus-cdp/src/domain/pardus_ext.rs index ce85005..e3ebc54 100644 --- a/crates/pardus-cdp/src/domain/pardus_ext.rs +++ b/crates/pardus-cdp/src/domain/pardus_ext.rs @@ -55,7 +55,7 @@ impl CdpDomainHandler for PardusDomain { pardus_core::Page::from_html(&html_str, &url) }; let tree = page.semantic_tree(); - let result = serde_json::to_value(&tree).unwrap_or(serde_json::json!({ + let result = serde_json::to_value(&*tree).unwrap_or(serde_json::json!({ "error": "Failed to serialize semantic tree" })); HandleResult::Success(serde_json::json!({ @@ -77,6 +77,7 @@ impl CdpDomainHandler for PardusDomain { let selector = params["selector"].as_str().unwrap_or("").to_string(); let value = params["value"].as_str().unwrap_or("").to_string(); let fields_param = params.get("fields").cloned(); + let href = params.get("href").and_then(|v| v.as_str()).unwrap_or("").to_string(); let result = if !action.is_empty() { let session_id = session.session_id.clone(); @@ -84,6 +85,33 @@ impl CdpDomainHandler for PardusDomain { let res = handle_interact(&action, &selector, &value, target_id, &fields_param, ctx).await; + // Fallback: if click failed but we have an href, navigate directly + let res = if res["success"].as_bool() == Some(false) && !href.is_empty() { + match ctx.navigate(target_id, &href).await { + Ok(()) => { + let new_url = ctx.get_url(target_id).await.unwrap_or_else(|| href.clone()); + + // Emit Page.frameNavigated so CDP bridge/frontend sync + ctx.event_bus.send(crate::protocol::message::CdpEvent { + method: "Page.frameNavigated".to_string(), + params: serde_json::json!({ + "frame": { + "id": target_id, + "url": new_url, + "mimeType": "text/html", + } + }), + session_id: Some(session_id.clone()), + }); + + serde_json::json!({ "success": true, "action": "click", "selector": selector, "navigated": true, "url": new_url, "fallback": "href" }) + } + Err(e) => serde_json::json!({ "success": false, "error": format!("href fallback navigation failed: {}", e), "selector": selector }) + } + } else { + res + }; + emit_action_completed(ctx, &action, &selector, &res, &session_id); res } else { @@ -332,41 +360,148 @@ async fn handle_interact( let Some(h) = handle else { return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); }; - if let Some(href) = &h.href { - match ctx.navigate(target_id, href).await { - Ok(()) => serde_json::json!({ "success": true, "action": "click", "selector": selector }), - Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), - } - } else { - // Non-link element: check if interactive - if h.action.is_some() { - serde_json::json!({ "success": true, "action": "click", "selector": selector, "tag": h.tag }) - } else { - serde_json::json!({ "success": true, "action": "click", "selector": selector, "note": "Element exists but is not a link" }) + + // Build form state from accumulated typed values + any provided fields + let mut form_state = pardus_core::interact::FormState::new(); + { + let targets = ctx.targets.lock().await; + if let Some(entry) = targets.get(target_id) { + for (name, val) in &entry.form_state { + form_state.set(name, val); + } } } + merge_fields_into_form_state(&mut form_state, fields_param); + + match pardus_core::interact::actions::click(&ctx.app, &page, &h, &form_state).await { + Ok(result) => match result { + pardus_core::interact::InteractionResult::Navigated(new_page) => { + update_target_from_page(ctx, target_id, &new_page).await; + serde_json::json!({ "success": true, "action": "click", "selector": selector, "navigated": true, "url": new_page.url }) + } + pardus_core::interact::InteractionResult::ElementNotFound { selector: sel, reason } => { + serde_json::json!({ "success": false, "error": reason, "selector": sel }) + } + _ => { + serde_json::json!({ "success": true, "action": "click", "selector": selector }) + } + }, + Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), + } } "type" => { let Some(h) = handle else { return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); }; - match pardus_core::interact::actions::type_text(&page, &h, value) { - Ok(_) => serde_json::json!({ "success": true, "action": "type", "selector": selector }), - Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), + + // Store the typed value in the target's form state so it can be + // used when the form is later submitted via click or submit. + if let Some(name) = &h.name { + let mut targets = ctx.targets.lock().await; + if let Some(entry) = targets.get_mut(target_id) { + entry.form_state.insert(name.clone(), value.to_string()); + } } + + serde_json::json!({ "success": true, "action": "type", "selector": selector, "value": value }) } "submit" => { - if handle.is_some() { - let _ = fields_param; - serde_json::json!({ "success": true, "action": "submit", "selector": selector, "note": "Form element found" }) + // Build form state from accumulated typed values + provided fields + let mut form_state = pardus_core::interact::FormState::new(); + { + let targets = ctx.targets.lock().await; + if let Some(entry) = targets.get(target_id) { + for (name, val) in &entry.form_state { + form_state.set(name, val); + } + } + } + merge_fields_into_form_state(&mut form_state, fields_param); + + // The selector should target a
element directly, or we try to + // find a form associated with the selected element. + let form_selector = if handle.is_some() { + // Check if the selected element is a form itself or inside a form + let h = handle.as_ref().unwrap(); + if h.tag == "form" { + selector.to_string() + } else { + // Try to find enclosing form via the element's CSS selector + find_enclosing_form(&page, &h.selector).unwrap_or_else(|| selector.to_string()) + } } else { - serde_json::json!({ "success": false, "error": "Form not found" }) + // Fallback: try the selector as a form selector + selector.to_string() + }; + + match pardus_core::interact::form::submit_form(&ctx.app, &page, &form_selector, &form_state).await { + Ok(result) => match result { + pardus_core::interact::InteractionResult::Navigated(new_page) => { + update_target_from_page(ctx, target_id, &new_page).await; + serde_json::json!({ "success": true, "action": "submit", "selector": selector, "navigated": true, "url": new_page.url }) + } + pardus_core::interact::InteractionResult::ElementNotFound { selector: sel, reason } => { + serde_json::json!({ "success": false, "error": reason, "selector": sel }) + } + _ => { + serde_json::json!({ "success": true, "action": "submit", "selector": selector }) + } + }, + Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), } } "scroll" => { // Scroll is handled client-side; just acknowledge serde_json::json!({ "success": true, "action": "scroll" }) } + "toggle" => { + let Some(h) = handle else { + return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); + }; + match pardus_core::interact::actions::toggle(&page, &h) { + Ok(pardus_core::interact::InteractionResult::Toggled { checked, .. }) => { + // Record toggle state in form_state + if let Some(name) = &h.name { + let toggle_val = h.value.as_deref().unwrap_or("on"); + let mut targets = ctx.targets.lock().await; + if let Some(entry) = targets.get_mut(target_id) { + if checked { + entry.form_state.insert(name.clone(), toggle_val.to_string()); + } else { + entry.form_state.remove(name); + } + } + } + serde_json::json!({ "success": true, "action": "toggle", "selector": selector, "checked": checked }) + } + Ok(pardus_core::interact::InteractionResult::ElementNotFound { reason, .. }) => { + serde_json::json!({ "success": false, "error": reason }) + } + Ok(other) => serde_json::json!({ "success": true, "action": "toggle", "selector": selector, "note": format!("{:?}", other) }), + Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), + } + } + "select" => { + let Some(h) = handle else { + return serde_json::json!({ "success": false, "error": format!("Element {} not found", selector) }); + }; + match pardus_core::interact::actions::select_option(&page, &h, value) { + Ok(pardus_core::interact::InteractionResult::Selected { value: selected_val, .. }) => { + if let Some(name) = &h.name { + let mut targets = ctx.targets.lock().await; + if let Some(entry) = targets.get_mut(target_id) { + entry.form_state.insert(name.clone(), selected_val.clone()); + } + } + serde_json::json!({ "success": true, "action": "select", "selector": selector, "value": selected_val }) + } + Ok(pardus_core::interact::InteractionResult::ElementNotFound { reason, .. }) => { + serde_json::json!({ "success": false, "error": reason }) + } + Ok(other) => serde_json::json!({ "success": true, "action": "select", "selector": selector, "note": format!("{:?}", other) }), + Err(e) => serde_json::json!({ "success": false, "error": e.to_string() }), + } + } _ => serde_json::json!({ "success": false, "error": format!("Unknown action '{}'", action) @@ -374,6 +509,62 @@ async fn handle_interact( } } +/// Update target store after a successful page navigation or form submission. +async fn update_target_from_page(ctx: &DomainContext, target_id: &str, new_page: &pardus_core::Page) { + let html_str = new_page.html.html().to_string(); + let url = new_page.url.clone(); + let title = new_page.title(); + let frame_tree_json = new_page.frame_tree.as_ref() + .and_then(|ft| serde_json::to_string(ft).ok()); + + let mut targets = ctx.targets.lock().await; + if let Some(entry) = targets.get_mut(target_id) { + entry.url = url; + entry.html = Some(html_str); + entry.title = title; + entry.frame_tree_json = frame_tree_json; + entry.form_state.clear(); + } +} + +/// Merge fields from a JSON object into a FormState. +fn merge_fields_into_form_state(form_state: &mut pardus_core::interact::FormState, fields_param: &Option) { + if let Some(fields) = fields_param { + if let Some(obj) = fields.as_object() { + for (key, val) in obj { + if let Some(v) = val.as_str() { + form_state.set(key, v); + } + } + } + } +} + +/// Walk up the DOM from the element matching `element_selector` to find an +/// enclosing ``. Returns a CSS selector for the form. +fn find_enclosing_form(page: &pardus_core::Page, element_selector: &str) -> Option { + use scraper::{Selector, ElementRef}; + + let sel = Selector::parse(element_selector).ok()?; + let el = page.html.select(&sel).next()?; + + let mut current = el.parent().and_then(ElementRef::wrap); + while let Some(parent) = current { + if parent.value().name() == "form" { + let form_sel = if let Some(id) = parent.value().attr("id") { + format!("#{}", id) + } else if let Some(action) = parent.value().attr("action") { + format!("form[action=\"{}\"]", action) + } else { + "form".to_string() + }; + return Some(form_sel); + } + current = parent.parent().and_then(ElementRef::wrap); + } + None +} + fn collect_interactive_nodes(node: &pardus_core::SemanticNode, out: &mut Vec) { if node.is_interactive { out.push(serde_json::json!({ @@ -429,3 +620,116 @@ fn emit_action_completed(ctx: &DomainContext, action: &str, selector: &str, resu session_id: Some(session_id.to_string()), }); } + +#[cfg(test)] +mod tests { + use super::*; + use pardus_core::Page; + + fn page_from(html: &str) -> Page { + Page::from_html(html, "https://example.com/page") + } + + // ----------------------------------------------------------------------- + // find_enclosing_form + // ----------------------------------------------------------------------- + + #[test] + fn test_find_enclosing_form_by_id() { + let html = r#" + + + + "#; + let page = page_from(html); + let result = find_enclosing_form(&page, r#"button[name="go"]"#); + assert_eq!(result, Some("#login-form".to_string())); + } + + #[test] + fn test_find_enclosing_form_by_action() { + let html = r#" +
+ + +
+ "#; + let page = page_from(html); + let result = find_enclosing_form(&page, r#"button[type="submit"]"#); + assert_eq!(result, Some(r#"form[action="/search"]"#.to_string())); + } + + #[test] + fn test_find_enclosing_form_fallback() { + let html = r#" +
+ + +
+ "#; + let page = page_from(html); + let result = find_enclosing_form(&page, r#"button[type="submit"]"#); + assert_eq!(result, Some("form".to_string())); + } + + #[test] + fn test_find_enclosing_form_no_parent() { + let html = r#" + + "#; + let page = page_from(html); + let result = find_enclosing_form(&page, "button"); + assert_eq!(result, None); + } + + #[test] + fn test_find_enclosing_form_nested_deeply() { + let html = r#" +
+
+
+
+ +
+
+
+
+ "#; + let page = page_from(html); + let result = find_enclosing_form(&page, r#"input[name="deep-input"]"#); + assert_eq!(result, Some("#deep-form".to_string())); + } + + // ----------------------------------------------------------------------- + // merge_fields_into_form_state + // ----------------------------------------------------------------------- + + #[test] + fn test_merge_fields_into_form_state() { + let mut form_state = pardus_core::interact::FormState::new(); + let fields = serde_json::json!({ + "username": "alice", + "password": "secret123" + }); + merge_fields_into_form_state(&mut form_state, &Some(fields)); + assert_eq!(form_state.get("username"), Some("alice")); + assert_eq!(form_state.get("password"), Some("secret123")); + } + + #[test] + fn test_merge_fields_does_not_override_when_none() { + let mut form_state = pardus_core::interact::FormState::new(); + form_state.set("existing", "value"); + merge_fields_into_form_state(&mut form_state, &None); + assert_eq!(form_state.get("existing"), Some("value")); + } + + #[test] + fn test_merge_fields_overrides_existing() { + let mut form_state = pardus_core::interact::FormState::new(); + form_state.set("username", "old"); + let fields = serde_json::json!({ "username": "new" }); + merge_fields_into_form_state(&mut form_state, &Some(fields)); + assert_eq!(form_state.get("username"), Some("new")); + } +} diff --git a/crates/pardus-cdp/src/domain/target.rs b/crates/pardus-cdp/src/domain/target.rs index 87a4110..3418f0c 100644 --- a/crates/pardus-cdp/src/domain/target.rs +++ b/crates/pardus-cdp/src/domain/target.rs @@ -41,6 +41,7 @@ impl CdpDomainHandler for TargetDomain { title: None, js_enabled: false, frame_tree_json: None, + form_state: std::collections::HashMap::new(), }); let _ = ctx.event_bus.send(CdpEvent { diff --git a/crates/pardus-cdp/src/server.rs b/crates/pardus-cdp/src/server.rs index 4cf3419..1d0002a 100644 --- a/crates/pardus-cdp/src/server.rs +++ b/crates/pardus-cdp/src/server.rs @@ -11,6 +11,7 @@ use crate::domain::emulation::EmulationDomain; use crate::domain::input::InputDomain; use crate::domain::log::LogDomain; use crate::domain::network::NetworkDomain; +use crate::domain::oauth::OAuthDomain; use crate::domain::pardus_ext::PardusDomain; use crate::domain::page::PageDomain; use crate::domain::performance::PerformanceDomain; @@ -72,6 +73,7 @@ impl CdpServer { tracing::info!("Max connections: {}", self.max_connections); let event_bus = Arc::new(EventBus::new(1024)); + let oauth_sessions = Arc::new(Mutex::new(pardus_core::oauth::OAuthSessionManager::new())); let registry = build_registry(); let router = Arc::new(CdpRouter::new(registry)); let conn_semaphore = Arc::new(Semaphore::new(self.max_connections)); @@ -103,11 +105,12 @@ impl CdpServer { let router = router.clone(); let event_bus = event_bus.clone(); + let oauth_sessions = oauth_sessions.clone(); let app = self.app.clone(); let timeout = self.timeout; tokio::task::spawn_local(async move { - if let Err(e) = handle_connection(stream, peer_addr, router, event_bus, app, timeout).await { + if let Err(e) = handle_connection(stream, peer_addr, router, event_bus, oauth_sessions, app, timeout).await { tracing::error!("Connection error from {}: {}", peer_addr, e); } drop(permit); @@ -131,6 +134,7 @@ async fn handle_connection( peer_addr: std::net::SocketAddr, router: Arc, event_bus: Arc, + oauth_sessions: Arc>, app: Arc, timeout: u64, ) -> anyhow::Result<()> { @@ -172,6 +176,7 @@ async fn handle_connection( targets, event_bus.clone(), node_map, + oauth_sessions, )); crate::transport::ws::handle_websocket( ws_stream, router, ctx, event_bus, timeout, @@ -201,5 +206,6 @@ fn build_registry() -> DomainRegistry { registry.register(Box::new(SecurityDomain)); registry.register(Box::new(PerformanceDomain)); registry.register(Box::new(PardusDomain)); + registry.register(Box::new(OAuthDomain)); registry } diff --git a/crates/pardus-cdp/tests/domain_context_test.rs b/crates/pardus-cdp/tests/domain_context_test.rs index 5a5db30..600ff4d 100644 --- a/crates/pardus-cdp/tests/domain_context_test.rs +++ b/crates/pardus-cdp/tests/domain_context_test.rs @@ -3,14 +3,14 @@ //! Tests that DomainContext correctly creates Browser instances from App config //! and that navigation/reload operations work through the Browser API. -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::Mutex; +use std::{collections::HashMap, sync::Arc}; -use pardus_cdp::domain::{DomainContext, TargetEntry}; -use pardus_cdp::protocol::event_bus::EventBus; -use pardus_cdp::protocol::node_map::NodeMap; +use pardus_cdp::{ + domain::{DomainContext, TargetEntry}, + protocol::{event_bus::EventBus, node_map::NodeMap}, +}; use pardus_core::{App, BrowserConfig}; +use tokio::sync::Mutex; // --------------------------------------------------------------------------- // DomainContext Creation Tests @@ -18,7 +18,7 @@ use pardus_core::{App, BrowserConfig}; #[test] fn test_domain_context_new() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -40,7 +40,7 @@ fn test_domain_context_new() { #[test] fn test_domain_context_create_browser() { let config = BrowserConfig::default(); - let app = Arc::new(App::new(config.clone())); + let app = Arc::new(App::new(config.clone()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -74,6 +74,7 @@ fn test_target_entry_creation() { title: Some("Example".to_string()), js_enabled: true, frame_tree_json: None, + form_state: HashMap::new(), }; assert_eq!(entry.url, "https://example.com"); @@ -90,6 +91,7 @@ fn test_target_entry_clone() { title: None, js_enabled: false, frame_tree_json: None, + form_state: HashMap::new(), }; let cloned = entry.clone(); @@ -103,7 +105,7 @@ fn test_target_entry_clone() { #[tokio::test] async fn test_get_html() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -113,13 +115,17 @@ async fn test_get_html() { // Insert a test target { let mut targets_lock = targets.lock().await; - targets_lock.insert("target-1".to_string(), TargetEntry { - url: "https://example.com".to_string(), - html: Some("Test".to_string()), - title: Some("Test".to_string()), - js_enabled: false, - frame_tree_json: None, - }); + targets_lock.insert( + "target-1".to_string(), + TargetEntry { + url: "https://example.com".to_string(), + html: Some("Test".to_string()), + title: Some("Test".to_string()), + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }, + ); } // Get HTML @@ -133,7 +139,7 @@ async fn test_get_html() { #[tokio::test] async fn test_get_url() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -142,13 +148,17 @@ async fn test_get_url() { { let mut targets_lock = targets.lock().await; - targets_lock.insert("target-1".to_string(), TargetEntry { - url: "https://example.com/page".to_string(), - html: None, - title: None, - js_enabled: false, - frame_tree_json: None, - }); + targets_lock.insert( + "target-1".to_string(), + TargetEntry { + url: "https://example.com/page".to_string(), + html: None, + title: None, + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }, + ); } let url = ctx.get_url("target-1").await; @@ -160,7 +170,7 @@ async fn test_get_url() { #[tokio::test] async fn test_get_title() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -169,13 +179,17 @@ async fn test_get_title() { { let mut targets_lock = targets.lock().await; - targets_lock.insert("target-1".to_string(), TargetEntry { - url: "https://example.com".to_string(), - html: None, - title: Some("Page Title".to_string()), - js_enabled: false, - frame_tree_json: None, - }); + targets_lock.insert( + "target-1".to_string(), + TargetEntry { + url: "https://example.com".to_string(), + html: None, + title: Some("Page Title".to_string()), + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }, + ); } let title = ctx.get_title("target-1").await; @@ -187,7 +201,7 @@ async fn test_get_title() { #[tokio::test] async fn test_get_target_entry() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -200,6 +214,7 @@ async fn test_get_target_entry() { title: Some("Title".to_string()), js_enabled: true, frame_tree_json: None, + form_state: HashMap::new(), }; { @@ -242,7 +257,7 @@ async fn test_create_browser_has_correct_config() { #[tokio::test] async fn test_update_target_with_data() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -262,7 +277,10 @@ async fn test_update_target_with_data() { assert!(entry.is_some()); let entry = entry.unwrap(); assert_eq!(entry.url, "https://example.com"); - assert_eq!(entry.html, Some("Updated".to_string())); + assert_eq!( + entry.html, + Some("Updated".to_string()) + ); assert_eq!(entry.title, Some("Updated Title".to_string())); } @@ -272,7 +290,7 @@ async fn test_update_target_with_data() { #[tokio::test] async fn test_event_bus_in_domain_context() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -328,7 +346,7 @@ async fn test_browser_uses_app_config() { #[tokio::test] async fn test_concurrent_target_access() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -340,7 +358,7 @@ async fn test_concurrent_target_access() { for i in 0..10 { let ctx_clone = DomainContext::new( - Arc::new(App::new(BrowserConfig::default())), + Arc::new(App::new(BrowserConfig::default()).unwrap()), targets.clone(), Arc::new(EventBus::new(1024)), Arc::new(Mutex::new(NodeMap::new())), @@ -379,7 +397,7 @@ async fn test_concurrent_target_access() { #[tokio::test] async fn test_navigate_invalid_url() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -393,7 +411,7 @@ async fn test_navigate_invalid_url() { #[tokio::test] async fn test_reload_nonexistent_target() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -411,7 +429,7 @@ async fn test_reload_nonexistent_target() { #[tokio::test] async fn test_multiple_browser_instances() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); diff --git a/crates/pardus-cdp/tests/domain_context_unit_test.rs b/crates/pardus-cdp/tests/domain_context_unit_test.rs index 63d7b09..dc5e794 100644 --- a/crates/pardus-cdp/tests/domain_context_unit_test.rs +++ b/crates/pardus-cdp/tests/domain_context_unit_test.rs @@ -5,14 +5,14 @@ #[cfg(test)] mod tests { - use std::collections::HashMap; - use std::sync::Arc; - use tokio::sync::Mutex; + use std::{collections::HashMap, sync::Arc}; - use pardus_cdp::domain::{DomainContext, TargetEntry}; - use pardus_cdp::protocol::event_bus::EventBus; - use pardus_cdp::protocol::node_map::NodeMap; + use pardus_cdp::{ + domain::{DomainContext, TargetEntry}, + protocol::{event_bus::EventBus, node_map::NodeMap}, + }; use pardus_core::{App, BrowserConfig}; + use tokio::sync::Mutex; // --------------------------------------------------------------------------- // DomainContext Creation Tests @@ -20,7 +20,7 @@ mod tests { #[test] fn test_domain_context_new() { - let app = Arc::new(App::new(BrowserConfig::default())); + let app = Arc::new(App::new(BrowserConfig::default()).unwrap()); let targets = Arc::new(Mutex::new(HashMap::::new())); let event_bus = Arc::new(EventBus::new(1024)); let node_map = Arc::new(Mutex::new(NodeMap::new())); @@ -58,6 +58,7 @@ mod tests { title: Some("Example".to_string()), js_enabled: true, frame_tree_json: None, + form_state: HashMap::new(), }; assert_eq!(entry.url, "https://example.com"); @@ -74,6 +75,7 @@ mod tests { title: None, js_enabled: false, frame_tree_json: None, + form_state: HashMap::new(), }; let cloned = entry.clone(); diff --git a/crates/pardus-cdp/tests/pardus_interact_test.rs b/crates/pardus-cdp/tests/pardus_interact_test.rs new file mode 100644 index 0000000..c2747c0 --- /dev/null +++ b/crates/pardus-cdp/tests/pardus_interact_test.rs @@ -0,0 +1,582 @@ +//! Integration tests for the Pardus CDP interact handler. +//! +//! Tests the full click/type/submit flow through `PardusDomain::handle()`, +//! verifying that actions correctly update the target store and return +//! appropriate JSON responses. + +use std::{collections::HashMap, sync::Arc}; + +use pardus_cdp::{ + domain::{ + CdpDomainHandler, DomainContext, HandleResult, TargetEntry, pardus_ext::PardusDomain, + }, + protocol::{event_bus::EventBus, node_map::NodeMap, target::CdpSession}, +}; +use pardus_core::{App, BrowserConfig, UrlPolicy}; +use tokio::sync::Mutex; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Create a DomainContext with a permissive URL policy (allows localhost for mockito tests). +fn setup_ctx_with_html( + html: &str, + url: &str, +) -> ( + Arc>>, + Arc, + DomainContext, +) { + let mut config = BrowserConfig::default(); + config.url_policy = UrlPolicy::permissive(); + let app = Arc::new(App::new(config).unwrap()); + let targets = Arc::new(Mutex::new(HashMap::new())); + let event_bus = Arc::new(EventBus::new(1024)); + let node_map = Arc::new(Mutex::new(NodeMap::new())); + + // Pre-populate target synchronously (won't block — no runtime yet) + let entry = TargetEntry { + url: url.to_string(), + html: Some(html.to_string()), + title: None, + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }; + // We'll insert in the test body since we need async. + + let ctx = DomainContext::new(app, targets.clone(), event_bus.clone(), node_map); + (targets, event_bus, ctx) +} + +/// Create a CdpSession attached to the given target_id. +fn make_session(target_id: &str) -> CdpSession { + let mut session = CdpSession::new("test-session".to_string()); + session.target_id = Some(target_id.to_string()); + session +} + +/// Insert a target entry into the targets map (async). +async fn insert_target( + targets: &Arc>>, + target_id: &str, + html: &str, + url: &str, +) { + let mut map = targets.lock().await; + map.insert( + target_id.to_string(), + TargetEntry { + url: url.to_string(), + html: Some(html.to_string()), + title: None, + js_enabled: false, + frame_tree_json: None, + form_state: HashMap::new(), + }, + ); +} + +/// Send an interact command through the domain handler and return the JSON result. +async fn interact( + ctx: &DomainContext, + target_id: &str, + action: &str, + selector: &str, +) -> serde_json::Value { + let domain = PardusDomain; + let mut session = make_session(target_id); + + let params = serde_json::json!({ + "action": action, + "selector": selector, + }); + + let result = domain.handle("interact", params, &mut session, ctx).await; + match result { + HandleResult::Success(v) => v, + HandleResult::Error(e) => { + serde_json::json!({ "error": e.error.message, "cdp_error": true }) + } + HandleResult::Ack => serde_json::json!({ "ack": true }), + } +} + +/// Send an interact command with a value parameter. +async fn interact_with_value( + ctx: &DomainContext, + target_id: &str, + action: &str, + selector: &str, + value: &str, +) -> serde_json::Value { + let domain = PardusDomain; + let mut session = make_session(target_id); + + let params = serde_json::json!({ + "action": action, + "selector": selector, + "value": value, + }); + + let result = domain.handle("interact", params, &mut session, ctx).await; + match result { + HandleResult::Success(v) => v, + HandleResult::Error(e) => { + serde_json::json!({ "error": e.error.message, "cdp_error": true }) + } + HandleResult::Ack => serde_json::json!({ "ack": true }), + } +} + +/// Send an interact command with fields. +async fn interact_with_fields( + ctx: &DomainContext, + target_id: &str, + action: &str, + selector: &str, + fields: serde_json::Value, +) -> serde_json::Value { + let domain = PardusDomain; + let mut session = make_session(target_id); + + let params = serde_json::json!({ + "action": action, + "selector": selector, + "fields": fields, + }); + + let result = domain.handle("interact", params, &mut session, ctx).await; + match result { + HandleResult::Success(v) => v, + HandleResult::Error(e) => { + serde_json::json!({ "error": e.error.message, "cdp_error": true }) + } + HandleResult::Ack => serde_json::json!({ "ack": true }), + } +} + +// --------------------------------------------------------------------------- +// Tests: error cases (no HTTP needed) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_interact_no_active_page() { + let (_targets, _eb, ctx) = setup_ctx_with_html("", ""); + // Don't insert any target + + let result = interact(&ctx, "missing-target", "click", "#1").await; + assert_eq!(result["success"], false); + assert_eq!(result["error"], "No active page"); +} + +#[tokio::test] +async fn test_click_element_not_found() { + let (targets, _eb, ctx) = setup_ctx_with_html( + "

No interactive elements

", + "https://example.com", + ); + insert_target( + &targets, + "t1", + "

Nothing

", + "https://example.com", + ) + .await; + + let result = interact(&ctx, "t1", "click", "#999").await; + assert_eq!(result["success"], false); + assert!(result["error"].as_str().unwrap().contains("not found")); +} + +#[tokio::test] +async fn test_type_element_not_found() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + "", + "https://example.com", + ) + .await; + + let result = interact_with_value(&ctx, "t1", "type", "#999", "hello").await; + assert_eq!(result["success"], false); + assert!(result["error"].as_str().unwrap().contains("not found")); +} + +#[tokio::test] +async fn test_interact_unknown_action() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + "", + "https://example.com", + ) + .await; + + let result = interact(&ctx, "t1", "teleport", "#1").await; + assert_eq!(result["success"], false); + assert!(result["error"].as_str().unwrap().contains("Unknown action")); +} + +// --------------------------------------------------------------------------- +// Tests: type action — form state accumulation (no HTTP needed) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_type_stores_value_in_form_state() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + r#" +
+ + +
+ "#, + "https://example.com/login", + ) + .await; + + let result = + interact_with_value(&ctx, "t1", "type", r#"input[name="username"]"#, "alice").await; + assert_eq!(result["success"], true); + assert_eq!(result["action"], "type"); + assert_eq!(result["value"], "alice"); + + // Verify form_state was persisted + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert_eq!(entry.form_state.get("username"), Some(&"alice".to_string())); +} + +#[tokio::test] +async fn test_type_accumulates_multiple_fields() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + r#" +
+ + +
+ "#, + "https://example.com/login", + ) + .await; + + interact_with_value(&ctx, "t1", "type", r#"input[name="username"]"#, "alice").await; + interact_with_value(&ctx, "t1", "type", r#"input[name="password"]"#, "s3cret").await; + + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert_eq!(entry.form_state.get("username"), Some(&"alice".to_string())); + assert_eq!( + entry.form_state.get("password"), + Some(&"s3cret".to_string()) + ); +} + +// --------------------------------------------------------------------------- +// Tests: type + click button flow (with mockito) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_click_link_navigates() { + let mut server = mockito::Server::new_async().await; + + // Mock the target page that the link points to + let target_html = r#"Target Page +

Welcome to About

"#; + let mock = server + .mock("GET", "/about") + .with_status(200) + .with_header("content-type", "text/html") + .with_body(target_html) + .create_async() + .await; + + let base_url = server.url(); + + // Source page with a link to /about + let source_html = format!( + r#" + About Us + "#, + base = base_url, + ); + + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target(&targets, "t1", &source_html, &base_url).await; + + // Click the link (element #1 = the tag) + let result = interact(&ctx, "t1", "click", "#1").await; + assert_eq!(result["success"], true); + assert_eq!(result["action"], "click"); + assert_eq!(result["navigated"], true); + + // Verify the target store was updated with the new page + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert!(entry.html.as_ref().unwrap().contains("Welcome to About")); + assert!(entry.url.contains("/about")); + + // Form state should be cleared after navigation + assert!(entry.form_state.is_empty()); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_click_link_resolves_relative_url() { + let mut server = mockito::Server::new_async().await; + + let target_html = r#"

Contact Us

"#; + let mock = server + .mock("GET", "/contact") + .with_status(200) + .with_header("content-type", "text/html") + .with_body(target_html) + .create_async() + .await; + + let base_url = server.url(); + + // Source page with a relative link + let source_html = r#" +
Contact + "#; + + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target(&targets, "t1", source_html, &format!("{}/page", base_url)).await; + + let result = interact(&ctx, "t1", "click", "#1").await; + assert_eq!(result["success"], true); + assert_eq!(result["navigated"], true); + + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert!(entry.html.as_ref().unwrap().contains("Contact Us")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_click_button_submits_form() { + let mut server = mockito::Server::new_async().await; + + // Mock the form submission endpoint + let response_html = r#"

Login Successful

"#; + let mock = server + .mock("POST", "/login") + .match_header("content-type", "application/x-www-form-urlencoded") + .with_status(200) + .with_header("content-type", "text/html") + .with_body(response_html) + .create_async() + .await; + + let base_url = server.url(); + + let source_html = format!( + r#" +
+ + + +
+ "#, + base = base_url, + ); + + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target(&targets, "t1", &source_html, &base_url).await; + + // Click the submit button (should be element #3 after the two inputs) + let result = interact(&ctx, "t1", "click", r#"button[name="submit"]"#).await; + assert_eq!(result["success"], true); + assert_eq!(result["action"], "click"); + + // Verify target was updated with the response page + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert!(entry.html.as_ref().unwrap().contains("Login Successful")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_submit_with_fields() { + let mut server = mockito::Server::new_async().await; + + let response_html = r#"

Search Results

"#; + let mock = server + .mock("GET", "/search") + .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded( + "q".to_string(), + "rust lang".to_string(), + )])) + .with_status(200) + .with_header("content-type", "text/html") + .with_body(response_html) + .create_async() + .await; + + let base_url = server.url(); + + let source_html = format!( + r#" +
+ +
+ "#, + base = base_url, + ); + + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target(&targets, "t1", &source_html, &base_url).await; + + let result = interact_with_fields( + &ctx, + "t1", + "submit", + "#search-form", + serde_json::json!({ "q": "rust lang" }), + ) + .await; + + assert_eq!(result["success"], true); + assert_eq!(result["action"], "submit"); + + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert!(entry.html.as_ref().unwrap().contains("Search Results")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_type_then_click_submits_with_typed_values() { + let mut server = mockito::Server::new_async().await; + + let response_html = r#"

Welcome alice

"#; + let mock = server + .mock("POST", "/login") + .match_header("content-type", "application/x-www-form-urlencoded") + .with_status(200) + .with_header("content-type", "text/html") + .with_body(response_html) + .create_async() + .await; + + let base_url = server.url(); + + let source_html = format!( + r#" +
+ + + +
+ "#, + base = base_url, + ); + + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target(&targets, "t1", &source_html, &base_url).await; + + // Type into fields + interact_with_value(&ctx, "t1", "type", r#"input[name="username"]"#, "alice").await; + interact_with_value(&ctx, "t1", "type", r#"input[name="password"]"#, "s3cret").await; + + // Verify form_state accumulated both values + { + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert_eq!(entry.form_state.get("username"), Some(&"alice".to_string())); + assert_eq!( + entry.form_state.get("password"), + Some(&"s3cret".to_string()) + ); + } + + // Click the submit button — should include typed values in submission + let result = interact(&ctx, "t1", "click", "button").await; + assert_eq!(result["success"], true); + assert_eq!(result["navigated"], true); + + // Verify target updated with response + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert!(entry.html.as_ref().unwrap().contains("Welcome alice")); + + // Form state cleared after navigation + assert!(entry.form_state.is_empty()); + + mock.assert_async().await; +} + +// --------------------------------------------------------------------------- +// Tests: toggle and select (no HTTP needed) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_toggle_checkbox() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + r#" +
+ +
+ "#, + "https://example.com", + ) + .await; + + let result = interact(&ctx, "t1", "toggle", r#"input[name="agree"]"#).await; + assert_eq!(result["success"], true); + assert_eq!(result["action"], "toggle"); + assert_eq!(result["checked"], true); + + // Verify form_state records the checked value + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert_eq!(entry.form_state.get("agree"), Some(&"yes".to_string())); +} + +#[tokio::test] +async fn test_select_option() { + let (targets, _eb, ctx) = setup_ctx_with_html("", ""); + insert_target( + &targets, + "t1", + r#" +
+ +
+ "#, + "https://example.com", + ) + .await; + + let result = interact_with_value(&ctx, "t1", "select", "select", "uk").await; + assert_eq!(result["success"], true); + assert_eq!(result["action"], "select"); + assert_eq!(result["value"], "uk"); + + // Verify form_state records the selection + let map = targets.lock().await; + let entry = map.get("t1").unwrap(); + assert_eq!(entry.form_state.get("country"), Some(&"uk".to_string())); +} diff --git a/crates/pardus-cli/src/commands/clean.rs b/crates/pardus-cli/src/commands/clean.rs index c1bcdbe..4758dc1 100644 --- a/crates/pardus-cli/src/commands/clean.rs +++ b/crates/pardus-cli/src/commands/clean.rs @@ -1,11 +1,8 @@ -use anyhow::Result; use std::path::PathBuf; -pub fn run( - cache_dir: Option, - cookies_only: bool, - cache_only: bool, -) -> Result<()> { +use anyhow::Result; + +pub fn run(cache_dir: Option, cookies_only: bool, cache_only: bool) -> Result<()> { let dir = cache_dir.unwrap_or_else(|| { dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) diff --git a/crates/pardus-cli/src/commands/interact.rs b/crates/pardus-cli/src/commands/interact.rs index 5fa02a4..3cf9e03 100644 --- a/crates/pardus-cli/src/commands/interact.rs +++ b/crates/pardus-cli/src/commands/interact.rs @@ -1,7 +1,6 @@ -use anyhow::Result; -use std::path::PathBuf; -use std::time::Instant; +use std::{path::PathBuf, time::Instant}; +use anyhow::Result; use pardus_core::{BrowserConfig, FormState, InteractionResult, ScrollDirection}; use crate::{InteractAction, OutputFormatArg}; @@ -74,8 +73,14 @@ pub async fn run_with_config( let result = browser.scroll(dir).await?; output_result(&result, &format); } - InteractAction::DispatchEvent { selector, event_type, init } => { - let result = browser.dispatch_event(&selector, &event_type, init.as_deref()).await?; + InteractAction::DispatchEvent { + selector, + event_type, + init, + } => { + let result = browser + .dispatch_event(&selector, &event_type, init.as_deref()) + .await?; output_result(&result, &format); } InteractAction::Upload { selector, files } => { @@ -148,7 +153,10 @@ fn output_result(result: &InteractionResult, format: &OutputFormatArg) { eprintln!("Wait timeout: {} not found", selector); } } - InteractionResult::Scrolled { url, page: new_page } => { + InteractionResult::Scrolled { + url, + page: new_page, + } => { eprintln!("Scrolled to: {}", url); let tree = new_page.semantic_tree(); match format { @@ -178,7 +186,10 @@ fn output_result(result: &InteractionResult, format: &OutputFormatArg) { } } } - InteractionResult::EventDispatched { selector, event_type } => { + InteractionResult::EventDispatched { + selector, + event_type, + } => { println!("Dispatched '{}' on {}", event_type, selector); } InteractionResult::FilesSet { selector, count } => { diff --git a/crates/pardus-cli/src/commands/map.rs b/crates/pardus-cli/src/commands/map.rs index fbb911b..d040e1d 100644 --- a/crates/pardus-cli/src/commands/map.rs +++ b/crates/pardus-cli/src/commands/map.rs @@ -1,7 +1,6 @@ use std::path::Path; use anyhow::Result; - use pardus_core::ProxyConfig; use pardus_kg::CrawlConfig; diff --git a/crates/pardus-cli/src/commands/mod.rs b/crates/pardus-cli/src/commands/mod.rs index 9d44749..a5e6953 100644 --- a/crates/pardus-cli/src/commands/mod.rs +++ b/crates/pardus-cli/src/commands/mod.rs @@ -1,9 +1,9 @@ -pub mod navigate; pub mod clean; pub mod interact; pub mod map; +pub mod navigate; pub mod repl; -pub mod serve; #[cfg(feature = "screenshot")] pub mod screenshot; +pub mod serve; pub mod tab; diff --git a/crates/pardus-cli/src/commands/navigate.rs b/crates/pardus-cli/src/commands/navigate.rs index 9accb68..33aea3d 100644 --- a/crates/pardus-cli/src/commands/navigate.rs +++ b/crates/pardus-cli/src/commands/navigate.rs @@ -1,9 +1,9 @@ +use std::{path::PathBuf, sync::Arc, time::Instant}; + use anyhow::Result; -use std::path::PathBuf; -use std::time::Instant; +use pardus_core::BrowserConfig; use crate::OutputFormatArg; -use pardus_core::BrowserConfig; pub async fn run_with_config( url: &str, @@ -19,10 +19,7 @@ pub async fn run_with_config( ) -> Result<()> { let start = Instant::now(); - println!( - "{:02}:{:02} pardus-browser navigate {}", - 0, 0, url - ); + println!("{:02}:{:02} pardus-browser navigate {}", 0, 0, url); let mut browser = pardus_core::Browser::new(config)?; @@ -41,10 +38,12 @@ pub async fn run_with_config( ); // Clone references before borrowing page - let net_log = browser.network_log.clone(); - let http_client = browser.http_client.clone(); + let net_log = browser.network_log().clone(); + let http_client = browser.http_client().clone(); - let page = browser.current_page().ok_or_else(|| anyhow::anyhow!("no page loaded"))?; + let page = browser + .current_page() + .ok_or_else(|| anyhow::anyhow!("no page loaded"))?; // Show redirect chain info if let Some(ref chain) = page.redirect_chain { @@ -81,11 +80,17 @@ pub async fn run_with_config( if let Some(ref cov_path) = coverage_output { let css_sources = pardus_debug::coverage::extract_inline_styles(&page.html); let log = net_log.lock().unwrap_or_else(|e| e.into_inner()); - let report = pardus_debug::coverage::CoverageReport::build(&page.url, &page.html, &css_sources, &log); + let report = pardus_debug::coverage::CoverageReport::build( + &page.url, + &page.html, + &css_sources, + &log, + ); let json = serde_json::to_string_pretty(&report)?; std::fs::write(cov_path, json)?; println!( - " Coverage report written to {} — {} CSS rules ({} matched, {} unmatched, {} untestable)", + " Coverage report written to {} — {} CSS rules ({} matched, {} unmatched, {} \ + untestable)", cov_path.display(), report.summary.total_css_rules, report.summary.matched_css_rules, @@ -97,7 +102,7 @@ pub async fn run_with_config( let tree = page.semantic_tree(); let tree = if interactive_only { - filter_interactive(&tree) + Arc::new(filter_interactive(&tree)) } else { tree }; @@ -205,22 +210,16 @@ fn filter_interactive(tree: &pardus_core::SemanticTree) -> pardus_core::Semantic fn filter_node(node: &SemanticNode) -> Option { if node.is_interactive { - let filtered_children: Vec = node - .children - .iter() - .filter_map(filter_node) - .collect(); + let filtered_children: Vec = + node.children.iter().filter_map(filter_node).collect(); return Some(SemanticNode { children: filtered_children, ..node.clone() }); } - let filtered_children: Vec = node - .children - .iter() - .filter_map(filter_node) - .collect(); + let filtered_children: Vec = + node.children.iter().filter_map(filter_node).collect(); if filtered_children.is_empty() { return None; diff --git a/crates/pardus-cli/src/commands/repl.rs b/crates/pardus-cli/src/commands/repl.rs index a6efa1d..5c36b5f 100644 --- a/crates/pardus-cli/src/commands/repl.rs +++ b/crates/pardus-cli/src/commands/repl.rs @@ -1,13 +1,24 @@ use anyhow::Result; -use pardus_core::{Browser, BrowserConfig, FormState, ProxyConfig, ScrollDirection}; -use pardus_core::intercept::builtins::{BlockingInterceptor, RedirectInterceptor, HeaderModifierInterceptor, MockResponseInterceptor}; -use pardus_core::intercept::rules::InterceptorRule; -use rustyline::error::ReadlineError; -use rustyline::Editor; +use pardus_core::{ + Browser, BrowserConfig, FormState, ProxyConfig, ScrollDirection, + intercept::{ + builtins::{ + BlockingInterceptor, HeaderModifierInterceptor, MockResponseInterceptor, + RedirectInterceptor, + }, + rules::InterceptorRule, + }, +}; +use rustyline::{Editor, error::ReadlineError}; use crate::OutputFormatArg; -pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, proxy_config: ProxyConfig) -> Result<()> { +pub async fn run_with_config( + js: bool, + format: OutputFormatArg, + wait_ms: u32, + proxy_config: ProxyConfig, +) -> Result<()> { let mut browser_config = BrowserConfig::default(); browser_config.proxy = proxy_config; let mut browser = Browser::new(browser_config)?; @@ -70,26 +81,20 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr Err(e) => eprintln!("Error: {}", e), } } - "reload" => { - match browser.reload().await { - Ok(_) => print_tree(&browser, &format), - Err(e) => eprintln!("Error: {}", e), - } - } - "back" => { - match browser.go_back().await { - Ok(Some(_)) => print_tree(&browser, &format), - Ok(None) => println!("Already at the beginning of history"), - Err(e) => eprintln!("Error: {}", e), - } - } - "forward" => { - match browser.go_forward().await { - Ok(Some(_)) => print_tree(&browser, &format), - Ok(None) => println!("Already at the end of history"), - Err(e) => eprintln!("Error: {}", e), - } - } + "reload" => match browser.reload().await { + Ok(_) => print_tree(&browser, &format), + Err(e) => eprintln!("Error: {}", e), + }, + "back" => match browser.go_back().await { + Ok(Some(_)) => print_tree(&browser, &format), + Ok(None) => println!("Already at the beginning of history"), + Err(e) => eprintln!("Error: {}", e), + }, + "forward" => match browser.go_forward().await { + Ok(Some(_)) => print_tree(&browser, &format), + Ok(None) => println!("Already at the end of history"), + Err(e) => eprintln!("Error: {}", e), + }, // Interactions "click" => { @@ -188,14 +193,20 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr let init = tokens.get(3).cloned(); if let Some(id_str) = selector.strip_prefix('#') { match id_str.parse::() { - Ok(id) => match browser.dispatch_event_by_id(id, event_type, init.as_deref()).await { + Ok(id) => match browser + .dispatch_event_by_id(id, event_type, init.as_deref()) + .await + { Ok(result) => print_interaction_result(&result, &format), Err(e) => eprintln!("Error: {}", e), }, Err(_) => eprintln!("Invalid element ID: {}", selector), } } else { - match browser.dispatch_event(selector, event_type, init.as_deref()).await { + match browser + .dispatch_event(selector, event_type, init.as_deref()) + .await + { Ok(result) => print_interaction_result(&result, &format), Err(e) => eprintln!("Error: {}", e), } @@ -239,18 +250,22 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr }; let result = if let Some(selector) = &element_selector { - browser.capture_element_screenshot(&url, selector, &opts).await + browser + .capture_element_screenshot(&url, selector, &opts) + .await } else { browser.capture_screenshot(&url, &opts).await }; match result { - Ok(bytes) => { - match std::fs::write(output_path, &bytes) { - Ok(_) => println!("Screenshot saved to {} ({} bytes)", output_path, bytes.len()), - Err(e) => eprintln!("Failed to write screenshot: {}", e), - } - } + Ok(bytes) => match std::fs::write(output_path, &bytes) { + Ok(_) => println!( + "Screenshot saved to {} ({} bytes)", + output_path, + bytes.len() + ), + Err(e) => eprintln!("Failed to write screenshot: {}", e), + }, Err(e) => eprintln!("Screenshot failed: {}", e), } } @@ -265,21 +280,19 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr } // Settings - "js" => { - match tokens.get(1).map(|s| s.as_str()) { - Some("on") | Some("true") | Some("1") => { - js_enabled = true; - browser.set_js_enabled(true, wait_ms); - println!("JS enabled"); - } - Some("off") | Some("false") | Some("0") => { - js_enabled = false; - browser.set_js_enabled(false, wait_ms); - println!("JS disabled"); - } - _ => println!("JS is currently {}", if js_enabled { "on" } else { "off" }), + "js" => match tokens.get(1).map(|s| s.as_str()) { + Some("on") | Some("true") | Some("1") => { + js_enabled = true; + browser.set_js_enabled(true, wait_ms); + println!("JS enabled"); } - } + Some("off") | Some("false") | Some("0") => { + js_enabled = false; + browser.set_js_enabled(false, wait_ms); + println!("JS disabled"); + } + _ => println!("JS is currently {}", if js_enabled { "on" } else { "off" }), + }, "format" => { match tokens.get(1).map(|s| s.as_str()) { Some("md") => format = OutputFormatArg::Md, @@ -316,7 +329,10 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr } other => { - eprintln!("Unknown command: {}. Type `help` for available commands.", other); + eprintln!( + "Unknown command: {}. Type `help` for available commands.", + other + ); } } } @@ -325,12 +341,7 @@ pub async fn run_with_config(js: bool, format: OutputFormatArg, wait_ms: u32, pr Ok(()) } -async fn navigate( - browser: &mut Browser, - url: &str, - js: bool, - wait_ms: u32, -) -> Result<()> { +async fn navigate(browser: &mut Browser, url: &str, js: bool, wait_ms: u32) -> Result<()> { if js { browser.navigate_with_js(url, wait_ms).await?; } else { @@ -384,17 +395,11 @@ fn print_tree(browser: &Browser, format: &OutputFormatArg) { } println!( " {} landmarks, {} links, {} headings, {} actions", - tree.stats.landmarks, - tree.stats.links, - tree.stats.headings, - tree.stats.actions, + tree.stats.landmarks, tree.stats.links, tree.stats.headings, tree.stats.actions, ); } -fn print_interaction_result( - result: &pardus_core::InteractionResult, - format: &OutputFormatArg, -) { +fn print_interaction_result(result: &pardus_core::InteractionResult, format: &OutputFormatArg) { use pardus_core::InteractionResult; match result { InteractionResult::Navigated(new_page) => { @@ -450,7 +455,10 @@ fn print_interaction_result( eprintln!("Wait timeout: {} not found", selector); } } - InteractionResult::Scrolled { url, page: new_page } => { + InteractionResult::Scrolled { + url, + page: new_page, + } => { eprintln!("Scrolled to: {}", url); let tree = new_page.semantic_tree(); match format { @@ -480,7 +488,10 @@ fn print_interaction_result( } } } - InteractionResult::EventDispatched { selector, event_type } => { + InteractionResult::EventDispatched { + selector, + event_type, + } => { println!("Dispatched '{}' on {}", event_type, selector); } InteractionResult::FilesSet { selector, count } => { @@ -555,11 +566,7 @@ async fn handle_tab( let tab_id = pardus_core::TabId::from_u64(id); match browser.switch_to(tab_id).await { Ok(tab) => { - println!( - "Switched to tab {}: {}", - tab.id, - tab.url - ); + println!("Switched to tab {}: {}", tab.id, tab.url); print_tree(browser, format); } Err(e) => eprintln!("Error: {}", e), @@ -592,20 +599,21 @@ async fn handle_tab( eprintln!("No tab to close"); } } - "info" => { - match browser.active_tab() { - Some(tab) => { - println!("Active Tab [{}]:", tab.id); - println!(" URL: {}", tab.url); - println!(" Title: {}", tab.title.as_deref().unwrap_or("(none)")); - println!(" State: {:?}", tab.state); - println!(" History: {}/{}", tab.history_index + 1, tab.history.len()); - } - None => println!("No active tab"), - } - } + "info" => match browser.active_tab() { + Some(tab) => { + println!("Active Tab [{}]:", tab.id); + println!(" URL: {}", tab.url); + println!(" Title: {}", tab.title.as_deref().unwrap_or("(none)")); + println!(" State: {:?}", tab.state); + println!(" History: {}/{}", tab.history_index + 1, tab.history.len()); + } + None => println!("No active tab"), + }, other => { - eprintln!("Unknown tab command: {}. Use: list, open, switch, close, info", other); + eprintln!( + "Unknown tab command: {}. Use: list, open, switch, close, info", + other + ); } } } @@ -637,7 +645,9 @@ fn split_tokens(input: &str) -> Vec { fn handle_intercept(browser: &mut Browser, args: &[String]) { if args.is_empty() { - eprintln!("Usage: intercept ..."); + eprintln!( + "Usage: intercept ..." + ); return; } @@ -650,7 +660,9 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { } let pattern = &args[1]; let rule = parse_pattern_to_rule(pattern); - browser.interceptors.add(Box::new(BlockingInterceptor::new(rule))); + browser + .interceptors() + .add(Box::new(BlockingInterceptor::new(rule))); println!("Added block interceptor for: {}", pattern); } "redirect" => { @@ -661,7 +673,9 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { let pattern = &args[1]; let target = &args[2]; let rule = parse_pattern_to_rule(pattern); - browser.interceptors.add(Box::new(RedirectInterceptor::new(rule, target.clone()))); + browser + .interceptors() + .add(Box::new(RedirectInterceptor::new(rule, target.clone()))); println!("Added redirect interceptor: {} -> {}", pattern, target); } "header" => { @@ -679,9 +693,9 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { } } if !headers.is_empty() { - browser.interceptors.add(Box::new( - HeaderModifierInterceptor::new(None, headers), - )); + browser + .interceptors() + .add(Box::new(HeaderModifierInterceptor::new(None, headers))); } } "remove-header" => { @@ -693,8 +707,9 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { for name in &headers { println!("Will remove header: {}", name); } - browser.interceptors.add(Box::new( - HeaderModifierInterceptor::new(None, std::collections::HashMap::new()).with_removal(headers), + browser.interceptors_mut().add(Box::new( + HeaderModifierInterceptor::new(None, std::collections::HashMap::new()) + .with_removal(headers), )); } "mock" => { @@ -712,10 +727,15 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { }; let body = args[3..].join(" "); let rule = parse_pattern_to_rule(pattern); - browser.interceptors.add(Box::new( - MockResponseInterceptor::text(rule, status, &body), - )); - println!("Added mock interceptor: {} -> {} {}", pattern, status, &body[..body.len().min(80)]); + browser + .interceptors_mut() + .add(Box::new(MockResponseInterceptor::text(rule, status, &body))); + println!( + "Added mock interceptor: {} -> {} {}", + pattern, + status, + &body[..body.len().min(80)] + ); } "domain" => { if args.len() < 2 { @@ -725,12 +745,14 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { } for domain in &args[1..] { let rule = InterceptorRule::Domain(domain.clone()); - browser.interceptors.add(Box::new(BlockingInterceptor::new(rule))); + browser + .interceptors_mut() + .add(Box::new(BlockingInterceptor::new(rule))); println!("Added domain block: {}", domain); } } "list" => { - let count = browser.interceptors.len(); + let count = browser.interceptors().len(); if count == 0 { println!("No interceptors active"); } else { @@ -739,7 +761,7 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { } "clear" => { println!("Clearing all interceptors"); - browser.interceptors = pardus_core::InterceptorManager::new(); + *browser.interceptors_mut() = pardus_core::InterceptorManager::new(); } other => { eprintln!("Unknown intercept command: {}", other); @@ -751,16 +773,16 @@ fn handle_intercept(browser: &mut Browser, args: &[String]) { fn parse_pattern_to_rule(pattern: &str) -> InterceptorRule { if pattern.contains("://") || pattern.starts_with("*.") { if pattern.contains('*') || pattern.contains('?') { - InterceptorRule::UrlGlob(pattern.to_string()) + InterceptorRule::url_glob(pattern) } else { InterceptorRule::Domain(pattern.to_string()) } } else if pattern.starts_with('/') { InterceptorRule::PathPrefix(pattern.to_string()) } else if pattern.contains('*') || pattern.contains('?') { - InterceptorRule::UrlGlob(pattern.to_string()) + InterceptorRule::url_glob(pattern) } else { - InterceptorRule::UrlGlob(format!("*{}*", pattern)) + InterceptorRule::url_glob(format!("*{}*", pattern)) } } @@ -835,14 +857,18 @@ fn handle_cookies(browser: &Browser, args: &[String]) { } // If a URL filter is given, show only matching cookies let filtered = if let Some(url) = args.get(1) { - cookies.into_iter().filter(|c| { - url.contains(&c.domain) || c.domain.contains(url) - }).collect::>() + cookies + .into_iter() + .filter(|c| url.contains(&c.domain) || c.domain.contains(url)) + .collect::>() } else { cookies }; - println!("{:<5} {:<30} {:<40} {:<10} {:<8}", "Secure", "Name", "Domain", "Path", "Httponly"); + println!( + "{:<5} {:<30} {:<40} {:<10} {:<8}", + "Secure", "Name", "Domain", "Path", "Httponly" + ); for c in &filtered { println!( "{:<5} {:<30} {:<40} {:<10} {:<8}", @@ -872,7 +898,10 @@ fn handle_cookies(browser: &Browser, args: &[String]) { let domain = args.get(2).map(|s| s.as_str()).unwrap_or("example.com"); let path = args.get(3).map(|s| s.as_str()).unwrap_or("/"); browser.set_cookie(name, value, domain, path); - println!("Cookie set: {}={} (domain={}, path={})", name, value, domain, path); + println!( + "Cookie set: {}={} (domain={}, path={})", + name, value, domain, path + ); } Some("delete") | Some("remove") => { @@ -896,7 +925,10 @@ fn handle_cookies(browser: &Browser, args: &[String]) { } _ => { - eprintln!("Usage: cookies list [url] | set = [domain] [path] | delete [domain] [path] | clear"); + eprintln!( + "Usage: cookies list [url] | set = [domain] [path] | delete [domain] \ + [path] | clear" + ); } } } @@ -908,7 +940,9 @@ fn handle_network(browser: &Browser, args: &[String]) { match subcmd { "list" | "ls" | "table" => { - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); if log.records.is_empty() { println!("No network requests captured yet. Navigate to a page first."); @@ -934,17 +968,26 @@ fn handle_network(browser: &Browser, args: &[String]) { return; } }; - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); let record = match log.records.iter().find(|r| r.id == id) { Some(r) => r, None => { - eprintln!("Request #{} not found. {} requests captured.", id, log.records.len()); + eprintln!( + "Request #{} not found. {} requests captured.", + id, + log.records.len() + ); return; } }; println!(" #{} {} {}", record.id, record.method, record.url); - println!(" Type: {} | Initiator: {}", record.resource_type, record.initiator); + println!( + " Type: {} | Initiator: {}", + record.resource_type, record.initiator + ); println!(" Description: {}", record.description); if let Some(status) = record.status { let status_text = record.status_text.as_deref().unwrap_or(""); @@ -989,9 +1032,13 @@ fn handle_network(browser: &Browser, args: &[String]) { } "failed" | "errors" => { - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); - let failed: Vec<_> = log.records.iter() + let failed: Vec<_> = log + .records + .iter() .filter(|r| r.error.is_some() || r.status.is_some_and(|s| s >= 400)) .collect(); if failed.is_empty() { @@ -1000,17 +1047,29 @@ fn handle_network(browser: &Browser, args: &[String]) { } println!(" Failed requests ({}):", failed.len()); println!(); - println!(" {:>2} {:<7} {:>6} {:<50} {}", "#", "Method", "Status", "URL", "Error"); + println!( + " {:>2} {:<7} {:>6} {:<50} {}", + "#", "Method", "Status", "URL", "Error" + ); for r in &failed { let status = r.status.map_or("—".to_string(), |s| s.to_string()); - let url = if r.url.len() > 50 { format!("{}…", &r.url[..47]) } else { r.url.clone() }; + let url = if r.url.len() > 50 { + format!("{}…", &r.url[..47]) + } else { + r.url.clone() + }; let error = r.error.as_deref().unwrap_or(""); - println!(" {:>2} {:<7} {:>6} {:<50} {}", r.id, r.method, status, url, error); + println!( + " {:>2} {:<7} {:>6} {:<50} {}", + r.id, r.method, status, url, error + ); } } "stats" => { - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); if log.records.is_empty() { println!("No network requests captured yet."); @@ -1018,12 +1077,17 @@ fn handle_network(browser: &Browser, args: &[String]) { } println!(" Network Stats:"); println!(" Total requests: {}", log.total_requests()); - println!(" Total bytes: {} ({})", log.total_bytes(), formatter::format_bytes(log.total_bytes())); + println!( + " Total bytes: {} ({})", + log.total_bytes(), + formatter::format_bytes(log.total_bytes()) + ); println!(" Max latency: {}ms", log.total_time_ms()); println!(" Failed: {}", log.failed_count()); // Breakdown by resource type - let mut type_counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut type_counts: std::collections::HashMap = + std::collections::HashMap::new(); for r in &log.records { *type_counts.entry(r.resource_type.to_string()).or_insert(0) += 1; } @@ -1036,7 +1100,9 @@ fn handle_network(browser: &Browser, args: &[String]) { } "json" => { - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); if log.records.is_empty() { println!("No network requests captured yet."); @@ -1055,7 +1121,9 @@ fn handle_network(browser: &Browser, args: &[String]) { return; } let path = &args[1]; - let log = browser.network_log.lock() + let log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); if log.records.is_empty() { println!("No network requests to export."); @@ -1064,7 +1132,11 @@ fn handle_network(browser: &Browser, args: &[String]) { let har = pardus_debug::har::HarFile::from_network_log(&log); match serde_json::to_string_pretty(&har) { Ok(json) => match std::fs::write(path, &json) { - Ok(_) => println!("HAR exported to {} ({} entries)", path, har.log.entries.len()), + Ok(_) => println!( + "HAR exported to {} ({} entries)", + path, + har.log.entries.len() + ), Err(e) => eprintln!("Failed to write HAR file: {}", e), }, Err(e) => eprintln!("Failed to serialize HAR: {}", e), @@ -1072,7 +1144,9 @@ fn handle_network(browser: &Browser, args: &[String]) { } "clear" => { - let mut log = browser.network_log.lock() + let mut log = browser + .network_log() + .lock() .unwrap_or_else(|e| e.into_inner()); let count = log.records.len(); log.records.clear(); @@ -1080,21 +1154,25 @@ fn handle_network(browser: &Browser, args: &[String]) { } other => { - eprintln!("Unknown network command: {}. Use: list, show, failed, stats, json, har, clear", other); + eprintln!( + "Unknown network command: {}. Use: list, show, failed, stats, json, har, clear", + other + ); } } } #[cfg(test)] mod tests { - use pardus_debug::{NetworkLog, NetworkRecord, ResourceType, Initiator}; use std::sync::{Arc, Mutex}; + use pardus_debug::{Initiator, NetworkLog, NetworkRecord, ResourceType}; + /// Build a Browser with a pre-populated network log for testing. /// /// We avoid `Browser::new()` because it creates a real HTTP client that /// may have TLS dependencies. Instead we exercise the public API surface - /// that `handle_network` actually touches: only `browser.network_log`. + /// that `handle_network` actually touches: only `browser.network_log()`. struct TestBrowser { network_log: Arc>, } @@ -1106,7 +1184,14 @@ mod tests { } } - fn add_record(&self, id: usize, url: &str, status: Option, resource_type: ResourceType, error: Option<&str>) { + fn add_record( + &self, + id: usize, + url: &str, + status: Option, + resource_type: ResourceType, + error: Option<&str>, + ) { let mut log = self.network_log.lock().unwrap(); let mut r = NetworkRecord::fetched( id, @@ -1119,21 +1204,25 @@ mod tests { r.status = status; r.error = error.map(|s| s.to_string()); if let Some(s) = status { - r.status_text = Some(if s < 400 { "OK".to_string() } else { "Error".to_string() }); + r.status_text = Some(if s < 400 { + "OK".to_string() + } else { + "Error".to_string() + }); } r.body_size = Some(1024 * id); r.timing_ms = Some(50 * id as u128); r.content_type = Some("text/html".to_string()); r.http_version = Some("HTTP/2".to_string()); r.started_at = Some("2026-01-01T00:00:00.000Z".to_string()); - r.request_headers.push(("accept".to_string(), "text/html".to_string())); - r.response_headers.push(("content-type".to_string(), "text/html".to_string())); + r.request_headers + .push(("accept".to_string(), "text/html".to_string())); + r.response_headers + .push(("content-type".to_string(), "text/html".to_string())); log.push(r); } - fn log_record_count(&self) -> usize { - self.network_log.lock().unwrap().records.len() - } + fn log_record_count(&self) -> usize { self.network_log.lock().unwrap().records.len() } } /// We need a thin adapter because `handle_network` expects `&Browser`. @@ -1141,10 +1230,34 @@ mod tests { /// but takes our `TestBrowser` instead. This tests the actual branching /// logic, filtering, and data access paths. fn populate_sample_log(browser: &TestBrowser) { - browser.add_record(1, "https://example.com/", Some(200), ResourceType::Document, None); - browser.add_record(2, "https://example.com/style.css", Some(200), ResourceType::Stylesheet, None); - browser.add_record(3, "https://example.com/app.js", Some(404), ResourceType::Script, None); - browser.add_record(4, "https://example.com/api/data", Some(500), ResourceType::Fetch, Some("internal error")); + browser.add_record( + 1, + "https://example.com/", + Some(200), + ResourceType::Document, + None, + ); + browser.add_record( + 2, + "https://example.com/style.css", + Some(200), + ResourceType::Stylesheet, + None, + ); + browser.add_record( + 3, + "https://example.com/app.js", + Some(404), + ResourceType::Script, + None, + ); + browser.add_record( + 4, + "https://example.com/api/data", + Some(500), + ResourceType::Fetch, + Some("internal error"), + ); } // ── list subcommand ──────────────────────────────────────────────── @@ -1154,7 +1267,7 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let table = pardus_debug::formatter::format_table(&log); assert!(table.contains("4 requests")); assert!(table.contains("example.com")); @@ -1167,7 +1280,7 @@ mod tests { #[test] fn test_network_list_empty() { let browser = TestBrowser::new(); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let table = pardus_debug::formatter::format_table(&log); assert!(table.is_empty()); } @@ -1179,7 +1292,7 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let record = log.records.iter().find(|r| r.id == 1).unwrap(); assert_eq!(record.url, "https://example.com/"); assert_eq!(record.status, Some(200)); @@ -1196,7 +1309,7 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let found = log.records.iter().find(|r| r.id == 999); assert!(found.is_none()); } @@ -1208,8 +1321,10 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); - let failed: Vec<_> = log.records.iter() + let log = browser.network_log().lock().unwrap(); + let failed: Vec<_> = log + .records + .iter() .filter(|r| r.error.is_some() || r.status.is_some_and(|s| s >= 400)) .collect(); @@ -1221,10 +1336,18 @@ mod tests { #[test] fn test_network_failed_empty_when_all_ok() { let browser = TestBrowser::new(); - browser.add_record(1, "https://ok.com/", Some(200), ResourceType::Document, None); - - let log = browser.network_log.lock().unwrap(); - let failed: Vec<_> = log.records.iter() + browser.add_record( + 1, + "https://ok.com/", + Some(200), + ResourceType::Document, + None, + ); + + let log = browser.network_log().lock().unwrap(); + let failed: Vec<_> = log + .records + .iter() .filter(|r| r.error.is_some() || r.status.is_some_and(|s| s >= 400)) .collect(); assert!(failed.is_empty()); @@ -1237,13 +1360,14 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); assert_eq!(log.total_requests(), 4); assert_eq!(log.total_bytes(), 1024 * (1 + 2 + 3 + 4)); // 10240 assert_eq!(log.failed_count(), 2); // Type breakdown - let mut type_counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut type_counts: std::collections::HashMap = + std::collections::HashMap::new(); for r in &log.records { *type_counts.entry(r.resource_type.to_string()).or_insert(0) += 1; } @@ -1253,7 +1377,7 @@ mod tests { #[test] fn test_network_stats_empty() { let browser = TestBrowser::new(); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); assert_eq!(log.total_requests(), 0); assert_eq!(log.total_bytes(), 0); assert_eq!(log.failed_count(), 0); @@ -1266,22 +1390,28 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let json_data = pardus_debug::formatter::NetworkLogJson::from_log(&log); let json = serde_json::to_string_pretty(&json_data).unwrap(); // Verify key fields are present - assert!(json.contains("\"total_requests\": 4") || json.contains("\"total_requests\":4"), - "JSON should contain total_requests count. Got: {}", json); - assert!(json.contains("\"failed\": 2") || json.contains("\"failed\":2"), - "JSON should contain failed count. Got: {}", json); + assert!( + json.contains("\"total_requests\": 4") || json.contains("\"total_requests\":4"), + "JSON should contain total_requests count. Got: {}", + json + ); + assert!( + json.contains("\"failed\": 2") || json.contains("\"failed\":2"), + "JSON should contain failed count. Got: {}", + json + ); assert!(json.contains("example.com")); } #[test] fn test_network_json_empty_log() { let browser = TestBrowser::new(); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let json_data = pardus_debug::formatter::NetworkLogJson::from_log(&log); assert_eq!(json_data.total_requests, 0); assert!(json_data.requests.is_empty()); @@ -1294,7 +1424,7 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let har = pardus_debug::har::HarFile::from_network_log(&log); assert_eq!(har.log.entries.len(), 4); assert_eq!(har.log.version, "1.2"); @@ -1309,7 +1439,7 @@ mod tests { let browser = TestBrowser::new(); populate_sample_log(&browser); - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let har = pardus_debug::har::HarFile::from_network_log(&log); let json = serde_json::to_string_pretty(&har).unwrap(); @@ -1333,7 +1463,7 @@ mod tests { assert_eq!(browser.log_record_count(), 4); { - let mut log = browser.network_log.lock().unwrap(); + let mut log = browser.network_log().lock().unwrap(); let count = log.records.len(); log.records.clear(); assert_eq!(count, 4); @@ -1347,7 +1477,7 @@ mod tests { let browser = TestBrowser::new(); assert_eq!(browser.log_record_count(), 0); - let mut log = browser.network_log.lock().unwrap(); + let mut log = browser.network_log().lock().unwrap(); let count = log.records.len(); log.records.clear(); assert_eq!(count, 0); @@ -1359,7 +1489,7 @@ mod tests { fn test_record_with_redirect_and_cache() { let browser = TestBrowser::new(); { - let mut log = browser.network_log.lock().unwrap(); + let mut log = browser.network_log().lock().unwrap(); let mut r = NetworkRecord::fetched( 10, "GET".to_string(), @@ -1375,7 +1505,7 @@ mod tests { log.push(r); } - let log = browser.network_log.lock().unwrap(); + let log = browser.network_log().lock().unwrap(); let r = log.records.iter().find(|r| r.id == 10).unwrap(); assert_eq!(r.status, Some(301)); assert_eq!(r.redirect_url.as_deref(), Some("https://new.com/page")); diff --git a/crates/pardus-cli/src/commands/screenshot.rs b/crates/pardus-cli/src/commands/screenshot.rs index 5eee67f..7a261e8 100644 --- a/crates/pardus-cli/src/commands/screenshot.rs +++ b/crates/pardus-cli/src/commands/screenshot.rs @@ -1,8 +1,8 @@ //! Screenshot capture command. -use anyhow::Result; use std::path::PathBuf; +use anyhow::Result; use pardus_core::screenshot::{ScreenshotFormat, ScreenshotOptions}; pub async fn run( @@ -46,14 +46,20 @@ pub async fn run( let bytes = if let Some(selector) = element { eprintln!("Capturing element '{}' from {}...", selector, url); - browser.capture_element_screenshot(url, selector, &opts).await? + browser + .capture_element_screenshot(url, selector, &opts) + .await? } else { eprintln!("Capturing {}...", url); browser.capture_screenshot(url, &opts).await? }; std::fs::write(output, &bytes)?; - eprintln!("Screenshot saved to {} ({} bytes)", output.display(), bytes.len()); + eprintln!( + "Screenshot saved to {} ({} bytes)", + output.display(), + bytes.len() + ); Ok(()) } diff --git a/crates/pardus-cli/src/commands/serve.rs b/crates/pardus-cli/src/commands/serve.rs index 7f0ee3a..aa8bdd4 100644 --- a/crates/pardus-cli/src/commands/serve.rs +++ b/crates/pardus-cli/src/commands/serve.rs @@ -1,10 +1,11 @@ +use std::sync::Arc; + use anyhow::Result; -use pardus_core::{App, BrowserConfig}; use pardus_cdp::CdpServer; -use std::sync::Arc; +use pardus_core::{App, BrowserConfig}; pub async fn run(host: &str, port: u16, timeout: u64, config: BrowserConfig) -> Result<()> { - let app = Arc::new(App::new(config)); + let app = Arc::new(App::new(config)?); let server = CdpServer::new(host.to_string(), port, timeout, app); server.run().await?; Ok(()) diff --git a/crates/pardus-cli/src/commands/tab.rs b/crates/pardus-cli/src/commands/tab.rs index 53108d4..8303b9f 100644 --- a/crates/pardus-cli/src/commands/tab.rs +++ b/crates/pardus-cli/src/commands/tab.rs @@ -26,7 +26,11 @@ pub async fn list(browser: &pardus_core::Browser, format: OutputFormatArg) -> Re } /// Open a new tab with proxy configuration -pub async fn open_with_config(url: &str, js: bool, proxy_config: pardus_core::ProxyConfig) -> Result<()> { +pub async fn open_with_config( + url: &str, + js: bool, + proxy_config: pardus_core::ProxyConfig, +) -> Result<()> { let mut browser_config = pardus_core::BrowserConfig::default(); browser_config.proxy = proxy_config; let mut browser = pardus_core::Browser::new(browser_config)?; @@ -35,7 +39,12 @@ pub async fn open_with_config(url: &str, js: bool, proxy_config: pardus_core::Pr } else { browser.navigate(url).await? }; - println!("Tab {}: {} — {}", tab.id, tab.url, tab.title.as_deref().unwrap_or("(no title)")); + println!( + "Tab {}: {} — {}", + tab.id, + tab.url, + tab.title.as_deref().unwrap_or("(no title)") + ); Ok(()) } diff --git a/crates/pardus-cli/src/config.rs b/crates/pardus-cli/src/config.rs index 45ffb59..dd179be 100644 --- a/crates/pardus-cli/src/config.rs +++ b/crates/pardus-cli/src/config.rs @@ -68,7 +68,8 @@ fn parse_pin_spec(spec: &str) -> Result<(Option, CertPin)> { CertPin::ca_cert(rest, None) } else { anyhow::bail!( - "invalid pin spec '{}': expected 'sha256:HASH', 'sha384:HASH', 'sha512:HASH', or 'ca:BASE64_DER'", + "invalid pin spec '{}': expected 'sha256:HASH', 'sha384:HASH', 'sha512:HASH', or \ + 'ca:BASE64_DER'", spec ); }; diff --git a/crates/pardus-cli/src/context.rs b/crates/pardus-cli/src/context.rs deleted file mode 100644 index 7eca182..0000000 --- a/crates/pardus-cli/src/context.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Shared CLI context — eliminates duplicated proxy/cert config building across commands. - -use pardus_core::BrowserConfig; - -use crate::config::{self, PinPolicyArg}; - -/// Shared context built from common CLI arguments. -/// -/// Each command variant constructs this from its proxy/cert-pin args, -/// then passes it to the command handler instead of building BrowserConfig inline. -pub struct CliContext { - pub browser_config: BrowserConfig, -} - -impl CliContext { - /// Build from the standard set of proxy + cert pin CLI arguments. - #[allow(clippy::too_many_arguments)] - pub fn from_cli_args( - proxy: Option, - proxy_http: Option, - proxy_https: Option, - no_proxy: Option, - no_proxy_env: bool, - cert_pin: Vec, - cert_pin_file: Option, - pin_policy: Option, - ) -> anyhow::Result { - let mut browser_config = BrowserConfig::default(); - - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } - browser_config.proxy = proxy_config; - - // Build certificate pinning configuration - if !cert_pin.is_empty() || cert_pin_file.is_some() { - let mut all_pins = cert_pin; - if let Some(path) = &cert_pin_file { - let file_pins = config::load_pins_from_file(path) - .map_err(|e| anyhow::anyhow!("Failed to load cert pin file '{}': {}", path.display(), e))?; - all_pins.extend(file_pins); - } - let pin_config = config::build_cert_pinning_config(&all_pins, pin_policy, true) - .map_err(|e| anyhow::anyhow!("Invalid certificate pin config: {}", e))?; - browser_config.cert_pinning = Some(pin_config); - } - - Ok(Self { browser_config }) - } - - /// Create a Browser from this context. - pub fn create_browser(&self) -> anyhow::Result { - pardus_core::Browser::new(self.browser_config.clone()) - } - - /// Get a reference to the browser config. - pub fn config(&self) -> &BrowserConfig { - &self.browser_config - } -} diff --git a/crates/pardus-cli/src/logging.rs b/crates/pardus-cli/src/logging.rs deleted file mode 100644 index 0372643..0000000 --- a/crates/pardus-cli/src/logging.rs +++ /dev/null @@ -1,23 +0,0 @@ -use tracing_subscriber::EnvFilter; - -pub fn init_logging() { - let json = std::env::var("LOG_FORMAT") - .map(|v| v == "json") - .unwrap_or(false); - - let filter = if let Ok(rust_log) = std::env::var("RUST_LOG") { - EnvFilter::new(&rust_log) - } else if let Ok(log_level) = std::env::var("LOG_LEVEL") { - EnvFilter::new(&log_level) - } else { - EnvFilter::new("warn") - }; - - let builder = tracing_subscriber::fmt(); - - if json { - builder.json().with_env_filter(filter).init(); - } else { - builder.with_env_filter(filter).init(); - } -} diff --git a/crates/pardus-cli/src/main.rs b/crates/pardus-cli/src/main.rs index a853018..9ebea53 100644 --- a/crates/pardus-cli/src/main.rs +++ b/crates/pardus-cli/src/main.rs @@ -1,13 +1,71 @@ +use std::path::PathBuf; + use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; -use std::path::PathBuf; mod commands; mod config; +fn build_proxy_config( + proxy: Option, + proxy_http: Option, + proxy_https: Option, + no_proxy: Option, + no_proxy_env: bool, +) -> pardus_core::ProxyConfig { + let mut proxy_config = pardus_core::ProxyConfig::new(); + if let Some(all_proxy) = proxy { + proxy_config = proxy_config.with_all_proxy(all_proxy); + } + if let Some(http) = proxy_http { + proxy_config = proxy_config.with_http_proxy(http); + } + if let Some(https) = proxy_https { + proxy_config = proxy_config.with_https_proxy(https); + } + if let Some(no) = no_proxy { + proxy_config = proxy_config.with_no_proxy(no); + } + if !no_proxy_env { + proxy_config = proxy_config.merge_env(); + } + proxy_config +} + +fn apply_cert_pinning( + browser_config: &mut pardus_core::BrowserConfig, + cert_pin: Vec, + cert_pin_file: Option, + pin_policy: Option, +) { + if cert_pin.is_empty() && cert_pin_file.is_none() { + return; + } + let mut all_pins = cert_pin; + if let Some(path) = &cert_pin_file { + match config::load_pins_from_file(path) { + Ok(file_pins) => all_pins.extend(file_pins), + Err(e) => { + eprintln!("Warning: failed to load cert pin file: {}", e); + } + } + } + match config::build_cert_pinning_config(&all_pins, pin_policy, true) { + Ok(pin_config) => { + browser_config.cert_pinning = Some(pin_config); + } + Err(e) => { + eprintln!("Warning: invalid certificate pin config: {}", e); + } + } +} + #[derive(Parser)] #[command(name = "pardus-browser")] -#[command(version, about = "Headless browser for AI agents — semantic tree, no pixels")] +#[command( + version, + about = "Headless browser for AI agents — semantic tree, no pixels" +)] struct Cli { #[command(subcommand)] command: Commands, @@ -264,7 +322,6 @@ enum Commands { /// Map a site's functional structure into a Knowledge Graph Map { - /// Root URL to start mapping from url: String, @@ -509,49 +566,23 @@ async fn main() -> Result<()> { } let mut browser_config = pardus_core::BrowserConfig::default(); + browser_config.proxy = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); + apply_cert_pinning(&mut browser_config, cert_pin, cert_pin_file, pin_policy); - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } - browser_config.proxy = proxy_config; - - if !cert_pin.is_empty() || cert_pin_file.is_some() { - let mut all_pins = cert_pin.clone(); - if let Some(path) = &cert_pin_file { - match config::load_pins_from_file(path) { - Ok(file_pins) => all_pins.extend(file_pins), - Err(e) => { - eprintln!("Warning: failed to load cert pin file: {}", e); - } - } - } - match config::build_cert_pinning_config(&all_pins, pin_policy, true) { - Ok(pin_config) => { - browser_config.cert_pinning = Some(pin_config); - } - Err(e) => { - eprintln!("Warning: invalid certificate pin config: {}", e); - } - } - } - - commands::navigate::run_with_config(&url, format, interactive_only, with_nav, js, wait_ms, network_log, har, coverage, browser_config, - ).await?; + commands::navigate::run_with_config( + &url, + format, + interactive_only, + with_nav, + js, + wait_ms, + network_log, + har, + coverage, + browser_config, + ) + .await?; } Commands::Interact { url, @@ -566,74 +597,31 @@ async fn main() -> Result<()> { no_proxy_env, } => { let mut browser_config = pardus_core::BrowserConfig::default(); + browser_config.proxy = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } - browser_config.proxy = proxy_config; - - commands::interact::run_with_config(&url, action, format, js, wait_ms, browser_config, - ).await?; + commands::interact::run_with_config(&url, action, format, js, wait_ms, browser_config) + .await?; } - Commands::Serve { host, port, timeout, cert_pin, cert_pin_file, pin_policy, proxy, proxy_http, proxy_https, no_proxy, no_proxy_env } => { + Commands::Serve { + host, + port, + timeout, + cert_pin, + cert_pin_file, + pin_policy, + proxy, + proxy_http, + proxy_https, + no_proxy, + no_proxy_env, + } => { tracing::info!("Starting CDP WebSocket server on ws://{host}:{port}"); let mut browser_config = pardus_core::BrowserConfig::default(); - - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } - browser_config.proxy = proxy_config; - - if !cert_pin.is_empty() || cert_pin_file.is_some() { - let mut all_pins = cert_pin; - if let Some(path) = &cert_pin_file { - match config::load_pins_from_file(path) { - Ok(file_pins) => all_pins.extend(file_pins), - Err(e) => { - eprintln!("Warning: failed to load cert pin file: {}", e); - } - } - } - match config::build_cert_pinning_config(&all_pins, pin_policy, true) { - Ok(pin_config) => { - browser_config.cert_pinning = Some(pin_config); - } - Err(e) => { - eprintln!("Warning: invalid certificate pin config: {}", e); - } - } - } + browser_config.proxy = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); + apply_cert_pinning(&mut browser_config, cert_pin, cert_pin_file, pin_policy); commands::serve::run(&host, port, timeout, browser_config).await?; } @@ -644,25 +632,16 @@ async fn main() -> Result<()> { } => { commands::clean::run(cache_dir, cookies_only, cache_only)?; } - Commands::Tab { action, proxy, proxy_http, proxy_https, no_proxy, no_proxy_env } => { - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } + Commands::Tab { + action, + proxy, + proxy_http, + proxy_https, + no_proxy, + no_proxy_env, + } => { + let proxy_config = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); match action { TabAction::List => { @@ -672,8 +651,7 @@ async fn main() -> Result<()> { commands::tab::list(&browser, OutputFormatArg::Md).await?; } TabAction::Open { url, js } => { - commands::tab::open_with_config(&url, js, proxy_config, - ).await?; + commands::tab::open_with_config(&url, js, proxy_config).await?; } TabAction::Info => { let mut browser_config = pardus_core::BrowserConfig::default(); @@ -682,30 +660,22 @@ async fn main() -> Result<()> { commands::tab::info(&browser, OutputFormatArg::Md)?; } TabAction::Navigate { url } => { - commands::tab::navigate_with_config(&url, proxy_config, - ).await?; + commands::tab::navigate_with_config(&url, proxy_config).await?; } } } - Commands::Repl { js, format, wait_ms, proxy, proxy_http, proxy_https, no_proxy, no_proxy_env } => { - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } + Commands::Repl { + js, + format, + wait_ms, + proxy, + proxy_http, + proxy_https, + no_proxy, + no_proxy_env, + } => { + let proxy_config = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); commands::repl::run_with_config(js, format, wait_ms, proxy_config).await?; } @@ -725,28 +695,22 @@ async fn main() -> Result<()> { no_proxy, no_proxy_env, } => { - // Build proxy configuration - let mut proxy_config = pardus_core::ProxyConfig::new(); - if let Some(all_proxy) = proxy { - proxy_config = proxy_config.with_all_proxy(all_proxy); - } - if let Some(http) = proxy_http { - proxy_config = proxy_config.with_http_proxy(http); - } - if let Some(https) = proxy_https { - proxy_config = proxy_config.with_https_proxy(https); - } - if let Some(no) = no_proxy { - proxy_config = proxy_config.with_no_proxy(no); - } - // Merge environment variables unless disabled - if !no_proxy_env { - proxy_config = proxy_config.merge_env(); - } + let proxy_config = + build_proxy_config(proxy, proxy_http, proxy_https, no_proxy, no_proxy_env); commands::map::run_with_config( - &url, &output, depth, max_pages, delay, skip_verify, pagination, hash_nav, verbose, proxy_config, - ).await?; + &url, + &output, + depth, + max_pages, + delay, + skip_verify, + pagination, + hash_nav, + verbose, + proxy_config, + ) + .await?; } #[cfg(feature = "screenshot")] Commands::Screenshot { @@ -770,7 +734,8 @@ async fn main() -> Result<()> { quality, chrome_path.as_ref(), timeout_ms, - ).await?; + ) + .await?; } } diff --git a/crates/pardus-core/Cargo.toml b/crates/pardus-core/Cargo.toml index 16b3f0c..2d87d7b 100644 --- a/crates/pardus-core/Cargo.toml +++ b/crates/pardus-core/Cargo.toml @@ -38,6 +38,7 @@ dashmap = { workspace = true } lru = "0.12" bumpalo = "3" fastrand = "2" +getrandom = "0.3" dirs = "6" # Network debugging @@ -56,6 +57,7 @@ blake3 = { workspace = true } # Streaming parser lol_html = { workspace = true } +encoding_rs = "0.8" bytes = { workspace = true } # PDF text extraction diff --git a/crates/pardus-core/src/app.rs b/crates/pardus-core/src/app.rs index 73b89f3..93a1e41 100644 --- a/crates/pardus-core/src/app.rs +++ b/crates/pardus-core/src/app.rs @@ -1,100 +1,100 @@ -use crate::config::BrowserConfig; -use crate::dedup::RequestDedup; -use crate::intercept::InterceptorManager; -use crate::session::SessionStore; +use std::sync::{Arc, Mutex}; + use pardus_debug::NetworkLog; use parking_lot::RwLock; use rquest_util::Emulation; -use std::sync::Arc; -use std::sync::Mutex; use url::Url; -/// Build Chrome-like default headers that anti-bot systems expect. +use crate::{ + config::BrowserConfig, dedup::RequestDedup, intercept::InterceptorManager, + session::SessionStore, +}; + fn chrome_default_headers() -> rquest::header::HeaderMap { let mut headers = rquest::header::HeaderMap::new(); - // Accept header (Chrome navigation request) headers.insert( rquest::header::ACCEPT, - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" - .parse().unwrap(), + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/\ + *;q=0.8" + .parse() + .unwrap(), ); - // Accept-Language headers.insert( rquest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9".parse().unwrap(), ); - // Accept-Encoding headers.insert( rquest::header::ACCEPT_ENCODING, "gzip, deflate, br".parse().unwrap(), ); - // Client Hints — Chrome 131 brand tokens headers.insert( "sec-ch-ua", r#""Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24""# - .parse().unwrap(), + .parse() + .unwrap(), ); headers.insert("sec-ch-ua-mobile", "?0".parse().unwrap()); headers.insert("sec-ch-ua-platform", r#""macOS""#.parse().unwrap()); - // Fetch metadata headers.insert("sec-fetch-dest", "document".parse().unwrap()); headers.insert("sec-fetch-mode", "navigate".parse().unwrap()); headers.insert("sec-fetch-site", "none".parse().unwrap()); headers.insert("sec-fetch-user", "?1".parse().unwrap()); - // Upgrade-Insecure-Requests headers.insert("upgrade-insecure-requests", "1".parse().unwrap()); headers } -/// Build an HTTP client from the given browser configuration. -/// -/// Extracted as a standalone function so that both `App` and `Browser` -/// can reuse the same client-building logic. -pub fn build_http_client(config: &BrowserConfig) -> anyhow::Result { - let mut client_builder = rquest::Client::builder() +fn base_client_builder(config: &BrowserConfig) -> rquest::ClientBuilder { + let mut builder = rquest::Client::builder() .emulation(Emulation::Chrome131) .timeout(std::time::Duration::from_millis(config.timeout_ms as u64)) .default_headers(chrome_default_headers()) .user_agent(&config.user_agent) - .cert_verification(false); // BoringSSL doesn't load system certs; skip verify for headless use + .cert_verification(config.tls_verify_certificates) + .pool_max_idle_per_host(config.connection_pool.max_idle_per_host) + .pool_idle_timeout(std::time::Duration::from_secs( + config.connection_pool.idle_timeout_secs, + )) + .tcp_keepalive(std::time::Duration::from_secs( + config.connection_pool.tcp_keepalive_secs, + )) + .http2_max_retry_count(2); - // Sandbox: disable cookie store for ephemeral sessions if !config.sandbox.ephemeral_session { - client_builder = client_builder.cookie_store(true); + builder = builder.cookie_store(true); } - // Certificate pinning: use custom TLS connector when pins are configured + builder +} + +pub fn build_http_client(config: &BrowserConfig) -> anyhow::Result { + let client_builder = base_client_builder(config); + #[cfg(feature = "tls-pinning")] - if let Some(pinning) = &config.cert_pinning { + let client_builder = if let Some(pinning) = &config.cert_pinning { if !pinning.pins.is_empty() || !pinning.default_pins.is_empty() { - client_builder = match crate::tls::pinned_client_builder(client_builder, pinning) { + match crate::tls::pinned_client_builder(client_builder, pinning) { Ok(builder) => builder, Err(e) => { - tracing::warn!( - "certificate pinning setup failed, using default TLS: {}", - e - ); - // Rebuild without pinning since builder was moved - let mut new_builder = rquest::Client::builder() - .emulation(Emulation::Chrome131) - .timeout(std::time::Duration::from_millis(config.timeout_ms as u64)) - .default_headers(chrome_default_headers()) - .user_agent(&config.user_agent); - if !config.sandbox.ephemeral_session { - new_builder = new_builder.cookie_store(true); - } - new_builder + tracing::warn!("certificate pinning setup failed, using default TLS: {}", e); + base_client_builder(config) } - }; + } + } else { + client_builder } - } + } else { + client_builder + }; + + #[cfg(not(feature = "tls-pinning"))] + let client_builder = client_builder; Ok(client_builder.build()?) } @@ -103,36 +103,28 @@ pub struct App { pub http_client: rquest::Client, pub config: RwLock, pub network_log: Arc>, - /// Request interception pipeline. pub interceptors: InterceptorManager, - /// Request deduplication tracker. pub dedup: RequestDedup, - /// Shared cookie jar for programmatic access. pub cookie_jar: Arc, } impl App { - pub fn new(config: BrowserConfig) -> Self { - let http_client = build_http_client(&config) - .expect("failed to build HTTP client"); + pub fn new(config: BrowserConfig) -> anyhow::Result { + let http_client = build_http_client(&config)?; let dedup_window = config.dedup_window_ms; - let cookie_jar = Arc::new( - SessionStore::ephemeral("app", &config.cache_dir) - .expect("failed to create cookie jar"), - ); + let cookie_jar = Arc::new(SessionStore::ephemeral("app", &config.cache_dir)?); - Self { + Ok(Self { http_client, config: RwLock::new(config), network_log: Arc::new(Mutex::new(NetworkLog::new())), interceptors: InterceptorManager::new(), dedup: RequestDedup::new(dedup_window), cookie_jar, - } + }) } - /// Create an App that shares pipeline state (for Browser temp_app). pub fn from_shared( http_client: rquest::Client, config: BrowserConfig, @@ -151,15 +143,25 @@ impl App { } } - /// Validate a URL against the configured security policy. - /// - /// Returns a parsed URL if valid, or an error if the URL violates the policy. + pub fn from_client_and_log( + http_client: rquest::Client, + config: BrowserConfig, + network_log: Arc>, + ) -> anyhow::Result { + let cookie_jar = Arc::new(SessionStore::ephemeral("app", &config.cache_dir)?); + Ok(Self { + http_client, + config: RwLock::new(config), + network_log, + interceptors: InterceptorManager::new(), + dedup: RequestDedup::new(0), + cookie_jar, + }) + } + pub fn validate_url(&self, url: &str) -> anyhow::Result { self.config.read().url_policy.validate(url) } - /// Get a snapshot of the current configuration. - pub fn config_snapshot(&self) -> BrowserConfig { - self.config.read().clone() - } + pub fn config_snapshot(&self) -> BrowserConfig { self.config.read().clone() } } diff --git a/crates/pardus-core/src/browser/helpers.rs b/crates/pardus-core/src/browser/helpers.rs index 5457e5e..6489d38 100644 --- a/crates/pardus-core/src/browser/helpers.rs +++ b/crates/pardus-core/src/browser/helpers.rs @@ -2,25 +2,26 @@ use std::sync::Arc; -use crate::interact::actions::InteractionResult; -use crate::page::Page; -use crate::tab::TabId; - use super::Browser; +use crate::{interact::actions::InteractionResult, page::Page, tab::TabId}; impl Browser { pub(super) fn require_active_id(&self) -> anyhow::Result { - self.active_tab.ok_or_else(|| anyhow::anyhow!("No active tab")) + self.active_tab + .ok_or_else(|| anyhow::anyhow!("No active tab")) } pub(super) fn require_active_page(&self) -> anyhow::Result<&Page> { - self.current_page().ok_or_else(|| anyhow::anyhow!("No page loaded in active tab")) + self.current_page() + .ok_or_else(|| anyhow::anyhow!("No page loaded in active tab")) } /// Check if the active tab has JS execution enabled. #[allow(dead_code)] pub(super) fn is_js_enabled(&self) -> bool { - self.active_tab().map(|t| t.config.js_enabled).unwrap_or(false) + self.active_tab() + .map(|t| t.config.js_enabled) + .unwrap_or(false) } /// Create a temporary `Arc` that shares pipeline state from the Browser. @@ -38,28 +39,42 @@ impl Browser { /// If an interaction produced a `Navigated` result, update the active tab. /// Clears accumulated form state on navigation. - pub(super) fn apply_navigated_result(&mut self, result: InteractionResult) -> anyhow::Result { + pub(super) fn apply_navigated_result( + &mut self, + result: InteractionResult, + ) -> anyhow::Result { if let InteractionResult::Navigated(new_page) = result { self.form_state = crate::interact::FormState::new(); let id = self.require_active_id()?; - let tab = self.tabs.get_mut(&id) + let tab = self + .tabs + .get_mut(&id) .ok_or_else(|| anyhow::anyhow!("active tab missing"))?; tab.update_page(new_page); Ok(InteractionResult::Navigated( - tab.page.as_ref() + tab.page + .as_ref() .ok_or_else(|| anyhow::anyhow!("page missing after update"))? - .clone_shallow() + .clone_shallow(), )) - } else if let InteractionResult::Scrolled { url, page: new_page } = result { + } else if let InteractionResult::Scrolled { + url, + page: new_page, + } = result + { self.form_state = crate::interact::FormState::new(); let id = self.require_active_id()?; - let tab = self.tabs.get_mut(&id) + let tab = self + .tabs + .get_mut(&id) .ok_or_else(|| anyhow::anyhow!("active tab missing"))?; tab.update_page(new_page); let url_clone = url.clone(); Ok(InteractionResult::Scrolled { url: url_clone, - page: tab.page.as_ref() + page: tab + .page + .as_ref() .ok_or_else(|| anyhow::anyhow!("page missing after update"))? .clone_shallow(), }) diff --git a/crates/pardus-core/src/browser/history.rs b/crates/pardus-core/src/browser/history.rs index 5831000..6b25e33 100644 --- a/crates/pardus-core/src/browser/history.rs +++ b/crates/pardus-core/src/browser/history.rs @@ -1,22 +1,33 @@ //! History navigation: go_back, go_forward. -use crate::tab::Tab; - use super::Browser; +use crate::tab::Tab; impl Browser { /// Go back in the active tab's history. pub async fn go_back(&mut self) -> anyhow::Result> { let id = self.require_active_id()?; - let tab = self.tabs.get_mut(&id) + let tab = self + .tabs + .get_mut(&id) .ok_or_else(|| anyhow::anyhow!("active tab missing"))?; if tab.history_index > 0 { tab.history_index -= 1; tab.url = tab.history[tab.history_index].clone(); tab.page = None; - tab.load_with_client(&self.http_client, &self.network_log, &self.config, tab.config.js_enabled, tab.config.wait_ms).await?; - Ok(Some(self.tabs.get(&id) - .ok_or_else(|| anyhow::anyhow!("active tab missing"))?)) + tab.load_with_client( + &self.http_client, + &self.network_log, + &self.config, + tab.config.js_enabled, + tab.config.wait_ms, + ) + .await?; + Ok(Some( + self.tabs + .get(&id) + .ok_or_else(|| anyhow::anyhow!("active tab missing"))?, + )) } else { Ok(None) } @@ -25,15 +36,27 @@ impl Browser { /// Go forward in the active tab's history. pub async fn go_forward(&mut self) -> anyhow::Result> { let id = self.require_active_id()?; - let tab = self.tabs.get_mut(&id) + let tab = self + .tabs + .get_mut(&id) .ok_or_else(|| anyhow::anyhow!("active tab missing"))?; if tab.history_index < tab.history.len() - 1 { tab.history_index += 1; tab.url = tab.history[tab.history_index].clone(); tab.page = None; - tab.load_with_client(&self.http_client, &self.network_log, &self.config, tab.config.js_enabled, tab.config.wait_ms).await?; - Ok(Some(self.tabs.get(&id) - .ok_or_else(|| anyhow::anyhow!("active tab missing"))?)) + tab.load_with_client( + &self.http_client, + &self.network_log, + &self.config, + tab.config.js_enabled, + tab.config.wait_ms, + ) + .await?; + Ok(Some( + self.tabs + .get(&id) + .ok_or_else(|| anyhow::anyhow!("active tab missing"))?, + )) } else { Ok(None) } diff --git a/crates/pardus-core/src/browser/interact.rs b/crates/pardus-core/src/browser/interact.rs index 09cb654..d85915f 100644 --- a/crates/pardus-core/src/browser/interact.rs +++ b/crates/pardus-core/src/browser/interact.rs @@ -2,10 +2,8 @@ use std::path::PathBuf; -use crate::interact::actions::InteractionResult; -use crate::interact::{FormState, ScrollDirection}; - use super::Browser; +use crate::interact::{FormState, ScrollDirection, actions::InteractionResult}; impl Browser { /// Click an element. If JS is enabled, dispatches click event in V8 DOM first. @@ -21,9 +19,9 @@ impl Browser { } let page = self.require_active_page()?; - let handle = page.query(selector).ok_or_else(|| { - anyhow::anyhow!("Element not found: {}", selector) - })?; + let handle = page + .query(selector) + .ok_or_else(|| anyhow::anyhow!("Element not found: {}", selector))?; let app = self.temp_app(); let result = crate::interact::actions::click(&app, page, &handle, &self.form_state).await?; drop(app); @@ -34,9 +32,9 @@ impl Browser { /// This is the preferred way for AI agents to click elements. pub async fn click_by_id(&mut self, id: usize) -> anyhow::Result { let page = self.require_active_page()?; - let handle = page.find_by_element_id(id).ok_or_else(|| { - anyhow::anyhow!("Element with ID {} not found", id) - })?; + let handle = page + .find_by_element_id(id) + .ok_or_else(|| anyhow::anyhow!("Element with ID {} not found", id))?; #[cfg(feature = "js")] if self.is_js_enabled() { @@ -55,7 +53,11 @@ impl Browser { /// Type text into a form field. /// If JS is enabled, dispatches input/change events in V8 DOM. - pub async fn type_text(&mut self, selector: &str, value: &str) -> anyhow::Result { + pub async fn type_text( + &mut self, + selector: &str, + value: &str, + ) -> anyhow::Result { #[cfg(feature = "js")] if self.is_js_enabled() { let page = self.require_active_page()?; @@ -64,9 +66,9 @@ impl Browser { } let page = self.require_active_page()?; - let handle = page.query(selector).ok_or_else(|| { - anyhow::anyhow!("Element not found: {}", selector) - })?; + let handle = page + .query(selector) + .ok_or_else(|| anyhow::anyhow!("Element not found: {}", selector))?; let (result, field_name) = { let name = handle.name.clone(); let result = crate::interact::actions::type_text(page, &handle, value)?; @@ -80,13 +82,17 @@ impl Browser { /// Type text into a form field by its element ID (shown in semantic tree as [#1], [#2], etc.) /// This is the preferred way for AI agents to fill form fields. - pub async fn type_by_id(&mut self, id: usize, value: &str) -> anyhow::Result { + pub async fn type_by_id( + &mut self, + id: usize, + value: &str, + ) -> anyhow::Result { #[cfg(feature = "js")] if self.is_js_enabled() { let page = self.require_active_page()?; - let handle = page.find_by_element_id(id).ok_or_else(|| { - anyhow::anyhow!("Element with ID {} not found", id) - })?; + let handle = page + .find_by_element_id(id) + .ok_or_else(|| anyhow::anyhow!("Element with ID {} not found", id))?; let name = handle.name.clone(); let selector = handle.selector.clone(); let result = crate::interact::js_interact::js_type(page, &selector, value).await?; @@ -97,9 +103,9 @@ impl Browser { } let page = self.require_active_page()?; - let handle = page.find_by_element_id(id).ok_or_else(|| { - anyhow::anyhow!("Element with ID {} not found", id) - })?; + let handle = page + .find_by_element_id(id) + .ok_or_else(|| anyhow::anyhow!("Element with ID {} not found", id))?; let (result, field_name) = { let name = handle.name.clone(); let result = crate::interact::actions::type_text(page, &handle, value)?; @@ -122,7 +128,8 @@ impl Browser { if self.is_js_enabled() { let page = self.require_active_page()?; let app = self.temp_app(); - let result = crate::interact::js_interact::js_submit(&app, page, form_selector, state).await?; + let result = + crate::interact::js_interact::js_submit(&app, page, form_selector, state).await?; drop(app); return self.apply_navigated_result(result); } @@ -142,16 +149,18 @@ impl Browser { ) -> anyhow::Result { let page = self.require_active_page()?; let app = self.temp_app(); - let result = crate::interact::wait::wait_for_selector( - &app, page, selector, timeout_ms, 500, - ).await?; + let result = + crate::interact::wait::wait_for_selector(&app, page, selector, timeout_ms, 500).await?; drop(app); Ok(result) } /// Scroll. If JS is enabled, dispatches scroll/wheel events in V8 DOM. /// Otherwise uses URL-based pagination detection. - pub async fn scroll(&mut self, direction: ScrollDirection) -> anyhow::Result { + pub async fn scroll( + &mut self, + direction: ScrollDirection, + ) -> anyhow::Result { #[cfg(feature = "js")] if self.is_js_enabled() { let page = self.require_active_page()?; @@ -169,9 +178,9 @@ impl Browser { /// Toggle a checkbox or radio. pub fn toggle(&mut self, selector: &str) -> anyhow::Result { let page = self.require_active_page()?; - let handle = page.query(selector).ok_or_else(|| { - anyhow::anyhow!("Element not found: {}", selector) - })?; + let handle = page + .query(selector) + .ok_or_else(|| anyhow::anyhow!("Element not found: {}", selector))?; let result = crate::interact::actions::toggle(page, &handle)?; if let Some(ref name) = handle.name { let value = handle.value.as_deref().unwrap_or("on"); @@ -183,11 +192,15 @@ impl Browser { } /// Select an option in a `unchanged"# + r#"unchanged"#, ); let interaction_js = r#" @@ -1062,7 +1043,7 @@ mod tests { #[test] fn test_js_type_triggers_oninput() { let html = test_page_html( - r#"empty"# + r#"empty"#, ); let interaction_js = r#" @@ -1110,7 +1091,7 @@ mod tests { #[test] fn test_js_scroll_triggers_onscroll() { let html = test_page_html( - r#"
not scrolled
"# + r#"
not scrolled
"#, ); let interaction_js = r#" @@ -1133,7 +1114,7 @@ mod tests { #[test] fn test_inline_onclick_registered_and_fires() { let html = test_page_html( - r#"waiting"# + r#"waiting"#, ); let interaction_js = r#" @@ -1154,7 +1135,7 @@ mod tests { #[test] fn test_inline_onchange_registered_and_fires() { let html = test_page_html( - r#"waiting"# + r#"waiting"#, ); let interaction_js = r#" @@ -1178,7 +1159,7 @@ mod tests { #[test] fn test_multiple_inline_handlers() { let html = test_page_html( - r#"none"# + r#"none"#, ); let interaction_js = r#" @@ -1204,7 +1185,7 @@ mod tests { fn test_window_location_href_detection_inline() { // Step 1: Test that inline handler with window.location.href fires let html = test_page_html( - r#"waiting"# + r#"waiting"#, ); let interaction_js = r#" @@ -1223,8 +1204,8 @@ mod tests { assert!(result.is_some()); let r = result.unwrap(); - eprintln!("[DEBUG] navigation_href: {:?}", r.navigation_href); - eprintln!("[DEBUG] html: {:?}", r.html); + tracing::debug!("[DEBUG] navigation_href: {:?}", r.navigation_href); + tracing::debug!("[DEBUG] html: {:?}", r.html); assert_eq!( r.navigation_href.as_deref(), Some("/new-page"), @@ -1237,7 +1218,7 @@ mod tests { #[test] fn test_js_submit_prevented() { let html = test_page_html( - r#"
waiting"# + r#"
waiting"#, ); let interaction_js = r#" @@ -1264,7 +1245,10 @@ mod tests { assert!(result.is_some()); let r = result.unwrap(); assert_eq!(r.submit_prevented, Some(true), "Submit should be prevented"); - assert!(r.html.unwrap().contains("prevented"), "Handler should have modified DOM"); + assert!( + r.html.unwrap().contains("prevented"), + "Handler should have modified DOM" + ); } // ==================== No-op / Edge Case Tests ==================== @@ -1334,7 +1318,11 @@ mod tests { match result.unwrap() { InteractionResult::Navigated(new_page) => { let html = new_page.html.html(); - assert!(html.contains("changed"), "Expected 'changed' in output, got: {}", html); + assert!( + html.contains("changed"), + "Expected 'changed' in output, got: {}", + html + ); } other => panic!("Expected Navigated, got: {:?}", other), } @@ -1352,7 +1340,11 @@ mod tests { match result.unwrap() { InteractionResult::Navigated(new_page) => { let html = new_page.html.html(); - assert!(html.contains("focused"), "Expected 'focused' in output, got: {}", html); + assert!( + html.contains("focused"), + "Expected 'focused' in output, got: {}", + html + ); } other => panic!("Expected Navigated, got: {:?}", other), } @@ -1412,7 +1404,11 @@ mod tests { match result.unwrap() { InteractionResult::Navigated(new_page) => { let html = new_page.html.html(); - assert!(html.contains("blurred"), "Expected 'blurred' in output, got: {}", html); + assert!( + html.contains("blurred"), + "Expected 'blurred' in output, got: {}", + html + ); } other => panic!("Expected Navigated, got: {:?}", other), } diff --git a/crates/pardus-core/src/interact/scroll.rs b/crates/pardus-core/src/interact/scroll.rs index 0a32518..3d5b120 100644 --- a/crates/pardus-core/src/interact/scroll.rs +++ b/crates/pardus-core/src/interact/scroll.rs @@ -1,9 +1,9 @@ use std::sync::Arc; + use url::Url; -use crate::app::App; -use crate::page::Page; use super::actions::InteractionResult; +use crate::{app::App, page::Page}; /// Scroll direction. #[derive(Debug, Clone, Copy, PartialEq)] @@ -37,8 +37,8 @@ pub async fn scroll( } None => Ok(InteractionResult::ElementNotFound { selector: String::new(), - reason: "no pagination pattern detected in URL. \ - Try enabling JS execution for AJAX-based infinite scroll." + reason: "no pagination pattern detected in URL. Try enabling JS execution for \ + AJAX-based infinite scroll." .to_string(), }), } @@ -103,7 +103,7 @@ fn detect_next_page_url(current_url: &str, direction: ScrollDirection) -> Option // Strategy 4: Path-based pagination (/page/2, /p/2) let path = url.path().to_string(); - let mut segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); for i in (1..segments.len()).rev() { let prev = segments[i - 1].to_lowercase(); @@ -116,8 +116,10 @@ fn detect_next_page_url(current_url: &str, direction: ScrollDirection) -> Option if next_page == 0 { return None; } - segments[i] = Box::leak(next_page.to_string().into_boxed_str()); - let new_path = format!("/{}", segments.join("/")); + let mut owned_segments: Vec = + segments.iter().map(|s| s.to_string()).collect(); + owned_segments[i] = next_page.to_string(); + let new_path = format!("/{}", owned_segments.join("/")); url.set_path(&new_path); return Some(url.to_string()); } diff --git a/crates/pardus-core/src/interact/wait.rs b/crates/pardus-core/src/interact/wait.rs index 50b9e08..5b19b27 100644 --- a/crates/pardus-core/src/interact/wait.rs +++ b/crates/pardus-core/src/interact/wait.rs @@ -1,10 +1,12 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use scraper::{Html, Selector}; -use crate::app::App; -use crate::page::Page; + use super::actions::InteractionResult; +use crate::{app::App, page::Page}; pub async fn wait_for_selector( app: &Arc, @@ -279,15 +281,11 @@ pub async fn wait_smart( WaitCondition::Selector(sel) => { wait_for_selector(app, page, sel, timeout_ms, interval_ms).await } - WaitCondition::ContentLoaded => { - wait_for_loaded(app, page, timeout_ms, interval_ms).await - } + WaitCondition::ContentLoaded => wait_for_loaded(app, page, timeout_ms, interval_ms).await, WaitCondition::ContentStable => { wait_for_stable(app, page, timeout_ms, interval_ms, 3).await } - WaitCondition::NetworkIdle => { - wait_for_stable(app, page, timeout_ms, interval_ms, 5).await - } + WaitCondition::NetworkIdle => wait_for_stable(app, page, timeout_ms, interval_ms, 5).await, WaitCondition::MinInteractiveElements(min) => { wait_for_interactive(app, page, *min, timeout_ms, interval_ms).await } @@ -313,13 +311,7 @@ fn extract_body_text(html: &Html) -> String { } fn has_loading_indicators(html: &Html) -> bool { - let mut text = String::new(); - for node in html.tree.nodes() { - if let Some(t) = node.value().as_text() { - text.push_str(t); - } - } - let text = text.to_lowercase(); + let text = extract_body_text(html).to_lowercase(); let indicators = [ "loading", "please wait", @@ -340,7 +332,10 @@ fn has_loading_indicators(html: &Html) -> bool { } } - if let Ok(sel) = Selector::parse("[class*='loading'], [class*='spinner'], [class*='skeleton'], [role='progressbar'], [aria-busy='true']") { + if let Ok(sel) = Selector::parse( + "[class*='loading'], [class*='spinner'], [class*='skeleton'], [role='progressbar'], \ + [aria-busy='true']", + ) { if html.select(&sel).next().is_some() { return true; } @@ -394,7 +389,7 @@ mod tests { #[test] fn test_has_loading_indicators_text() { let html = Html::parse_document( - r#"
Loading content, please wait...
"# + r#"
Loading content, please wait...
"#, ); assert!(has_loading_indicators(&html)); } @@ -402,7 +397,7 @@ mod tests { #[test] fn test_no_loading_indicators() { let html = Html::parse_document( - r#"

Welcome

This is real content.

"# + r#"

Welcome

This is real content.

"#, ); assert!(!has_loading_indicators(&html)); } @@ -410,7 +405,7 @@ mod tests { #[test] fn test_single_loading_word_no_indicator() { let html = Html::parse_document( - r#"

Processing your request is important to us.

"# + r#"

Processing your request is important to us.

"#, ); assert!(!has_loading_indicators(&html)); } @@ -418,7 +413,7 @@ mod tests { #[test] fn test_loading_indicator_aria_busy() { let html = Html::parse_document( - r#"
Loading...
"# + r#"
Loading...
"#, ); assert!(has_loading_indicators(&html)); } @@ -426,7 +421,7 @@ mod tests { #[test] fn test_loading_indicator_class() { let html = Html::parse_document( - r#"
Working...
"# + r#"
Working...
"#, ); assert!(has_loading_indicators(&html)); } @@ -434,7 +429,7 @@ mod tests { #[test] fn test_loading_indicator_progressbar_role() { let html = Html::parse_document( - r#"
Loading...
"# + r#"
Loading...
"#, ); assert!(has_loading_indicators(&html)); } @@ -442,19 +437,17 @@ mod tests { #[test] fn test_loading_indicator_skeleton_class() { let html = Html::parse_document( - r#"
...
"# + r#"
...
"#, ); assert!(has_loading_indicators(&html)); } #[test] fn test_content_fingerprint_stable() { - let html1 = Html::parse_document( - "

Test

Hello world

" - ); - let html2 = Html::parse_document( - "

Test

Hello world

" - ); + let html1 = + Html::parse_document("

Test

Hello world

"); + let html2 = + Html::parse_document("

Test

Hello world

"); let fp1 = content_fingerprint(&html1); let fp2 = content_fingerprint(&html2); assert_eq!(fp1, fp2); @@ -462,12 +455,10 @@ mod tests { #[test] fn test_content_fingerprint_differs() { - let html1 = Html::parse_document( - "

Test

Hello world

" - ); - let html2 = Html::parse_document( - "

Test

Different content

" - ); + let html1 = + Html::parse_document("

Test

Hello world

"); + let html2 = + Html::parse_document("

Test

Different content

"); let fp1 = content_fingerprint(&html1); let fp2 = content_fingerprint(&html2); assert_ne!(fp1, fp2); @@ -476,33 +467,39 @@ mod tests { #[test] fn test_content_fingerprint_ignores_script() { let html1 = Html::parse_document( - r#"

Test

Hello

"# + r#"

Test

Hello

"#, ); let html2 = Html::parse_document( - r#"

Test

Hello

"# + r#"

Test

Hello

"#, ); let fp1 = content_fingerprint(&html1); let fp2 = content_fingerprint(&html2); - assert_eq!(fp1, fp2, "fingerprint should ignore script content differences"); + assert_eq!( + fp1, fp2, + "fingerprint should ignore script content differences" + ); } #[test] fn test_content_fingerprint_ignores_style() { let html1 = Html::parse_document( - r#"

Test

Hello

"# + r#"

Test

Hello

"#, ); let html2 = Html::parse_document( - r#"

Test

Hello

"# + r#"

Test

Hello

"#, ); let fp1 = content_fingerprint(&html1); let fp2 = content_fingerprint(&html2); - assert_eq!(fp1, fp2, "fingerprint should ignore style content differences"); + assert_eq!( + fp1, fp2, + "fingerprint should ignore style content differences" + ); } #[test] fn test_extract_body_text() { let html = Html::parse_document( - "Ignore

Hello

" + "Ignore

Hello

", ); let text = extract_body_text(&html); assert!(text.contains("Hello")); @@ -518,11 +515,20 @@ mod tests { #[test] fn test_wait_condition_equality() { - assert_eq!(WaitCondition::Selector("#foo".to_string()), WaitCondition::Selector("#foo".to_string())); + assert_eq!( + WaitCondition::Selector("#foo".to_string()), + WaitCondition::Selector("#foo".to_string()) + ); assert_eq!(WaitCondition::ContentLoaded, WaitCondition::ContentLoaded); assert_ne!(WaitCondition::ContentLoaded, WaitCondition::ContentStable); - assert_eq!(WaitCondition::MinInteractiveElements(5), WaitCondition::MinInteractiveElements(5)); - assert_ne!(WaitCondition::MinInteractiveElements(3), WaitCondition::MinInteractiveElements(5)); + assert_eq!( + WaitCondition::MinInteractiveElements(5), + WaitCondition::MinInteractiveElements(5) + ); + assert_ne!( + WaitCondition::MinInteractiveElements(3), + WaitCondition::MinInteractiveElements(5) + ); } #[test] diff --git a/crates/pardus-core/src/intercept/builtins.rs b/crates/pardus-core/src/intercept/builtins.rs index 154664d..656428a 100644 --- a/crates/pardus-core/src/intercept/builtins.rs +++ b/crates/pardus-core/src/intercept/builtins.rs @@ -1,14 +1,15 @@ //! Built-in interceptor implementations. -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; +use tokio::sync::Mutex; -use super::rules::InterceptorRule; use super::{ - InterceptAction, Interceptor, InterceptorPhase, MockResponse, ModifiedRequest, - RequestContext, + InterceptAction, Interceptor, InterceptorPhase, MockResponse, ModifiedRequest, RequestContext, + rules::InterceptorRule, }; +use crate::oauth::OAuthSessionManager; // --------------------------------------------------------------------------- // BlockingInterceptor @@ -20,20 +21,14 @@ pub struct BlockingInterceptor { } impl BlockingInterceptor { - pub fn new(rule: InterceptorRule) -> Self { - Self { rule } - } + pub fn new(rule: InterceptorRule) -> Self { Self { rule } } } #[async_trait] impl Interceptor for BlockingInterceptor { - fn phase(&self) -> InterceptorPhase { - InterceptorPhase::BeforeRequest - } + fn phase(&self) -> InterceptorPhase { InterceptorPhase::BeforeRequest } - fn matches(&self, ctx: &RequestContext) -> bool { - self.rule.matches(ctx) - } + fn matches(&self, ctx: &RequestContext) -> bool { self.rule.matches(ctx) } async fn intercept_request(&self, _ctx: &mut RequestContext) -> InterceptAction { InterceptAction::Block @@ -51,20 +46,14 @@ pub struct RedirectInterceptor { } impl RedirectInterceptor { - pub fn new(rule: InterceptorRule, target_url: String) -> Self { - Self { rule, target_url } - } + pub fn new(rule: InterceptorRule, target_url: String) -> Self { Self { rule, target_url } } } #[async_trait] impl Interceptor for RedirectInterceptor { - fn phase(&self) -> InterceptorPhase { - InterceptorPhase::BeforeRequest - } + fn phase(&self) -> InterceptorPhase { InterceptorPhase::BeforeRequest } - fn matches(&self, ctx: &RequestContext) -> bool { - self.rule.matches(ctx) - } + fn matches(&self, ctx: &RequestContext) -> bool { self.rule.matches(ctx) } async fn intercept_request(&self, _ctx: &mut RequestContext) -> InterceptAction { InterceptAction::Redirect(self.target_url.clone()) @@ -84,10 +73,7 @@ pub struct HeaderModifierInterceptor { impl HeaderModifierInterceptor { /// Add/replace headers on all matching requests. - pub fn new( - rule: Option, - headers_to_add: HashMap, - ) -> Self { + pub fn new(rule: Option, headers_to_add: HashMap) -> Self { Self { rule, headers_to_add, @@ -104,9 +90,7 @@ impl HeaderModifierInterceptor { #[async_trait] impl Interceptor for HeaderModifierInterceptor { - fn phase(&self) -> InterceptorPhase { - InterceptorPhase::BeforeRequest - } + fn phase(&self) -> InterceptorPhase { InterceptorPhase::BeforeRequest } fn matches(&self, ctx: &RequestContext) -> bool { match &self.rule { @@ -155,7 +139,10 @@ impl MockResponseInterceptor { /// Convenience: mock with a text body. pub fn text(rule: InterceptorRule, status: u16, body: &str) -> Self { let mut headers = HashMap::new(); - headers.insert("content-type".to_string(), "text/html; charset=utf-8".to_string()); + headers.insert( + "content-type".to_string(), + "text/html; charset=utf-8".to_string(), + ); Self::new(rule, status, headers, body.as_bytes().to_vec()) } @@ -172,13 +159,9 @@ impl MockResponseInterceptor { #[async_trait] impl Interceptor for MockResponseInterceptor { - fn phase(&self) -> InterceptorPhase { - InterceptorPhase::BeforeRequest - } + fn phase(&self) -> InterceptorPhase { InterceptorPhase::BeforeRequest } - fn matches(&self, ctx: &RequestContext) -> bool { - self.rule.matches(ctx) - } + fn matches(&self, ctx: &RequestContext) -> bool { self.rule.matches(ctx) } async fn intercept_request(&self, _ctx: &mut RequestContext) -> InterceptAction { InterceptAction::Mock(MockResponse { @@ -189,11 +172,87 @@ impl Interceptor for MockResponseInterceptor { } } +// --------------------------------------------------------------------------- +// OAuthTokenInterceptor +// --------------------------------------------------------------------------- + +/// Automatically injects `Authorization: Bearer ` headers for requests +/// matching registered OAuth provider domains. Handles auto-refresh of expired tokens. +/// +/// Shares an `OAuthSessionManager` with the CDP `OAuthDomain` via `Arc`. +pub struct OAuthTokenInterceptor { + sessions: Arc>, +} + +impl OAuthTokenInterceptor { + pub fn new(sessions: Arc>) -> Self { Self { sessions } } +} + +#[async_trait] +impl Interceptor for OAuthTokenInterceptor { + fn phase(&self) -> InterceptorPhase { InterceptorPhase::BeforeRequest } + + fn matches(&self, ctx: &RequestContext) -> bool { + let sessions = match self.sessions.try_lock() { + Ok(s) => s, + Err(_) => return false, + }; + sessions.find_matching_session(&ctx.url).is_some() + } + + async fn intercept_request(&self, ctx: &mut RequestContext) -> InterceptAction { + let sessions = self.sessions.lock().await; + + // Find the matching session and check token status + let provider_name = match sessions.find_matching_session(&ctx.url) { + Some((name, _)) => name.to_string(), + None => return InterceptAction::Continue, + }; + + // Check if token needs refresh + let needs_refresh = sessions + .get_tokens(&provider_name) + .map(|t| t.is_expired(60)) + .unwrap_or(false); + + if needs_refresh { + // Try to refresh the token + let refresh_token = sessions + .get_tokens(&provider_name) + .and_then(|t| t.refresh_token.clone()); + + if let Some(refresh_token) = refresh_token { + if let Some(provider) = sessions.get_provider(&provider_name).cloned() { + // Note: we can't refresh here because we don't have access to the HTTP client. + // Token refresh should be done by the CDP handler before this interceptor runs. + // For now, we'll still inject the existing (possibly expired) token. + let _ = (provider, refresh_token); + } + } + } + + // Inject the Authorization header + if let Some(tokens) = sessions.get_tokens(&provider_name) { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), tokens.authorization_header()); + return InterceptAction::Modify(ModifiedRequest { + url: None, + headers, + remove_headers: vec![], + body: None, + }); + } + + InterceptAction::Continue + } +} + #[cfg(test)] mod tests { + use pardus_debug::{Initiator, ResourceType}; + use super::*; use crate::intercept::rules::InterceptorRule; - use pardus_debug::{Initiator, ResourceType}; fn test_ctx(url: &str) -> RequestContext { RequestContext { @@ -209,7 +268,7 @@ mod tests { #[tokio::test] async fn test_blocking_interceptor() { - let interceptor = BlockingInterceptor::new(InterceptorRule::UrlGlob("*/ads/*".to_string())); + let interceptor = BlockingInterceptor::new(InterceptorRule::url_glob("*/ads/*")); let mut ctx = test_ctx("https://example.com/ads/banner.png"); assert!(interceptor.matches(&ctx)); let action = interceptor.intercept_request(&mut ctx).await; @@ -218,7 +277,7 @@ mod tests { #[tokio::test] async fn test_blocking_no_match() { - let interceptor = BlockingInterceptor::new(InterceptorRule::UrlGlob("*/ads/*".to_string())); + let interceptor = BlockingInterceptor::new(InterceptorRule::url_glob("*/ads/*")); let ctx = test_ctx("https://example.com/page"); assert!(!interceptor.matches(&ctx)); } @@ -226,7 +285,7 @@ mod tests { #[tokio::test] async fn test_redirect_interceptor() { let interceptor = RedirectInterceptor::new( - InterceptorRule::UrlGlob("*/api/*".to_string()), + InterceptorRule::url_glob("*/api/*"), "http://localhost:3000/api/".to_string(), ); let mut ctx = test_ctx("https://example.com/api/data"); @@ -257,7 +316,7 @@ mod tests { #[tokio::test] async fn test_mock_response() { let interceptor = MockResponseInterceptor::text( - InterceptorRule::UrlGlob("*/api/data*".to_string()), + InterceptorRule::url_glob("*/api/data*"), 200, "{\"mocked\": true}", ); diff --git a/crates/pardus-core/src/intercept/rules.rs b/crates/pardus-core/src/intercept/rules.rs index b8b6570..74e17b0 100644 --- a/crates/pardus-core/src/intercept/rules.rs +++ b/crates/pardus-core/src/intercept/rules.rs @@ -7,7 +7,7 @@ use crate::intercept::RequestContext; /// A pattern-matching rule used by built-in interceptors. pub enum InterceptorRule { /// Glob pattern matched against the full URL (e.g. `*/images/*`). - UrlGlob(String), + UrlGlob(String, regex::Regex), /// Regex matched against the full URL. UrlRegex(regex::Regex), /// Exact or wildcard domain match (e.g. `example.com`, `*.example.com`). @@ -23,7 +23,7 @@ pub enum InterceptorRule { impl std::fmt::Debug for InterceptorRule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::UrlGlob(p) => f.debug_tuple("UrlGlob").field(p).finish(), + Self::UrlGlob(p, _) => f.debug_tuple("UrlGlob").field(p).finish(), Self::UrlRegex(re) => f.debug_tuple("UrlRegex").field(&re.to_string()).finish(), Self::Domain(d) => f.debug_tuple("Domain").field(d).finish(), Self::ResourceType(types) => f.debug_tuple("ResourceType").field(types).finish(), @@ -34,10 +34,19 @@ impl std::fmt::Debug for InterceptorRule { } impl InterceptorRule { + /// Create a URL glob rule from a glob pattern. + pub fn url_glob(pattern: impl Into) -> Self { + let p = pattern.into(); + let regex_str = glob_to_regex(&p); + let re = regex::Regex::new(®ex_str) + .unwrap_or_else(|_| regex::Regex::new(®ex::escape(&p)).unwrap()); + Self::UrlGlob(p, re) + } + /// Check if this rule matches the given request context. pub fn matches(&self, ctx: &RequestContext) -> bool { match self { - Self::UrlGlob(pattern) => glob_matches(pattern, &ctx.url), + Self::UrlGlob(_, re) => re.is_match(&ctx.url), Self::UrlRegex(re) => re.is_match(&ctx.url), Self::Domain(domain) => domain_matches(domain, &ctx.url), Self::ResourceType(types) => types.contains(&ctx.resource_type), @@ -110,10 +119,12 @@ fn url_path(url: &str) -> String { #[cfg(test)] mod tests { - use super::*; use std::collections::HashMap; + use pardus_debug::Initiator; + use super::*; + fn ctx_with_url(url: &str) -> RequestContext { RequestContext { url: url.to_string(), @@ -142,7 +153,7 @@ mod tests { #[test] fn test_glob_star_matches_any() { - let rule = InterceptorRule::UrlGlob("*example.com*".to_string()); + let rule = InterceptorRule::url_glob("*example.com*"); assert!(rule.matches(&ctx_with_url("https://example.com/page"))); assert!(rule.matches(&ctx_with_url("https://sub.example.com/page"))); assert!(!rule.matches(&ctx_with_url("https://other.com/page"))); @@ -150,14 +161,14 @@ mod tests { #[test] fn test_glob_path_pattern() { - let rule = InterceptorRule::UrlGlob("*/api/*".to_string()); + let rule = InterceptorRule::url_glob("*/api/*"); assert!(rule.matches(&ctx_with_url("https://example.com/api/users"))); assert!(!rule.matches(&ctx_with_url("https://example.com/page"))); } #[test] fn test_glob_extension() { - let rule = InterceptorRule::UrlGlob("*.css".to_string()); + let rule = InterceptorRule::url_glob("*.css"); assert!(rule.matches(&ctx_with_url("https://example.com/styles/main.css"))); assert!(!rule.matches(&ctx_with_url("https://example.com/styles/main.js"))); } @@ -200,10 +211,8 @@ mod tests { #[test] fn test_resource_type_match() { - let rule = InterceptorRule::ResourceType(vec![ - ResourceType::Stylesheet, - ResourceType::Image, - ]); + let rule = + InterceptorRule::ResourceType(vec![ResourceType::Stylesheet, ResourceType::Image]); assert!(rule.matches(&ctx_with_resource_type(ResourceType::Stylesheet))); assert!(rule.matches(&ctx_with_resource_type(ResourceType::Image))); assert!(!rule.matches(&ctx_with_resource_type(ResourceType::Document))); diff --git a/crates/pardus-core/src/js/dom.rs b/crates/pardus-core/src/js/dom.rs index 1b67b8a..e431f66 100644 --- a/crates/pardus-core/src/js/dom.rs +++ b/crates/pardus-core/src/js/dom.rs @@ -4,6 +4,44 @@ use std::collections::{HashMap, HashSet}; /// Unique ID for a DOM node. pub type NodeId = u32; +// --------------------------------------------------------------------------- +// Structural mutations (for incremental semantic tree updates) +// --------------------------------------------------------------------------- + +/// A simplified mutation record for incremental semantic tree updates. +/// +/// Unlike `MutationRecord` (which targets JS `MutationObserver` delivery), +/// this always captures CSS selectors so the incremental update engine can +/// locate affected subtrees in the `SemanticTree`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StructuralMutation { + /// What kind of mutation occurred. + pub kind: StructuralMutationKind, + /// CSS selector of the target element at the time of mutation. + pub target_selector: Option, + /// CSS selector of the target's parent element. + pub parent_selector: Option, + /// Tag name of the target element. + pub target_tag: Option, + /// For childList mutations: CSS selectors of added elements. + pub added_selectors: Vec, + /// For childList mutations: CSS selectors of removed elements (captured before removal). + pub removed_selectors: Vec, + /// For attributes: the attribute name that changed. + pub attribute_name: Option, + /// The selector of the target BEFORE the mutation (for id/name changes that + /// alter the selector itself). + pub old_target_selector: Option, +} + +/// Kind of structural DOM mutation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum StructuralMutationKind { + ChildList, + Attributes, + CharacterData, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MutationRecord { pub type_: String, @@ -73,6 +111,10 @@ pub struct DomDocument { undo_stack: Vec, /// HTML snapshot stack for redo. redo_stack: Vec, + /// Unconditional structural mutations for incremental semantic tree updates. + /// Unlike `pending_mutations` (which are observer-scoped and conditional), + /// this log always records every DOM change regardless of observers. + structural_mutations: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -135,6 +177,7 @@ impl DomDocument { max_nodes: None, undo_stack: Vec::new(), redo_stack: Vec::new(), + structural_mutations: Vec::new(), }; // Create document root @@ -618,6 +661,13 @@ impl DomDocument { attribute_name: Option, old_value: Option, ) { + // Always record structural mutation (for incremental semantic tree updates). + // Must happen before the observer early-return and before any node removal. + self.record_structural_mutation( + type_, target, &added_nodes, &removed_nodes, attribute_name.clone(), None, + ); + + // Observer-delivery path (existing logic, gated by observers) if self.observers.is_empty() { return; } @@ -643,6 +693,156 @@ impl DomDocument { self.queue_mutation(type_, target, vec![], vec![], None, None); } + // ----------------------------------------------------------------------- + // Structural mutation tracking (for incremental semantic tree updates) + // ----------------------------------------------------------------------- + + /// Compute a unique CSS selector for a DOM node by walking its parent chain. + /// + /// Strategy mirrors `build_unique_selector` in `semantic/tree.rs`: + /// 1. `#id` if the element has a non-empty `id` attribute + /// 2. `tag[name="..."]` if unique among same-tag siblings + /// 3. Structural path: `body > div:nth-child(2) > form > input` + pub fn build_selector_for_node(&self, node_id: NodeId) -> Option { + let node = self.nodes.get(&node_id)?; + if node.node_type != DomNodeType::Element { + return None; + } + + // Prefer #id + if let Some(id) = node.attributes.get("id") { + if !id.is_empty() { + return Some(format!("#{}", css_escape_dom_id(id))); + } + } + + // Try tag[name="..."] if unique + if let Some(name) = node.attributes.get("name") { + let tag = node.tag_name.as_deref()?; + let candidate = format!(r#"{}[name="{}"]"#, tag, name); + if let Some(ids) = self.tag_index.get(tag) { + let matching: Vec = ids + .iter() + .filter(|&&id| { + self.nodes + .get(&id) + .and_then(|n| n.attributes.get("name")) + .map_or(false, |n| n == name) + }) + .copied() + .collect(); + if matching.len() == 1 { + return Some(candidate); + } + } + } + + // Build structural path + self.build_structural_path(node_id) + } + + fn build_structural_path(&self, node_id: NodeId) -> Option { + let mut segments = Vec::new(); + let mut current = Some(node_id); + + while let Some(nid) = current { + let node = self.nodes.get(&nid)?; + let tag = node.tag_name.as_deref()?; + + if tag == "body" || tag == "html" { + break; + } + + let nth = self.count_element_position_in_dom(nid); + segments.push(format!("{}:nth-child({})", tag, nth)); + + current = node.parent_id; + } + + segments.reverse(); + if segments.is_empty() { + None + } else { + Some(segments.join(" > ")) + } + } + + fn count_element_position_in_dom(&self, node_id: NodeId) -> usize { + let parent_id = match self.nodes.get(&node_id).and_then(|n| n.parent_id) { + Some(id) => id, + None => return 1, + }; + let parent = match self.nodes.get(&parent_id) { + Some(n) => n, + None => return 1, + }; + let mut count = 0; + for &child_id in &parent.children { + if let Some(child) = self.nodes.get(&child_id) { + if child.node_type == DomNodeType::Element { + count += 1; + } + } + if child_id == node_id { + return count; + } + } + count + } + + /// Record a structural mutation unconditionally (regardless of JS observers). + fn record_structural_mutation( + &mut self, + type_: &str, + target: NodeId, + added_nodes: &[NodeId], + removed_nodes: &[NodeId], + attribute_name: Option, + old_target_selector: Option, + ) { + let target_selector = self.build_selector_for_node(target); + let parent_selector = self + .nodes + .get(&target) + .and_then(|n| n.parent_id) + .and_then(|pid| self.build_selector_for_node(pid)); + let target_tag = self.nodes.get(&target).and_then(|n| n.tag_name.clone()); + + // Capture selectors for added nodes + let added_sels: Vec = added_nodes + .iter() + .filter_map(|&id| self.build_selector_for_node(id)) + .collect(); + + // Capture selectors for removed nodes (they still exist at this point + // because `queue_mutation` is called *before* `remove_recursive`) + let removed_sels: Vec = removed_nodes + .iter() + .filter_map(|&id| self.build_selector_for_node(id)) + .collect(); + + self.structural_mutations.push(StructuralMutation { + kind: match type_ { + "childList" => StructuralMutationKind::ChildList, + "attributes" => StructuralMutationKind::Attributes, + "characterData" => StructuralMutationKind::CharacterData, + _ => return, // Unknown type, skip + }, + target_selector, + parent_selector, + target_tag, + added_selectors: added_sels, + removed_selectors: removed_sels, + attribute_name, + old_target_selector, + }); + } + + /// Drain all recorded structural mutations, clearing the log. + pub fn drain_structural_mutations(&mut self) -> Vec { + std::mem::take(&mut self.structural_mutations) + } + /// Drain all pending mutations grouped by observer ID. pub fn drain_all_pending_mutations(&mut self) -> Vec<(u32, Vec)> { let mut result = Vec::new(); @@ -2734,4 +2934,307 @@ mod tests { assert!(doc.redo()); assert_eq!(doc.get_text_content(doc.get_element_by_id("target").unwrap()), "v3"); } + + // ==================== Shadow DOM Stub Tests ==================== + + #[test] + fn test_shadow_root_stubs_return_none() { + let html = "
content
"; + let doc = DomDocument::from_html(html); + let host = doc.get_element_by_id("host").unwrap(); + assert!(doc.get_shadow_root(host).is_none()); + } + + #[test] + fn test_is_shadow_host_stubs_return_false() { + let html = "
content
"; + let doc = DomDocument::from_html(html); + let host = doc.get_element_by_id("host").unwrap(); + assert!(!doc.is_shadow_host(host)); + } + + #[test] + fn test_query_selector_deep_alias_works() { + let html = "
text
"; + let doc = DomDocument::from_html(html); + let result = doc.query_selector_deep(0, "#inner"); + assert!(result.is_some()); + assert_eq!(doc.get_text_content(result.unwrap()), "text"); + } + + #[test] + fn test_query_selector_all_deep_alias_works() { + let html = "
  • a
  • b
"; + let doc = DomDocument::from_html(html); + let results = doc.query_selector_all_deep(0, ".item"); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_collect_all_elements_deep() { + let html = "
ab
"; + let doc = DomDocument::from_html(html); + let body = doc.body(); + let elements = doc.collect_all_elements_deep(body); + // body + div + 2 spans = 4 + assert!(elements.len() >= 3); + } + + // ==================== MutationObserver Tests ==================== + + #[test] + fn test_register_observer_returns_incrementing_ids() { + let mut doc = DomDocument::from_html("
"); + let a = doc.get_element_by_id("a").unwrap(); + let b = doc.get_element_by_id("b").unwrap(); + let id1 = doc.register_observer(a, MutationObserverInit::default()); + let id2 = doc.register_observer(b, MutationObserverInit::default()); + assert!(id1 < id2); + } + + #[test] + fn test_disconnect_observer_removes_observer() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + let obs_id = doc.register_observer(target, MutationObserverInit::default()); + assert!(doc.has_observers()); + doc.disconnect_observer(obs_id); + assert!(!doc.has_observers()); + } + + #[test] + fn test_disconnect_nonexistent_is_noop() { + let mut doc = DomDocument::from_html(""); + doc.disconnect_observer(999); + } + + #[test] + fn test_child_list_mutation_on_append() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + doc.register_observer(target, opts); + + let child = doc.create_element("span"); + doc.append_child(target, child); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_obs_id, mutations) = &records[0]; + assert_eq!(mutations.len(), 1); + assert_eq!(mutations[0].type_, "childList"); + assert_eq!(mutations[0].target, target); + assert!(mutations[0].added_nodes.contains(&child)); + assert!(mutations[0].removed_nodes.is_empty()); + } + + #[test] + fn test_child_list_mutation_on_remove() { + let mut doc = DomDocument::from_html("
x
"); + let target = doc.get_element_by_id("target").unwrap(); + let child = doc.get_element_by_id("child").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + doc.register_observer(target, opts); + + doc.remove_child(target, child); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + assert_eq!(mutations[0].removed_nodes.len(), 1); + assert!(mutations[0].added_nodes.is_empty()); + } + + #[test] + fn test_attribute_mutation_captures_old_value() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.attributes = true; + opts.attribute_old_value = true; + doc.register_observer(target, opts); + + doc.set_attribute(target, "class", "new"); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + assert_eq!(mutations[0].type_, "attributes"); + assert_eq!(mutations[0].attribute_name, Some("class".to_string())); + assert_eq!(mutations[0].old_value, Some("old".to_string())); + } + + #[test] + fn test_attribute_filter_only_matching() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.attributes = true; + opts.attribute_filter = vec!["class".to_string()]; + doc.register_observer(target, opts); + + // Change class — should be observed + doc.set_attribute(target, "class", "b"); + // Change data-x — should NOT be observed (not in filter) + doc.set_attribute(target, "data-x", "y"); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + // Only the class mutation should be delivered + assert_eq!(mutations.len(), 1); + assert_eq!(mutations[0].attribute_name, Some("class".to_string())); + } + + #[test] + fn test_character_data_mutation() { + let mut doc = DomDocument::from_html("
hello
"); + let target = doc.get_element_by_id("target").unwrap(); + let children = doc.get_children(target); + let text_id = children.into_iter().find(|&c| doc.get_node_type(c) == 3).unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.character_data = true; + opts.character_data_old_value = true; + doc.register_observer(target, opts); + + doc.set_node_value(text_id, "world"); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + assert_eq!(mutations[0].type_, "characterData"); + assert_eq!(mutations[0].old_value, Some("hello".to_string())); + } + + #[test] + fn test_subtree_observer_catches_child_mutations() { + let mut doc = DomDocument::from_html("
"); + let parent = doc.get_element_by_id("parent").unwrap(); + let child = doc.get_element_by_id("child").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + opts.subtree = true; + doc.register_observer(parent, opts); + + // Mutation on child should bubble to parent's observer + let grandchild = doc.create_element("span"); + doc.append_child(child, grandchild); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + assert_eq!(mutations[0].target, child); + } + + #[test] + fn test_no_subtree_observer_ignores_child_mutations() { + let mut doc = DomDocument::from_html("
"); + let parent = doc.get_element_by_id("parent").unwrap(); + let child = doc.get_element_by_id("child").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + opts.subtree = false; + doc.register_observer(parent, opts); + + let grandchild = doc.create_element("span"); + doc.append_child(child, grandchild); + + let records = doc.drain_all_pending_mutations(); + assert!(records.is_empty()); + } + + #[test] + fn test_multiple_observers_same_target() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + let obs1 = doc.register_observer(target, opts.clone()); + let obs2 = doc.register_observer(target, opts); + + let child = doc.create_element("span"); + doc.append_child(target, child); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 2); + let ids: Vec = records.iter().map(|(id, _)| *id).collect(); + assert!(ids.contains(&obs1)); + assert!(ids.contains(&obs2)); + } + + #[test] + fn test_take_mutation_records_returns_empty_after_drain() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + doc.register_observer(target, opts); + + let child = doc.create_element("span"); + doc.append_child(target, child); + + let first = doc.drain_all_pending_mutations(); + assert!(!first.is_empty()); + let second = doc.drain_all_pending_mutations(); + assert!(second.is_empty()); + } + + #[test] + fn test_no_observers_no_mutations_queued() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + let child = doc.create_element("span"); + doc.append_child(target, child); + let records = doc.drain_all_pending_mutations(); + assert!(records.is_empty()); + } + + #[test] + fn test_queue_simple_mutation() { + let mut doc = DomDocument::from_html("
"); + let target = doc.get_element_by_id("target").unwrap(); + + let mut opts = MutationObserverInit::default(); + opts.child_list = true; + doc.register_observer(target, opts); + + doc.queue_simple_mutation("childList", target); + + let records = doc.drain_all_pending_mutations(); + assert_eq!(records.len(), 1); + let (_, mutations) = &records[0]; + assert_eq!(mutations[0].type_, "childList"); + assert!(mutations[0].added_nodes.is_empty()); + assert!(mutations[0].removed_nodes.is_empty()); + } +} + +/// Escape a DOM id value for use in a CSS selector. +fn css_escape_dom_id(id: &str) -> String { + if id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + id.to_string() + } else { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_string() + } else { + format!("\\{:X}", c as u32) + } + }) + .collect() + } } diff --git a/crates/pardus-core/src/js/fetch.rs b/crates/pardus-core/src/js/fetch.rs index 2df3232..c237c99 100644 --- a/crates/pardus-core/src/js/fetch.rs +++ b/crates/pardus-core/src/js/fetch.rs @@ -503,4 +503,241 @@ mod tests { assert!(!is_url_safe("http://[fc00::1]/")); assert!(!is_url_safe("http://[fd00::1]/")); } + + // ==================== FetchCacheMode Tests ==================== + + #[test] + fn test_fetch_cache_mode_from_str() { + assert_eq!(FetchCacheMode::from_str("no-store"), FetchCacheMode::NoStore); + assert_eq!(FetchCacheMode::from_str("force-cache"), FetchCacheMode::ForceCache); + assert_eq!(FetchCacheMode::from_str("only-if-cached"), FetchCacheMode::OnlyIfCached); + assert_eq!(FetchCacheMode::from_str("default"), FetchCacheMode::Default); + assert_eq!(FetchCacheMode::from_str(""), FetchCacheMode::Default); + assert_eq!(FetchCacheMode::from_str("invalid"), FetchCacheMode::Default); + } + + #[test] + fn test_fetch_cache_mode_equality() { + assert_eq!(FetchCacheMode::Default, FetchCacheMode::Default); + assert_ne!(FetchCacheMode::NoStore, FetchCacheMode::ForceCache); + assert_ne!(FetchCacheMode::Default, FetchCacheMode::OnlyIfCached); + } + + // ==================== FetchArgs Deserialization Tests ==================== + + #[test] + fn test_fetch_args_defaults() { + let json = r#"{"url":"https://example.com","method":""}"#; + let args: FetchArgs = serde_json::from_str(json).unwrap(); + assert_eq!(args.url, "https://example.com"); + assert!(args.headers.is_empty()); + assert!(args.body.is_none()); + assert!(args.cache.is_none()); + } + + #[test] + fn test_fetch_args_full() { + let json = r#"{"url":"https://api.example.com/data","method":"POST","headers":{"content-type":"application/json"},"body":"{\"key\":\"value\"}","cache":"no-store"}"#; + let args: FetchArgs = serde_json::from_str(json).unwrap(); + assert_eq!(args.url, "https://api.example.com/data"); + assert_eq!(args.method, "POST"); + assert_eq!(args.headers.get("content-type").unwrap(), "application/json"); + assert_eq!(args.body, Some("{\"key\":\"value\"}".to_string())); + assert_eq!(args.cache, Some("no-store".to_string())); + } + + #[test] + fn test_fetch_args_empty_body() { + let json = r#"{"url":"https://example.com","method":"GET","body":null}"#; + let args: FetchArgs = serde_json::from_str(json).unwrap(); + assert!(args.body.is_none()); + } + + // ==================== FetchResult Serialization Tests ==================== + + #[test] + fn test_fetch_result_serialization() { + let result = FetchResult { + ok: true, + status: 200, + status_text: "OK".to_string(), + headers: { + let mut h = HashMap::new(); + h.insert("content-type".to_string(), "text/html".to_string()); + h + }, + body: "Hello".to_string(), + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("\"status\":200")); + assert!(json.contains("\"body\":\"Hello\"")); + } + + #[test] + fn test_fetch_result_error() { + let result = FetchResult { + ok: false, + status: 403, + status_text: "Forbidden".to_string(), + headers: HashMap::new(), + body: String::new(), + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"ok\":false")); + assert!(json.contains("\"status\":403")); + } + + #[test] + fn test_fetch_result_network_error() { + let result = FetchResult { + ok: false, + status: 0, + status_text: "Network Error".to_string(), + headers: HashMap::new(), + body: String::new(), + }; + assert_eq!(result.status, 0); + assert!(!result.ok); + } + + // ==================== FetchRequest Tests ==================== + + #[test] + fn test_fetch_request_default_method() { + assert_eq!(default_method(), "GET"); + } + + #[test] + fn test_fetch_request_deserialization() { + let json = r#"{"url":"https://example.com"}"#; + let req: FetchRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.url, "https://example.com"); + assert_eq!(req.method, "GET"); + assert!(req.headers.is_empty()); + assert!(req.body.is_none()); + } + + #[test] + fn test_fetch_request_with_method() { + let json = r#"{"url":"https://example.com","method":"POST","body":"data"}"#; + let req: FetchRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.method, "POST"); + assert_eq!(req.body, Some("data".to_string())); + } + + // ==================== FetchResponse Tests ==================== + + #[test] + fn test_fetch_response_serialization_roundtrip() { + let resp = FetchResponse { + status: 200, + status_text: "OK".to_string(), + headers: { + let mut h = HashMap::new(); + h.insert("content-type".to_string(), "text/plain".to_string()); + h + }, + body: "Hello World".to_string(), + ok: true, + }; + let json = serde_json::to_string(&resp).unwrap(); + let back: FetchResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(back.status, 200); + assert_eq!(back.body, "Hello World"); + assert!(back.ok); + } + + // ==================== FetchPolicy Tests ==================== + + #[test] + fn test_fetch_policy_default() { + let policy = FetchPolicy { blocked: false }; + assert!(!policy.blocked); + } + + #[test] + fn test_fetch_policy_blocked() { + let policy = FetchPolicy { blocked: true }; + assert!(policy.blocked); + } + + // ==================== build_request Method Tests ==================== + + #[test] + fn test_build_request_get() { + let client = rquest::Client::new(); + let req = build_request(&client, "GET", "https://example.com", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "GET"); + } + + #[test] + fn test_build_request_post() { + let client = rquest::Client::new(); + let req = build_request(&client, "POST", "https://example.com", &HashMap::new(), &Some("body".to_string())); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "POST"); + } + + #[test] + fn test_build_request_put() { + let client = rquest::Client::new(); + let req = build_request(&client, "PUT", "https://example.com", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "PUT"); + } + + #[test] + fn test_build_request_delete() { + let client = rquest::Client::new(); + let req = build_request(&client, "DELETE", "https://example.com/resource", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "DELETE"); + } + + #[test] + fn test_build_request_patch() { + let client = rquest::Client::new(); + let req = build_request(&client, "PATCH", "https://example.com", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "PATCH"); + } + + #[test] + fn test_build_request_head() { + let client = rquest::Client::new(); + let req = build_request(&client, "HEAD", "https://example.com", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "HEAD"); + } + + #[test] + fn test_build_request_unknown_defaults_to_get() { + let client = rquest::Client::new(); + let req = build_request(&client, "OPTIONS", "https://example.com", &HashMap::new(), &None); + let built = req.build().unwrap(); + assert_eq!(built.method().as_str(), "GET"); + } + + #[test] + fn test_build_request_with_headers() { + let client = rquest::Client::new(); + let mut headers = HashMap::new(); + headers.insert("accept".to_string(), "application/json".to_string()); + headers.insert("x-custom".to_string(), "value".to_string()); + let req = build_request(&client, "GET", "https://example.com", &headers, &None); + let built = req.build().unwrap(); + assert_eq!(built.headers().get("accept").unwrap(), "application/json"); + assert_eq!(built.headers().get("x-custom").unwrap(), "value"); + } + + // ==================== extract_response_headers Tests ==================== + + // Note: These would need a real HTTP response. Test the types instead. + + #[test] + fn test_op_fetch_max_body_size() { + assert_eq!(OP_FETCH_MAX_BODY_SIZE, 1_048_576); + } } diff --git a/crates/pardus-core/src/js/runtime.rs b/crates/pardus-core/src/js/runtime.rs index e48dcca..4c077ec 100644 --- a/crates/pardus-core/src/js/runtime.rs +++ b/crates/pardus-core/src/js/runtime.rs @@ -3,24 +3,28 @@ //! Uses deno_core (V8) to execute JavaScript with thread-based timeouts. //! Provides a minimal `document` and `window` shim via ops that interact with the DOM. -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::Duration; +use std::{ + cell::RefCell, + collections::HashMap, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; use deno_core::*; use parking_lot::{Condvar, Mutex}; use scraper::{Html, Selector}; use url::Url; -use super::dom::DomDocument; -use super::extension::pardus_dom; -use super::snapshot::get_bootstrap_snapshot; -use crate::sandbox::{JsSandboxMode, SandboxPolicy}; -use crate::session::SessionStore; +use super::{dom::DomDocument, extension::pardus_dom, snapshot::get_bootstrap_snapshot}; +use crate::{ + sandbox::{JsSandboxMode, SandboxPolicy}, + session::SessionStore, +}; /// Per-execution in-memory sessionStorage (not persisted to disk). pub type SessionStorageMap = HashMap>; @@ -168,7 +172,10 @@ async fn fetch_external_scripts( ) -> Vec { let client = match rquest::Client::builder() .timeout(std::time::Duration::from_millis(timeout_ms)) - .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + .user_agent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like \ + Gecko) Chrome/131.0.0.0 Safari/537.36", + ) .build() { Ok(c) => c, @@ -184,7 +191,7 @@ async fn fetch_external_scripts( Ok(response) => { let status = response.status().as_u16(); if !(200..300).contains(&status) { - eprintln!("[JS] External script {} returned HTTP {}", url, status); + tracing::warn!("[JS] External script {} returned HTTP {}", url, status); continue; } if let Some(len) = response @@ -194,7 +201,7 @@ async fn fetch_external_scripts( .and_then(|s| s.parse::().ok()) { if len > max_size { - eprintln!("[JS] External script too large: {} bytes", len); + tracing::warn!("[JS] External script too large: {} bytes", len); continue; } } @@ -205,7 +212,12 @@ async fn fetch_external_scripts( && !is_analytics_script(&code) && !is_problematic_script(&code) { - eprintln!("[JS] Fetched external script {}: {} ({} bytes)", i, url, code.len()); + tracing::debug!( + "[JS] Fetched external script {}: {} ({} bytes)", + i, + url, + code.len() + ); results.push(ScriptInfo { name: format!("external_script_{}.js", i), code, @@ -213,12 +225,12 @@ async fn fetch_external_scripts( } } Err(e) => { - eprintln!("[JS] Failed to read external script {}: {}", url, e); + tracing::warn!("[JS] Failed to read external script {}: {}", url, e); } } } Err(e) => { - eprintln!("[JS] Failed to fetch external script {}: {}", url, e); + tracing::warn!("[JS] Failed to fetch external script {}: {}", url, e); } } } @@ -241,7 +253,10 @@ fn transform_module_syntax(code: &str) -> String { for line in code.lines() { let trimmed = line.trim(); - if trimmed.starts_with("import ") || trimmed.starts_with("import{") || trimmed.starts_with("import(") { + if trimmed.starts_with("import ") + || trimmed.starts_with("import{") + || trimmed.starts_with("import(") + { continue; } @@ -315,15 +330,21 @@ fn create_runtime( runtime.op_state().borrow_mut().put(dom); // Store timer queue in op state - runtime.op_state().borrow_mut().put(super::timer::TimerQueue::new()); + runtime + .op_state() + .borrow_mut() + .put(super::timer::TimerQueue::new()); // Store sandbox policy in op state so ops can check restrictions runtime.op_state().borrow_mut().put(sandbox.clone()); // Store per-runtime fetch policy - runtime.op_state().borrow_mut().put(super::fetch::FetchPolicy { - blocked: sandbox.block_js_fetch, - }); + runtime + .op_state() + .borrow_mut() + .put(super::fetch::FetchPolicy { + blocked: sandbox.block_js_fetch, + }); // Store session store for cookie/localStorage ops if let Some(session_store) = session { @@ -331,7 +352,10 @@ fn create_runtime( } // Store per-execution in-memory sessionStorage - runtime.op_state().borrow_mut().put(SessionStorageMap::new()); + runtime + .op_state() + .borrow_mut() + .put(SessionStorageMap::new()); // Set up window.location and user agent from base_url. // Use individual property assignments (not `window.location = {...}`) @@ -391,7 +415,10 @@ fn create_runtime_snapshot( runtime.op_state().borrow_mut().put(dom); // Store timer queue in op state - runtime.op_state().borrow_mut().put(super::timer::TimerQueue::new()); + runtime + .op_state() + .borrow_mut() + .put(super::timer::TimerQueue::new()); // Store sandbox policy in op state so ops can check restrictions runtime.op_state().borrow_mut().put(sandbox.clone()); @@ -402,7 +429,10 @@ fn create_runtime_snapshot( } // Store per-execution in-memory sessionStorage - runtime.op_state().borrow_mut().put(SessionStorageMap::new()); + runtime + .op_state() + .borrow_mut() + .put(SessionStorageMap::new()); // Set up window.location and user agent from base_url. // Use individual property assignments (not `window.location = {...}`) @@ -448,6 +478,7 @@ fn create_runtime_snapshot( /// Result of script execution in a thread. struct ThreadResult { dom_html: Option, + mutations: Vec, #[allow(dead_code)] error: Option, } @@ -460,9 +491,7 @@ struct ThreadDoneGuard { } impl Drop for ThreadDoneGuard { - fn drop(&mut self) { - self.cvar.notify_one(); - } + fn drop(&mut self) { self.cvar.notify_one(); } } /// Execute scripts in a separate thread with timeout, graceful termination, and no leaks. @@ -474,9 +503,10 @@ fn execute_scripts_with_timeout( sandbox: SandboxPolicy, user_agent: String, session: Option>, -) -> Option { +) -> Option<(String, Vec)> { let lock = Arc::new(Mutex::new(ThreadResult { dom_html: None, + mutations: Vec::new(), error: None, })); let cvar = Arc::new(Condvar::new()); @@ -497,6 +527,7 @@ fn execute_scripts_with_timeout( Err(e) => { *lock.lock() = ThreadResult { dom_html: None, + mutations: Vec::new(), error: Some(format!("Invalid base URL: {}", e)), }; return; @@ -514,16 +545,18 @@ fn execute_scripts_with_timeout( let dom = Rc::new(RefCell::new(doc)); // Create runtime (pass sandbox policy, use snapshot if available) - let (mut runtime, bootstrapped) = match create_runtime_snapshot(dom.clone(), &base, &sandbox, &user_agent, session) { - Ok(r) => r, - Err(e) => { - *lock.lock() = ThreadResult { - dom_html: None, - error: Some(format!("Failed to create runtime: {}", e)), - }; - return; - } - }; + let (mut runtime, bootstrapped) = + match create_runtime_snapshot(dom.clone(), &base, &sandbox, &user_agent, session) { + Ok(r) => r, + Err(e) => { + *lock.lock() = ThreadResult { + dom_html: None, + mutations: Vec::new(), + error: Some(format!("Failed to create runtime: {}", e)), + }; + return; + } + }; // Execute bootstrap.js only if not already loaded from snapshot if !bootstrapped { @@ -534,6 +567,7 @@ fn execute_scripts_with_timeout( if let Err(e) = runtime.execute_script("bootstrap.js", bootstrap) { *lock.lock() = ThreadResult { dom_html: None, + mutations: Vec::new(), error: Some(format!("Bootstrap error: {}", e)), }; return; @@ -551,7 +585,7 @@ fn execute_scripts_with_timeout( } if let Err(e) = runtime.execute_script(script.name.clone(), script.code) { // Log error but continue with next script - eprintln!("[JS] Script {} error: {}", script.name, e); + tracing::warn!("[JS] Script {} error: {}", script.name, e); } } @@ -560,17 +594,21 @@ fn execute_scripts_with_timeout( } // Fire DOMContentLoaded event after all scripts - let _ = runtime.execute_script("dom_content_loaded.js", r#" + let _ = runtime.execute_script( + "dom_content_loaded.js", + r#" (function() { if (typeof _fireDOMContentLoaded === 'function') _fireDOMContentLoaded(); var event = new Event('DOMContentLoaded', { bubbles: true, cancelable: false }); document.dispatchEvent(event); })(); -"#); +"#, + ); // Flush pending mutation observer callbacks after DOMContentLoaded - let _ = runtime.execute_script("mutation_flush_dcl.js", - "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();" + let _ = runtime.execute_script( + "mutation_flush_dcl.js", + "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();", ); // Run event loop with bounded timeout (not infinite) @@ -607,8 +645,9 @@ fn execute_scripts_with_timeout( } // Flush pending mutation observer callbacks after each event loop poll - let _ = runtime.execute_script("mutation_flush_evloop.js", - "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();" + let _ = runtime.execute_script( + "mutation_flush_evloop.js", + "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();", ); } @@ -624,7 +663,9 @@ fn execute_scripts_with_timeout( let _ = runtime.execute_script("timers.js", timer_js); let op_state_mut = runtime.op_state(); let mut state_mut = op_state_mut.borrow_mut(); - if let Some(queue_mut) = state_mut.try_borrow_mut::() { + if let Some(queue_mut) = + state_mut.try_borrow_mut::() + { queue_mut.mark_delay_zero_fired(); } } @@ -633,14 +674,19 @@ fn execute_scripts_with_timeout( } // Flush pending mutation observer callbacks after timer drainage - let _ = runtime.execute_script("mutation_flush_timers.js", - "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();" + let _ = runtime.execute_script( + "mutation_flush_timers.js", + "if (typeof _deliverPendingMutations === 'function') _deliverPendingMutations();", ); + // Drain structural mutations before serializing DOM + let mutations = dom.borrow_mut().drain_structural_mutations(); + // Serialize DOM back to HTML let output = dom.borrow().to_html(); *lock.lock() = ThreadResult { dom_html: Some(output), + mutations, error: None, }; }); @@ -650,23 +696,33 @@ fn execute_scripts_with_timeout( let wait_result = cvar_caller.wait_for(&mut guard, Duration::from_millis(timeout_ms)); if guard.dom_html.is_some() { - return guard.dom_html.clone(); + let html = guard.dom_html.clone(); + let mutations = std::mem::take(&mut guard.mutations); + return html.map(|h| (h, mutations)); } if wait_result.timed_out() { // Signal termination and wait grace period terminated.store(true, Ordering::SeqCst); - eprintln!("[JS] Execution timed out after {}ms, waiting for thread to finish...", timeout_ms); + tracing::warn!( + "[JS] Execution timed out after {}ms, waiting for thread to finish...", + timeout_ms + ); - let grace_result = cvar_caller.wait_for(&mut guard, Duration::from_millis(THREAD_JOIN_GRACE_MS)); + let grace_result = + cvar_caller.wait_for(&mut guard, Duration::from_millis(THREAD_JOIN_GRACE_MS)); if grace_result.timed_out() { - eprintln!("[JS] Thread did not finish within grace period, returning original HTML"); + tracing::warn!( + "[JS] Thread did not finish within grace period, returning original HTML" + ); return None; } } - guard.dom_html.clone() + let html = guard.dom_html.clone(); + let mutations = std::mem::take(&mut guard.mutations); + html.map(|h| (h, mutations)) } // ==================== CDP Evaluate Result Types ==================== @@ -704,13 +760,15 @@ pub fn evaluate_js_expression( // 2. Create a V8 context with DOM shims // 3. Execute the expression // 4. Serialize the result - + // For now, return a stub that indicates the expression was received // This allows CDP clients to connect without crashing EvaluateResult { r#type: "undefined".to_string(), value: "null".to_string(), - description: Some("JS evaluation stub - full implementation requires V8 context".to_string()), + description: Some( + "JS evaluation stub - full implementation requires V8 context".to_string(), + ), subtype: None, exception_details: None, } @@ -732,18 +790,18 @@ pub async fn execute_js( sandbox: Option<&SandboxPolicy>, user_agent: &str, session: Option>, -) -> anyhow::Result { +) -> anyhow::Result<(String, Vec)> { let sandbox = sandbox.cloned().unwrap_or_default(); // If JS is disabled by sandbox, return original HTML immediately if sandbox.js_mode == JsSandboxMode::Disabled { - return Ok(html.to_string()); + return Ok((html.to_string(), Vec::new())); } // Parse base URL let base = match Url::parse(base_url) { Ok(u) => u, - Err(_) => return Ok(html.to_string()), + Err(_) => return Ok((html.to_string(), Vec::new())), }; // Extract scripts from HTML (inline + external) @@ -752,7 +810,12 @@ pub async fn execute_js( // Fetch external scripts asynchronously const MAX_EXTERNAL_SCRIPT_SIZE: usize = 200_000; const EXTERNAL_FETCH_TIMEOUT_MS: u64 = 5_000; - let external = fetch_external_scripts(external_urls, MAX_EXTERNAL_SCRIPT_SIZE, EXTERNAL_FETCH_TIMEOUT_MS).await; + let external = fetch_external_scripts( + external_urls, + MAX_EXTERNAL_SCRIPT_SIZE, + EXTERNAL_FETCH_TIMEOUT_MS, + ) + .await; scripts.extend(external); // Apply sandbox-configurable script limits @@ -765,10 +828,10 @@ pub async fn execute_js( // If no scripts, return original HTML if scripts.is_empty() { - return Ok(html.to_string()); + return Ok((html.to_string(), Vec::new())); } - eprintln!( + tracing::debug!( "[JS] Found {} inline script(s) to execute for {}", scripts.len(), base.as_str() @@ -797,10 +860,10 @@ pub async fn execute_js( ); match result { - Some(modified_html) => Ok(modified_html), + Some((modified_html, mutations)) => Ok((modified_html, mutations)), None => { // Timeout or error - return original HTML - Ok(html.to_string()) + Ok((html.to_string(), Vec::new())) } } } @@ -815,7 +878,8 @@ mod tests { #[test] fn test_extract_scripts_empty_html() { - let (scripts, urls) = extract_scripts("", &Url::parse("https://example.com").unwrap()); + let (scripts, urls) = + extract_scripts("", &Url::parse("https://example.com").unwrap()); assert!(scripts.is_empty()); assert!(urls.is_empty()); } @@ -823,7 +887,7 @@ mod tests { #[test] fn test_extract_scripts_no_scripts() { let html = r#"

Hello

"#; - let (scripts, urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); + let (scripts, _urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); assert!(scripts.is_empty()); assert!(urls.is_empty()); } @@ -862,7 +926,7 @@ mod tests { "#; - let (scripts, urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); + let (scripts, _urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); assert_eq!(scripts.len(), 1); assert!(scripts[0].code.contains("inline code")); assert_eq!(urls.len(), 1); // external URL collected but not fetched @@ -919,7 +983,8 @@ export function hello() {} } scripts_html.push_str(""); - let (scripts, _urls) = extract_scripts(&scripts_html, &Url::parse("https://example.com").unwrap()); + let (scripts, _urls) = + extract_scripts(&scripts_html, &Url::parse("https://example.com").unwrap()); assert_eq!(scripts.len(), MAX_SCRIPTS); } @@ -954,7 +1019,9 @@ export function hello() {} fn test_is_analytics_script_not_analytics() { assert!(!is_analytics_script("function doSomething() { return 1; }")); assert!(!is_analytics_script("const app = { name: 'MyApp' };")); - assert!(!is_analytics_script("document.querySelector('.btn').click();")); + assert!(!is_analytics_script( + "document.querySelector('.btn').click();" + )); } #[test] @@ -983,14 +1050,19 @@ export function hello() {} #[tokio::test] async fn test_execute_js_no_scripts() { let html = "

Hello

"; - let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); + let (result, _mutations) = + execute_js(html, "https://example.com", 100, None, "test-ua", None) + .await + .unwrap(); assert_eq!(result, html); } #[tokio::test] async fn test_execute_js_invalid_url() { let html = "

Hello

"; - let result = execute_js(html, "not-a-url", 100, None, "test-ua", None).await.unwrap(); + let (result, _mutations) = execute_js(html, "not-a-url", 100, None, "test-ua", None) + .await + .unwrap(); assert_eq!(result, html); } @@ -1001,7 +1073,10 @@ export function hello() {} "#; - let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); + let (result, _mutations) = + execute_js(html, "https://example.com", 100, None, "test-ua", None) + .await + .unwrap(); assert!(result.contains("")); } @@ -1020,16 +1095,22 @@ export function hello() {} #[test] fn test_is_problematic_script_not_flagged() { // These are standard web APIs — they should NOT be flagged - assert!(!is_problematic_script("element.addEventListener('click', handler)")); + assert!(!is_problematic_script( + "element.addEventListener('click', handler)" + )); assert!(!is_problematic_script("setInterval(function() {}, 100)")); assert!(!is_problematic_script("requestAnimationFrame(render)")); - assert!(!is_problematic_script("new MutationObserver(function() {})")); + assert!(!is_problematic_script( + "new MutationObserver(function() {})" + )); } #[test] fn test_is_problematic_script_destructive() { // These ARE destructive and should be flagged - assert!(is_problematic_script("document.write('overwrites everything')")); + assert!(is_problematic_script( + "document.write('overwrites everything')" + )); assert!(is_problematic_script("document.writeln('content')")); } @@ -1047,7 +1128,9 @@ export function hello() {} #[test] fn test_is_problematic_script_safe_code() { // These should NOT be flagged as problematic - assert!(!is_problematic_script("function add(a, b) { return a + b; }")); + assert!(!is_problematic_script( + "function add(a, b) { return a + b; }" + )); assert!(!is_problematic_script("const x = 1;")); assert!(!is_problematic_script("document.body.innerHTML = 'Hello';")); } @@ -1060,7 +1143,222 @@ export function hello() {} "#; - let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); + let (result, _mutations) = + execute_js(html, "https://example.com", 100, None, "test-ua", None) + .await + .unwrap(); assert!(result.contains("Safe")); } + + // ==================== Module Transform Tests ==================== + + #[test] + fn test_transform_module_removes_import_default() { + let code = "import foo from './bar.js';\nconsole.log(foo);"; + let result = transform_module_syntax(code); + assert!(!result.contains("import ")); + assert!(result.contains("console.log(foo);")); + } + + #[test] + fn test_transform_module_removes_named_import() { + let code = "import { useState, useEffect } from 'react';\nconst [x, setX] = useState(0);"; + let result = transform_module_syntax(code); + assert!(!result.contains("import ")); + assert!(result.contains("useState(0)")); + } + + #[test] + fn test_transform_module_removes_side_effect_import() { + let code = "import './polyfill.js';\nconsole.log('done');"; + let result = transform_module_syntax(code); + assert!(!result.contains("import ")); + assert!(result.contains("console.log('done')")); + } + + #[test] + fn test_transform_module_dynamic_import_preserved() { + let code = "const mod = import('./module.js');"; + let result = transform_module_syntax(code); + // Dynamic import() is a function call, not a statement import + assert!(result.contains("import('./module.js')")); + } + + #[test] + fn test_transform_module_export_default() { + let code = "export default function App() { return 1; }"; + let result = transform_module_syntax(code); + assert!(!result.contains("export")); + assert!(result.contains("function App()")); + } + + #[test] + fn test_transform_module_export_const() { + let code = "export const VERSION = '1.0';"; + let result = transform_module_syntax(code); + assert!(result.contains("const VERSION = '1.0';")); + assert!(!result.contains("export")); + } + + #[test] + fn test_transform_module_export_function() { + let code = "export function hello() { return 'world'; }"; + let result = transform_module_syntax(code); + assert!(result.contains("function hello()")); + assert!(!result.contains("export")); + } + + #[test] + fn test_transform_module_export_class() { + let code = "export class MyComponent { render() {} }"; + let result = transform_module_syntax(code); + assert!(result.contains("class MyComponent")); + assert!(!result.contains("export")); + } + + #[test] + fn test_transform_module_export_let_var() { + let code = "export let count = 0;\nexport var name = 'test';"; + let result = transform_module_syntax(code); + assert!(result.contains("let count = 0;")); + assert!(result.contains("var name = 'test';")); + assert!(!result.contains("export")); + } + + #[test] + fn test_transform_module_export_list() { + let code = "export { foo, bar };"; + let result = transform_module_syntax(code); + assert!(result.contains("{ foo, bar }")); + assert!(!result.contains("export")); + } + + #[test] + fn test_transform_module_preserves_plain_code() { + let code = "const x = 1;\nfunction add(a, b) { return a + b; }"; + let result = transform_module_syntax(code); + assert_eq!(result.trim(), code); + } + + #[test] + fn test_transform_module_empty_input() { + let result = transform_module_syntax(""); + assert!(result.is_empty()); + } + + #[test] + fn test_transform_module_mixed() { + let code = r#"import { h } from 'preact'; +import styles from './styles.css'; +const App = () => h('div', null, 'Hello'); +export default App; +export const NAME = 'App';"#; + let result = transform_module_syntax(code); + assert!(!result.contains("import ")); + assert!(!result.contains("export ")); + assert!(result.contains("const App")); + assert!(result.contains("const NAME")); + } + + // ==================== Script Filtering Edge Cases ==================== + + #[test] + fn test_analytics_not_triggered_by_partial_match() { + // "post" should not match "posthog" + assert!(!is_analytics_script("const post = getPost();")); + } + + #[test] + fn test_analytics_hotjar_variations() { + assert!(is_analytics_script("hj('trigger', 'my-trigger');")); + assert!(is_analytics_script("hotjar.identify({ id: 123 });")); + } + + #[test] + fn test_analytics_posthog() { + assert!(is_analytics_script( + "posthog.init('phc_xxx', { api_host: 'https://app.posthog.com' });" + )); + assert!(is_analytics_script("posthog.capture('event');")); + } + + #[test] + fn test_problematic_new_function() { + assert!(is_problematic_script("new Function('return this')()")); + } + + #[test] + fn test_problematic_document_write() { + assert!(is_problematic_script( + "document.write('

overwrites

')" + )); + assert!(is_problematic_script("document.writeln('text')")); + } + + #[test] + fn test_not_problematic_function_constructor() { + // "new function(" (lowercase f) is the pattern, not "new Function(" + // But since we lowercase first, this should match + // Actually "new function(" with lowercase is a named function expression + // This is a legitimate pattern and should NOT be flagged + assert!(!is_problematic_script("const obj = new MyClass();")); + } + + #[test] + fn test_extract_scripts_module_type() { + let html = r#""#; + let (scripts, _urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); + assert_eq!(scripts.len(), 1); + assert!(!scripts[0].code.contains("import ")); + assert!(!scripts[0].code.contains("export ")); + assert!(scripts[0].code.contains("const z = x + 1;")); + } + + #[test] + fn test_extract_scripts_external_url_resolution() { + let html = r#""#; + let (_, urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0], "https://example.com/js/app.js"); + } + + #[test] + fn test_extract_scripts_external_relative_url() { + let html = r#""#; + let (_, urls) = extract_scripts(html, &Url::parse("https://example.com/page/").unwrap()); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0], "https://example.com/page/bundle.js"); + } + + #[test] + fn test_extract_scripts_max_external_limit() { + let mut html = String::from(""); + for i in 0..10 { + html.push_str(&format!("", i)); + } + html.push_str(""); + let (_, urls) = extract_scripts(&html, &Url::parse("https://example.com").unwrap()); + assert!(urls.len() <= 5); + } + + #[test] + fn test_extract_scripts_inline_before_external() { + let html = r#" + + + + "#; + let (scripts, _urls) = extract_scripts(html, &Url::parse("https://example.com").unwrap()); + assert_eq!(scripts.len(), 2); + assert_eq!(urls.len(), 1); + // Inline scripts should be in order + assert!(scripts[0].code.contains("var a = 1")); + assert!(scripts[1].code.contains("var b = 2")); + } + + // ==================== execute_js Integration ==================== + // NOTE: V8-based integration tests run individually but crash when batched + // due to deno_core's V8 platform not being safe for multi-init in a + // single test process. Run these with: cargo test -p pardus-core --features js -- } diff --git a/crates/pardus-core/src/js/snapshot.rs b/crates/pardus-core/src/js/snapshot.rs index a901714..78a1e9d 100644 --- a/crates/pardus-core/src/js/snapshot.rs +++ b/crates/pardus-core/src/js/snapshot.rs @@ -8,9 +8,8 @@ use std::sync::OnceLock; use deno_core::RuntimeOptions; -use crate::sandbox::JsSandboxMode; - use super::extension::pardus_dom; +use crate::sandbox::JsSandboxMode; fn create_bootstrap_snapshot(bootstrap_code: &'static str) -> &'static [u8] { let mut runtime = deno_core::JsRuntimeForSnapshot::new(RuntimeOptions { @@ -18,12 +17,12 @@ fn create_bootstrap_snapshot(bootstrap_code: &'static str) -> &'static [u8] { ..Default::default() }); if let Err(e) = runtime.execute_script("bootstrap.js", bootstrap_code) { - eprintln!("[JS] Bootstrap snapshot creation failed: {e}"); + tracing::warn!("[JS] Bootstrap snapshot creation failed: {e}"); // Fall back: return an empty slice — runtime will bootstrap normally return &[]; } let snapshot = runtime.snapshot(); - eprintln!("[JS] Bootstrap snapshot created ({} bytes)", snapshot.len()); + tracing::debug!("[JS] Bootstrap snapshot created ({} bytes)", snapshot.len()); Box::leak(snapshot) } @@ -39,9 +38,5 @@ pub fn get_bootstrap_snapshot(mode: &JsSandboxMode) -> Option<&'static [u8]> { _ => FULL_SNAPSHOT.get_or_init(|| create_bootstrap_snapshot(include_str!("bootstrap.js"))), }; // Empty slice means snapshot creation failed — signal caller to bootstrap normally - if bytes.is_empty() { - None - } else { - Some(bytes) - } + if bytes.is_empty() { None } else { Some(bytes) } } diff --git a/crates/pardus-core/src/js/timer.rs b/crates/pardus-core/src/js/timer.rs index eaebc52..7897b08 100644 --- a/crates/pardus-core/src/js/timer.rs +++ b/crates/pardus-core/src/js/timer.rs @@ -94,3 +94,191 @@ impl TimerQueue { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_timeout_returns_incrementing_ids() { + let mut queue = TimerQueue::new(); + let id1 = queue.set_timeout(Some("a".into()), 0); + let id2 = queue.set_timeout(Some("b".into()), 100); + let id3 = queue.set_timeout(Some("c".into()), 0); + assert!(id1 < id2); + assert!(id2 < id3); + } + + #[test] + fn test_set_interval_returns_incrementing_ids() { + let mut queue = TimerQueue::new(); + let id1 = queue.set_interval(Some("a".into()), 100); + let id2 = queue.set_interval(Some("b".into()), 200); + assert!(id1 < id2); + } + + #[test] + fn test_set_timeout_creates_non_interval() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("cb".into()), 0); + assert_eq!(queue.timers.len(), 1); + assert!(!queue.timers[0].is_interval); + assert_eq!(queue.timers[0].delay_ms, 0); + } + + #[test] + fn test_set_interval_creates_interval() { + let mut queue = TimerQueue::new(); + queue.set_interval(Some("cb".into()), 500); + assert_eq!(queue.timers.len(), 1); + assert!(queue.timers[0].is_interval); + assert_eq!(queue.timers[0].delay_ms, 500); + } + + #[test] + fn test_clear_timer_removes_timer() { + let mut queue = TimerQueue::new(); + let id = queue.set_timeout(Some("cb".into()), 0); + assert_eq!(queue.timers.len(), 1); + queue.clear_timer(id); + assert!(queue.timers.is_empty()); + } + + #[test] + fn test_clear_timer_nonexistent_is_noop() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("cb".into()), 0); + queue.clear_timer(999); + assert_eq!(queue.timers.len(), 1); + } + + #[test] + fn test_clear_timer_by_id_not_position() { + let mut queue = TimerQueue::new(); + let id1 = queue.set_timeout(Some("a".into()), 0); + let _id2 = queue.set_timeout(Some("b".into()), 0); + let id3 = queue.set_timeout(Some("c".into()), 0); + queue.clear_timer(id1); + assert_eq!(queue.timers.len(), 2); + assert_eq!(queue.timers[0].callback_str, Some("b".into())); + assert_eq!(queue.timers[1].callback_str, Some("c".into())); + // id3 still works + queue.clear_timer(id3); + assert_eq!(queue.timers.len(), 1); + } + + #[test] + fn test_get_expired_timer_callbacks_js_empty() { + let queue = TimerQueue::new(); + assert_eq!(queue.get_expired_timer_callbacks_js(), ""); + } + + #[test] + fn test_get_expired_timer_callbacks_js_delay_zero() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("console.log('hi')".into()), 0); + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.contains("console.log('hi')")); + assert!(js.starts_with("try {")); + } + + #[test] + fn test_get_expired_timer_callbacks_js_skips_nonzero_delay() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("slow()".into()), 5000); + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.is_empty()); + } + + #[test] + fn test_get_expired_timer_callbacks_js_skips_none_callback() { + let mut queue = TimerQueue::new(); + queue.set_timeout(None, 0); + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.is_empty()); + } + + #[test] + fn test_get_expired_timer_callbacks_js_skips_fired() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("once()".into()), 0); + queue.mark_delay_zero_fired(); + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.is_empty()); + } + + #[test] + fn test_get_expired_timer_callbacks_js_includes_delay_zero_intervals() { + let mut queue = TimerQueue::new(); + queue.set_interval(Some("tick()".into()), 0); + // delay=0 intervals ARE returned by get_expired_timer_callbacks_js + // (only mark_delay_zero_fired skips them) + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.contains("tick()")); + } + + #[test] + fn test_get_expired_timer_callbacks_js_multiple() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("a()".into()), 0); + queue.set_timeout(Some("b()".into()), 0); + queue.set_timeout(Some("c()".into()), 100); // skipped + let js = queue.get_expired_timer_callbacks_js(); + assert!(js.contains("a()")); + assert!(js.contains("b()")); + assert!(!js.contains("c()")); + } + + #[test] + fn test_mark_delay_zero_fired_increments_tick_count() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("a".into()), 0); + queue.set_timeout(Some("b".into()), 0); + queue.set_timeout(Some("c".into()), 100); // not delay=0 + assert_eq!(queue.tick_count(), 0); + queue.mark_delay_zero_fired(); + assert_eq!(queue.tick_count(), 2); + } + + #[test] + fn test_mark_delay_zero_fired_idempotent() { + let mut queue = TimerQueue::new(); + queue.set_timeout(Some("a".into()), 0); + queue.mark_delay_zero_fired(); + assert_eq!(queue.tick_count(), 1); + queue.mark_delay_zero_fired(); + assert_eq!(queue.tick_count(), 1); // already fired + } + + #[test] + fn test_mark_delay_zero_does_not_fire_intervals() { + let mut queue = TimerQueue::new(); + queue.set_interval(Some("tick".into()), 0); + queue.mark_delay_zero_fired(); + assert_eq!(queue.tick_count(), 0); + assert!(!queue.timers[0].is_fired); + } + + #[test] + fn test_is_at_limit() { + let mut queue = TimerQueue::new(); + assert!(!queue.is_at_limit()); + // Force tick count to max + for _ in 0..queue.max_ticks { + queue.tick_count += 1; + } + assert!(queue.is_at_limit()); + } + + #[test] + fn test_timer_entry_defaults() { + let mut queue = TimerQueue::new(); + let id = queue.set_timeout(Some("cb".into()), 1000); + let entry = queue.timers.iter().find(|t| t.id == id).unwrap(); + assert_eq!(entry.id, id); + assert_eq!(entry.callback_str, Some("cb".into())); + assert_eq!(entry.delay_ms, 1000); + assert!(!entry.is_interval); + assert!(!entry.is_fired); + } +} diff --git a/crates/pardus-core/src/lib.rs b/crates/pardus-core/src/lib.rs index e6d569b..99e4515 100644 --- a/crates/pardus-core/src/lib.rs +++ b/crates/pardus-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod intercept; #[cfg(feature = "js")] pub mod js; pub mod navigation; +pub mod oauth; pub mod output; pub mod page; pub mod page_analysis; @@ -37,7 +38,7 @@ pub use app::App; pub use browser::Browser; pub use config::{BrowserConfig, ProxyConfig, CspConfig, RetryConfig}; pub use page::Page; -pub use page::{RedirectHop, RedirectChain}; +pub use page::{RedirectHop, RedirectChain, OAuthNavigateResult}; pub use sandbox::{JsSandboxMode, SandboxPolicy}; pub use page::PageSnapshot; pub use url_policy::UrlPolicy; @@ -62,6 +63,12 @@ pub use interact::action_plan::{ActionPlan, ActionType, PageType, SuggestedActio pub use interact::auto_fill::{AutoFillValues, AutoFillResult, ValidationStatus}; #[cfg(feature = "js")] pub use interact::recording::{SessionRecording, SessionRecorder, RecordedAction, RecordedActionType, ReplayStepResult, replay}; +pub use oauth::{ + exchange_code, refresh_tokens, start_authorization, StartFlowResult, + discover as oidc_discover, OpenIdConfiguration, PkcePair, + OAuthProviderConfig, OAuthSession, OAuthSessionManager, OAuthSessionStatus, SessionSummary, + validate_id_token, IdTokenClaims, OAuthTokenSet, +}; pub use tab::tab::TabConfig; pub use tab::{Tab, TabId, TabManager}; pub use intercept::InterceptorManager; diff --git a/crates/pardus-core/src/oauth/flow.rs b/crates/pardus-core/src/oauth/flow.rs new file mode 100644 index 0000000..3f8696c --- /dev/null +++ b/crates/pardus-core/src/oauth/flow.rs @@ -0,0 +1,240 @@ +//! OAuth 2.0 authorization code flow with PKCE. +//! +//! Provides functions for constructing authorization URLs, exchanging +//! authorization codes for tokens, and refreshing tokens. + +use std::collections::HashMap; + +use super::pkce::PkcePair; +use super::store::OAuthProviderConfig; +use super::token::OAuthTokenSet; + +/// Result of starting an authorization flow. +pub struct StartFlowResult { + /// The full authorization URL to navigate to. + pub authorization_url: String, + /// Anti-CSRF state parameter. + pub state: String, + /// OIDC nonce for ID token validation. + pub nonce: String, + /// The PKCE pair (code_verifier stored for later exchange). + pub pkce: PkcePair, +} + +/// Construct the authorization URL with all required OAuth 2.0 + PKCE parameters. +/// +/// The caller should: +/// 1. Store the `state`, `nonce`, and `pkce` for later use. +/// 2. Navigate the browser to `authorization_url`. +/// 3. Intercept the redirect to `redirect_uri` and extract the `code` parameter. +/// 4. Call `exchange_code` with the code and the stored `pkce.code_verifier`. +pub fn start_authorization( + config: &OAuthProviderConfig, + scopes: Option<&[String]>, + extra_params: &HashMap, +) -> StartFlowResult { + let pkce = PkcePair::generate(); + let state = generate_random_string(32); + let nonce = generate_random_string(32); + + let effective_scopes = scopes + .map(|s| s.to_vec()) + .unwrap_or_else(|| config.scopes.clone()); + + let mut url = url::Url::parse(&config.authorization_endpoint) + .expect("invalid authorization_endpoint URL"); + + { + let mut query = url.query_pairs_mut(); + query.append_pair("response_type", "code"); + query.append_pair("client_id", &config.client_id); + query.append_pair("redirect_uri", &config.redirect_uri); + query.append_pair("scope", &effective_scopes.join(" ")); + query.append_pair("state", &state); + query.append_pair("code_challenge", &pkce.code_challenge); + query.append_pair("code_challenge_method", "S256"); + + if !effective_scopes.iter().any(|s| s == "openid") { + // Add nonce only for OIDC flows + } else { + query.append_pair("nonce", &nonce); + } + + for (key, value) in extra_params { + query.append_pair(key, value); + } + } + + StartFlowResult { + authorization_url: url.to_string(), + state, + nonce, + pkce, + } +} + +/// Exchange an authorization code for tokens at the token endpoint. +/// +/// Sends a `grant_type=authorization_code` request with PKCE code_verifier. +pub async fn exchange_code( + http_client: &rquest::Client, + config: &OAuthProviderConfig, + code: &str, + code_verifier: &str, +) -> anyhow::Result { + let mut params = vec![ + ("grant_type", "authorization_code".to_string()), + ("code", code.to_string()), + ("redirect_uri", config.redirect_uri.clone()), + ("client_id", config.client_id.clone()), + ("code_verifier", code_verifier.to_string()), + ]; + + if let Some(secret) = &config.client_secret { + params.push(("client_secret", secret.clone())); + } + + let response = http_client + .post(&config.token_endpoint) + .form(¶ms) + .send() + .await + .map_err(|e| anyhow::anyhow!("token exchange request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("token exchange returned status {status}: {body}"); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| anyhow::anyhow!("failed to parse token response: {e}"))?; + + OAuthTokenSet::from_token_response(&json) +} + +/// Refresh an access token using a refresh token. +/// +/// Sends a `grant_type=refresh_token` request to the token endpoint. +pub async fn refresh_tokens( + http_client: &rquest::Client, + config: &OAuthProviderConfig, + refresh_token: &str, +) -> anyhow::Result { + let mut params = vec![ + ("grant_type", "refresh_token".to_string()), + ("refresh_token", refresh_token.to_string()), + ("client_id", config.client_id.clone()), + ]; + + if let Some(secret) = &config.client_secret { + params.push(("client_secret", secret.clone())); + } + + let response = http_client + .post(&config.token_endpoint) + .form(¶ms) + .send() + .await + .map_err(|e| anyhow::anyhow!("token refresh request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("token refresh returned status {status}: {body}"); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| anyhow::anyhow!("failed to parse refresh response: {e}"))?; + + OAuthTokenSet::from_token_response(&json) +} + +/// Generate a random alphanumeric string of the given length using getrandom. +fn generate_random_string(len: usize) -> String { + let charset = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut buf = vec![0u8; len]; + getrandom::fill(&mut buf).expect("failed to generate random bytes"); + buf.iter() + .map(|b| charset[*b as usize % charset.len()] as char) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> OAuthProviderConfig { + OAuthProviderConfig { + name: "test".to_string(), + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + client_id: "my-client-id".to_string(), + client_secret: None, + scopes: vec!["openid".to_string(), "profile".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + issuer: Some("https://auth.example.com".to_string()), + userinfo_endpoint: None, + } + } + + #[test] + fn authorization_url_contains_required_params() { + let result = start_authorization(&test_config(), None, &HashMap::new()); + + let url = url::Url::parse(&result.authorization_url).unwrap(); + let params: HashMap = url.query_pairs().into_owned().collect(); + + assert_eq!(params.get("response_type").unwrap(), "code"); + assert_eq!(params.get("client_id").unwrap(), "my-client-id"); + assert_eq!( + params.get("redirect_uri").unwrap(), + "http://localhost:8080/callback" + ); + assert!(params.contains_key("state")); + assert!(params.contains_key("code_challenge")); + assert_eq!(params.get("code_challenge_method").unwrap(), "S256"); + assert!(params.contains_key("nonce")); // OIDC flow + assert_eq!(params.get("scope").unwrap(), "openid profile"); + } + + #[test] + fn authorization_url_with_extra_params() { + let extras = HashMap::from([ + ("prompt".to_string(), "consent".to_string()), + ("access_type".to_string(), "offline".to_string()), + ]); + let result = start_authorization(&test_config(), None, &extras); + + let url = url::Url::parse(&result.authorization_url).unwrap(); + let params: HashMap = url.query_pairs().into_owned().collect(); + + assert_eq!(params.get("prompt").unwrap(), "consent"); + assert_eq!(params.get("access_type").unwrap(), "offline"); + } + + #[test] + fn authorization_url_custom_scopes() { + let custom_scopes = vec!["email".to_string(), "calendar".to_string()]; + let result = + start_authorization(&test_config(), Some(&custom_scopes), &HashMap::new()); + + let url = url::Url::parse(&result.authorization_url).unwrap(); + let params: HashMap = url.query_pairs().into_owned().collect(); + + assert_eq!(params.get("scope").unwrap(), "email calendar"); + } + + #[test] + fn state_and_nonce_are_random() { + let a = start_authorization(&test_config(), None, &HashMap::new()); + let b = start_authorization(&test_config(), None, &HashMap::new()); + assert_ne!(a.state, b.state); + assert_ne!(a.nonce, b.nonce); + assert_ne!(a.pkce.code_verifier, b.pkce.code_verifier); + } +} diff --git a/crates/pardus-core/src/oauth/mod.rs b/crates/pardus-core/src/oauth/mod.rs new file mode 100644 index 0000000..31b169a --- /dev/null +++ b/crates/pardus-core/src/oauth/mod.rs @@ -0,0 +1,18 @@ +//! OAuth 2.0 / OIDC support for Pardus browser. +//! +//! Implements authorization code flow with PKCE, token management, +//! OIDC discovery, and automatic Authorization header injection. + +pub mod flow; +pub mod oidc; +pub mod pkce; +pub mod store; +pub mod token; + +pub use flow::{exchange_code, refresh_tokens, start_authorization, StartFlowResult}; +pub use oidc::{discover, OpenIdConfiguration}; +pub use pkce::PkcePair; +pub use store::{ + OAuthProviderConfig, OAuthSession, OAuthSessionManager, OAuthSessionStatus, SessionSummary, +}; +pub use token::{validate_id_token, IdTokenClaims, OAuthTokenSet}; diff --git a/crates/pardus-core/src/oauth/oidc.rs b/crates/pardus-core/src/oauth/oidc.rs new file mode 100644 index 0000000..a53ae00 --- /dev/null +++ b/crates/pardus-core/src/oauth/oidc.rs @@ -0,0 +1,94 @@ +//! OIDC (OpenID Connect) discovery support. +//! +//! Fetches and parses the OpenID Provider Configuration from +//! `/.well-known/openid-configuration`. + +use serde::Deserialize; + +/// OpenID Provider Configuration Response (subset of fields). +/// +/// See . +#[derive(Debug, Clone, Deserialize)] +pub struct OpenIdConfiguration { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + #[serde(default)] + pub userinfo_endpoint: Option, + #[serde(default)] + pub jwks_uri: Option, + #[serde(default)] + pub scopes_supported: Option>, + #[serde(default)] + pub response_types_supported: Vec, + #[serde(default)] + pub code_challenge_methods_supported: Option>, + #[serde(default)] + pub token_endpoint_auth_methods_supported: Option>, +} + +/// Fetch the OIDC discovery document for the given issuer URL. +/// +/// Appends `/.well-known/openid-configuration` to the issuer URL if not already present. +pub async fn discover( + http_client: &rquest::Client, + issuer_url: &str, +) -> anyhow::Result { + let url = if issuer_url.ends_with("/.well-known/openid-configuration") { + issuer_url.to_string() + } else { + format!( + "{}/.well-known/openid-configuration", + issuer_url.trim_end_matches('/') + ) + }; + + let response = http_client + .get(&url) + .send() + .await + .map_err(|e| anyhow::anyhow!("OIDC discovery request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("OIDC discovery returned status {status}: {body}"); + } + + let config: OpenIdConfiguration = response + .json() + .await + .map_err(|e| anyhow::anyhow!("failed to parse OIDC discovery document: {e}"))?; + + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_discovery_document() { + let json = serde_json::json!({ + "issuer": "https://accounts.google.com", + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + "scopes_supported": ["openid", "email", "profile"], + "response_types_supported": ["code", "token", "id_token"], + "code_challenge_methods_supported": ["S256"] + }); + + let config: OpenIdConfiguration = serde_json::from_value(json).unwrap(); + assert_eq!(config.issuer, "https://accounts.google.com"); + assert_eq!( + config.authorization_endpoint, + "https://accounts.google.com/o/oauth2/v2/auth" + ); + assert_eq!( + config.code_challenge_methods_supported, + Some(vec!["S256".to_string()]) + ); + } +} diff --git a/crates/pardus-core/src/oauth/pkce.rs b/crates/pardus-core/src/oauth/pkce.rs new file mode 100644 index 0000000..22f8753 --- /dev/null +++ b/crates/pardus-core/src/oauth/pkce.rs @@ -0,0 +1,87 @@ +//! PKCE (Proof Key for Code Exchange) generation for OAuth 2.0. +//! +//! Implements S256 code challenge method per RFC 7636: +//! code_challenge = BASE64URL(SHA256(code_verifier)) + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use sha2::{Digest, Sha256}; + +/// PKCE parameters generated at the start of each authorization flow. +#[derive(Debug, Clone)] +pub struct PkcePair { + /// Cryptographically random 43-char string (unreserved ASCII chars). + pub code_verifier: String, + /// BASE64URL(SHA256(code_verifier)). + pub code_challenge: String, +} + +/// Unreserved characters per RFC 3986 §2.3, used for code_verifier. +const VERIFIER_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +impl PkcePair { + /// Generate a new PKCE pair with a 43-char random code_verifier + /// and the corresponding S256 code_challenge. + pub fn generate() -> Self { + let verifier = Self::generate_verifier(43); + let challenge = Self::compute_challenge(&verifier); + Self { + code_verifier: verifier, + code_challenge: challenge, + } + } + + /// Generate a cryptographically random code_verifier of the given length. + fn generate_verifier(len: usize) -> String { + let mut buf = vec![0u8; len]; + getrandom::fill(&mut buf).expect("failed to generate random bytes for PKCE verifier"); + buf.iter() + .map(|b| VERIFIER_CHARSET[*b as usize % VERIFIER_CHARSET.len()] as char) + .collect() + } + + /// Compute BASE64URL(SHA256(input)) per RFC 7636 §4.2. + pub fn compute_challenge(verifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let hash = hasher.finalize(); + URL_SAFE_NO_PAD.encode(hash) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_verifier_length() { + let pair = PkcePair::generate(); + assert_eq!(pair.code_verifier.len(), 43); + } + + #[test] + fn pkce_verifier_contains_only_unreserved_chars() { + let pair = PkcePair::generate(); + for ch in pair.code_verifier.chars() { + assert!( + VERIFIER_CHARSET.contains(&(ch as u8)), + "invalid char in verifier: {ch}" + ); + } + } + + #[test] + fn pkce_challenge_is_base64url_sha256() { + let pair = PkcePair::generate(); + let expected = PkcePair::compute_challenge(&pair.code_verifier); + assert_eq!(pair.code_challenge, expected); + } + + #[test] + fn pkce_uniqueness() { + let a = PkcePair::generate(); + let b = PkcePair::generate(); + assert_ne!(a.code_verifier, b.code_verifier); + assert_ne!(a.code_challenge, b.code_challenge); + } +} diff --git a/crates/pardus-core/src/oauth/store.rs b/crates/pardus-core/src/oauth/store.rs new file mode 100644 index 0000000..a09b7a3 --- /dev/null +++ b/crates/pardus-core/src/oauth/store.rs @@ -0,0 +1,395 @@ +//! OAuth session state management. +//! +//! Tracks multiple concurrent OAuth sessions (one per provider) including +//! PKCE state, tokens, and session lifecycle. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::pkce::PkcePair; +use super::token::OAuthTokenSet; + +/// OAuth provider configuration (manually provided or discovered via OIDC). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthProviderConfig { + /// Logical name for this provider (e.g., "google", "github"). + pub name: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub redirect_uri: String, + /// OIDC issuer URL (for validation and discovery). + pub issuer: Option, + /// Userinfo endpoint (from OIDC discovery). + pub userinfo_endpoint: Option, +} + +/// Status of an OAuth session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OAuthSessionStatus { + /// Provider registered, no flow started. + Idle, + /// Authorization URL generated, waiting for callback. + AuthorizationPending, + /// Tokens obtained, session active. + Active, + /// Access token expired (may have refresh token). + Expired, + /// Flow failed with an error. + Failed(String), +} + +impl std::fmt::Display for OAuthSessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Idle => write!(f, "idle"), + Self::AuthorizationPending => write!(f, "authorization_pending"), + Self::Active => write!(f, "active"), + Self::Expired => write!(f, "expired"), + Self::Failed(e) => write!(f, "failed: {e}"), + } + } +} + +/// A single OAuth session for one provider. +pub struct OAuthSession { + pub provider: OAuthProviderConfig, + pub pkce: Option, + /// Anti-CSRF state parameter. + pub state: Option, + /// OIDC nonce for ID token validation. + pub nonce: Option, + pub tokens: Option, + pub status: OAuthSessionStatus, + /// Captured authorization code from redirect. + pub pending_code: Option, +} + +/// Summary of a session (for listing without exposing sensitive data). +#[derive(Debug, Clone, Serialize)] +pub struct SessionSummary { + pub provider: String, + pub status: String, + pub has_access_token: bool, + pub has_refresh_token: bool, + pub expires_at: Option, + pub scopes: Option, +} + +/// Manages multiple OAuth sessions keyed by provider name. +pub struct OAuthSessionManager { + sessions: HashMap, +} + +impl OAuthSessionManager { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + } + } + + /// Register a provider configuration (creates an Idle session). + pub fn register_provider(&mut self, config: OAuthProviderConfig) { + let name = config.name.clone(); + self.sessions.insert( + name, + OAuthSession { + provider: config, + pkce: None, + state: None, + nonce: None, + tokens: None, + status: OAuthSessionStatus::Idle, + pending_code: None, + }, + ); + } + + /// Start a new authorization flow: generate PKCE, state, nonce, and store them. + /// Returns (authorization_url, state) — the caller should navigate to the URL. + pub fn start_flow( + &mut self, + provider_name: &str, + state: String, + nonce: String, + pkce: PkcePair, + authorization_url: String, + ) -> anyhow::Result<()> { + let session = self + .sessions + .get_mut(provider_name) + .ok_or_else(|| anyhow::anyhow!("provider '{}' not registered", provider_name))?; + + session.pkce = Some(pkce); + session.state = Some(state); + session.nonce = Some(nonce); + session.status = OAuthSessionStatus::AuthorizationPending; + session.pending_code = None; + + // Store the auth URL temporarily in state for retrieval + let _ = authorization_url; // caller already has it + Ok(()) + } + + /// Store the captured authorization code from the redirect callback. + pub fn set_pending_code(&mut self, provider_name: &str, code: String) -> anyhow::Result<()> { + let session = self + .sessions + .get_mut(provider_name) + .ok_or_else(|| anyhow::anyhow!("provider '{}' not registered", provider_name))?; + session.pending_code = Some(code); + Ok(()) + } + + /// Get the PKCE code_verifier for a pending session (needed for token exchange). + pub fn get_code_verifier(&self, provider_name: &str) -> anyhow::Result { + let session = self + .sessions + .get(provider_name) + .ok_or_else(|| anyhow::anyhow!("provider '{}' not registered", provider_name))?; + session + .pkce + .as_ref() + .map(|p| p.code_verifier.clone()) + .ok_or_else(|| anyhow::anyhow!("no PKCE pair for provider '{}'", provider_name)) + } + + /// Get the pending authorization code (if captured from redirect). + pub fn get_pending_code(&self, provider_name: &str) -> Option { + self.sessions + .get(provider_name) + .and_then(|s| s.pending_code.clone()) + } + + /// Get the stored state parameter for CSRF validation. + pub fn get_state(&self, provider_name: &str) -> Option { + self.sessions + .get(provider_name) + .and_then(|s| s.state.clone()) + } + + /// Complete the flow by storing the obtained tokens. + pub fn complete_flow(&mut self, provider_name: &str, tokens: OAuthTokenSet) { + if let Some(session) = self.sessions.get_mut(provider_name) { + session.tokens = Some(tokens); + session.status = OAuthSessionStatus::Active; + session.pending_code = None; + } + } + + /// Mark a flow as failed. + pub fn fail_flow(&mut self, provider_name: &str, error: String) { + if let Some(session) = self.sessions.get_mut(provider_name) { + session.status = OAuthSessionStatus::Failed(error); + } + } + + /// Get the tokens for a provider (if any). + pub fn get_tokens(&self, provider_name: &str) -> Option<&OAuthTokenSet> { + self.sessions.get(provider_name).and_then(|s| s.tokens.as_ref()) + } + + /// Get mutable tokens for a provider (for refresh). + pub fn get_tokens_mut(&mut self, provider_name: &str) -> Option<&mut OAuthTokenSet> { + self.sessions + .get_mut(provider_name) + .and_then(|s| s.tokens.as_mut()) + } + + /// Get the provider config for a provider. + pub fn get_provider(&self, provider_name: &str) -> Option<&OAuthProviderConfig> { + self.sessions.get(provider_name).map(|s| &s.provider) + } + + /// Find a session whose provider's issuer or token_endpoint domain matches the given URL. + /// Used for auto-injection of Authorization headers. + pub fn find_matching_session(&self, url: &str) -> Option<(&str, &OAuthTokenSet)> { + let parsed = match url::Url::parse(url) { + Ok(u) => u, + Err(_) => return None, + }; + let host = parsed.host_str().unwrap_or(""); + + for (name, session) in &self.sessions { + if session.tokens.is_none() { + continue; + } + // Check if the URL host matches the provider's issuer or token endpoint domain + let provider_domains = [ + session.provider.issuer.as_deref(), + Some(&session.provider.token_endpoint), + Some(&session.provider.authorization_endpoint), + ]; + for domain in provider_domains.iter().flatten() { + if let Ok(provider_url) = url::Url::parse(domain) { + if provider_url.host_str() == Some(host) { + return Some((name, session.tokens.as_ref().unwrap())); + } + } + } + } + None + } + + /// List all sessions as summaries. + pub fn get_all_sessions(&self) -> Vec { + self.sessions + .iter() + .map(|(name, session)| SessionSummary { + provider: name.clone(), + status: session.status.to_string(), + has_access_token: session.tokens.is_some(), + has_refresh_token: session + .tokens + .as_ref() + .and_then(|t| t.refresh_token.clone()) + .is_some(), + expires_at: session.tokens.as_ref().and_then(|t| t.expires_at), + scopes: session + .tokens + .as_ref() + .and_then(|t| t.scope.clone()) + .or_else(|| { + if session.provider.scopes.is_empty() { + None + } else { + Some(session.provider.scopes.join(" ")) + } + }), + }) + .collect() + } + + /// Remove a session. + pub fn remove_session(&mut self, provider_name: &str) -> bool { + self.sessions.remove(provider_name).is_some() + } + + /// Check if a provider is registered. + pub fn has_provider(&self, provider_name: &str) -> bool { + self.sessions.contains_key(provider_name) + } +} + +impl Default for OAuthSessionManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_provider(name: &str) -> OAuthProviderConfig { + OAuthProviderConfig { + name: name.to_string(), + authorization_endpoint: format!("https://{name}.example.com/auth"), + token_endpoint: format!("https://{name}.example.com/token"), + client_id: "test-client".to_string(), + client_secret: None, + scopes: vec!["openid".to_string(), "profile".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + issuer: Some(format!("https://{name}.example.com")), + userinfo_endpoint: None, + } + } + + fn test_tokens() -> OAuthTokenSet { + OAuthTokenSet { + access_token: "access-123".to_string(), + token_type: "Bearer".to_string(), + expires_at: Some(chrono::Utc::now().timestamp() + 3600), + refresh_token: Some("refresh-456".to_string()), + id_token: None, + scope: Some("openid profile".to_string()), + } + } + + #[test] + fn register_and_check() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + assert!(mgr.has_provider("google")); + assert!(!mgr.has_provider("github")); + } + + #[test] + fn full_lifecycle() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + + let pkce = PkcePair::generate(); + let state = "test-state".to_string(); + let nonce = "test-nonce".to_string(); + let auth_url = "https://google.example.com/auth?...".to_string(); + + mgr.start_flow("google", state.clone(), nonce, pkce, auth_url).unwrap(); + assert_eq!( + mgr.sessions.get("google").unwrap().status, + OAuthSessionStatus::AuthorizationPending + ); + + mgr.set_pending_code("google", "auth-code-123".to_string()).unwrap(); + assert_eq!( + mgr.get_pending_code("google"), + Some("auth-code-123".to_string()) + ); + + let verifier = mgr.get_code_verifier("google").unwrap(); + assert!(!verifier.is_empty()); + + mgr.complete_flow("google", test_tokens()); + assert_eq!( + mgr.sessions.get("google").unwrap().status, + OAuthSessionStatus::Active + ); + assert!(mgr.get_tokens("google").is_some()); + } + + #[test] + fn find_matching_session() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + mgr.complete_flow("google", test_tokens()); + + let result = mgr.find_matching_session("https://google.example.com/api/user"); + assert!(result.is_some()); + let (name, tokens) = result.unwrap(); + assert_eq!(name, "google"); + assert_eq!(tokens.access_token, "access-123"); + } + + #[test] + fn find_no_match() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + mgr.complete_flow("google", test_tokens()); + + assert!(mgr + .find_matching_session("https://other.example.com/api") + .is_none()); + } + + #[test] + fn list_sessions() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + mgr.register_provider(test_provider("github")); + + let sessions = mgr.get_all_sessions(); + assert_eq!(sessions.len(), 2); + } + + #[test] + fn remove_session() { + let mut mgr = OAuthSessionManager::new(); + mgr.register_provider(test_provider("google")); + assert!(mgr.remove_session("google")); + assert!(!mgr.has_provider("google")); + } +} diff --git a/crates/pardus-core/src/oauth/token.rs b/crates/pardus-core/src/oauth/token.rs new file mode 100644 index 0000000..fa48c16 --- /dev/null +++ b/crates/pardus-core/src/oauth/token.rs @@ -0,0 +1,240 @@ +//! OAuth 2.0 token types and basic JWT (ID token) parsing. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +/// Tokens received from the token endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthTokenSet { + pub access_token: String, + pub token_type: String, + /// Unix epoch seconds when the access token expires. + pub expires_at: Option, + pub refresh_token: Option, + /// Raw JWT string for the ID token (if OIDC). + pub id_token: Option, + pub scope: Option, +} + +impl OAuthTokenSet { + /// Parse a token response from the token endpoint JSON. + /// Converts `expires_in` (seconds from now) to `expires_at` (absolute timestamp). + pub fn from_token_response(json: &serde_json::Value) -> anyhow::Result { + let access_token = json["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing access_token in token response"))? + .to_string(); + + let token_type = json["token_type"] + .as_str() + .unwrap_or("Bearer") + .to_string(); + + let expires_at = json["expires_in"].as_i64().map(|secs| { + chrono::Utc::now().timestamp() + secs + }); + + let refresh_token = json["refresh_token"].as_str().map(String::from); + let id_token = json["id_token"].as_str().map(String::from); + let scope = json["scope"].as_str().map(String::from); + + Ok(Self { + access_token, + token_type, + expires_at, + refresh_token, + id_token, + scope, + }) + } + + /// Check if the access token is expired or will expire within `buffer_secs`. + pub fn is_expired(&self, buffer_secs: i64) -> bool { + match self.expires_at { + Some(exp) => chrono::Utc::now().timestamp() >= (exp - buffer_secs), + None => false, // no expiry info, assume valid + } + } + + /// Build the Authorization header value (e.g., "Bearer "). + pub fn authorization_header(&self) -> String { + format!("{} {}", self.token_type, self.access_token) + } +} + +/// Claims extracted from an ID token JWT payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdTokenClaims { + pub iss: String, + pub sub: String, + /// May be a single string or an array. + #[serde(deserialize_with = "deserialize_aud")] + pub aud: String, + pub exp: i64, + pub iat: i64, + pub email: Option, + pub name: Option, + pub nonce: Option, +} + +/// Support both `"aud": "string"` and `"aud": ["string"]` in JWT payloads. +fn deserialize_aud<'de, D: serde::Deserializer<'de>>(de: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Aud { + Single(String), + Multiple(Vec), + } + match Aud::deserialize(de)? { + Aud::Single(s) => Ok(s), + Aud::Multiple(v) => Ok(v.first().cloned().unwrap_or_default()), + } +} + +/// Basic ID token validation: decode the JWT payload and check claims. +/// Does NOT verify the JWT signature — only suitable for trusted environments. +pub fn validate_id_token( + id_token: &str, + expected_issuer: Option<&str>, + expected_audience: &str, + expected_nonce: Option<&str>, +) -> anyhow::Result { + let parts: Vec<&str> = id_token.split('.').collect(); + if parts.len() != 3 { + anyhow::bail!("invalid JWT: expected 3 parts, got {}", parts.len()); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| anyhow::anyhow!("failed to decode JWT payload: {e}"))?; + let claims: IdTokenClaims = serde_json::from_slice(&payload_bytes) + .map_err(|e| anyhow::anyhow!("failed to parse JWT claims: {e}"))?; + + // Check expiry + let now = chrono::Utc::now().timestamp(); + if claims.exp < now { + anyhow::bail!( + "ID token expired: exp={} now={}", + claims.exp, + now + ); + } + + // Check issuer + if let Some(iss) = expected_issuer { + if claims.iss != iss { + anyhow::bail!( + "ID token issuer mismatch: expected={} got={}", + iss, + claims.iss + ); + } + } + + // Check audience + if claims.aud != expected_audience { + anyhow::bail!( + "ID token audience mismatch: expected={} got={}", + expected_audience, + claims.aud + ); + } + + // Check nonce + if let Some(nonce) = expected_nonce { + match &claims.nonce { + Some(n) if n == nonce => {} + Some(n) => anyhow::bail!("ID token nonce mismatch: expected={} got={}", nonce, n), + None => anyhow::bail!("ID token missing nonce, expected={}", nonce), + } + } + + Ok(claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_token(expires_in: i64) -> OAuthTokenSet { + OAuthTokenSet { + access_token: "test-token".to_string(), + token_type: "Bearer".to_string(), + expires_at: Some(chrono::Utc::now().timestamp() + expires_in), + refresh_token: Some("refresh-123".to_string()), + id_token: None, + scope: Some("openid profile".to_string()), + } + } + + #[test] + fn token_not_expired() { + let token = make_token(300); + assert!(!token.is_expired(60)); + } + + #[test] + fn token_expired() { + let token = make_token(-10); + assert!(token.is_expired(0)); + } + + #[test] + fn token_near_expiry_within_buffer() { + let token = make_token(30); + assert!(token.is_expired(60)); + } + + #[test] + fn authorization_header() { + let token = make_token(300); + assert_eq!(token.authorization_header(), "Bearer test-token"); + } + + #[test] + fn from_token_response() { + let json = serde_json::json!({ + "access_token": "abc123", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh-abc", + "scope": "openid profile email" + }); + let token = OAuthTokenSet::from_token_response(&json).unwrap(); + assert_eq!(token.access_token, "abc123"); + assert_eq!(token.token_type, "Bearer"); + assert!(token.expires_at.is_some()); + assert_eq!(token.refresh_token.as_deref(), Some("refresh-abc")); + } + + #[test] + fn validate_id_token_basic() { + // Create a minimal JWT: header.payload.signature + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode( + serde_json::json!({ + "iss": "https://accounts.google.com", + "sub": "12345", + "aud": "my-client-id", + "exp": chrono::Utc::now().timestamp() + 3600, + "iat": chrono::Utc::now().timestamp(), + "email": "test@example.com", + "nonce": "abc" + }).to_string(), + ); + let jwt = format!("{header}.{payload}.signature"); + + let claims = validate_id_token( + &jwt, + Some("https://accounts.google.com"), + "my-client-id", + Some("abc"), + ) + .unwrap(); + + assert_eq!(claims.iss, "https://accounts.google.com"); + assert_eq!(claims.sub, "12345"); + assert_eq!(claims.email.as_deref(), Some("test@example.com")); + } +} diff --git a/crates/pardus-core/src/page.rs b/crates/pardus-core/src/page.rs index 2cadb7d..dec4655 100644 --- a/crates/pardus-core/src/page.rs +++ b/crates/pardus-core/src/page.rs @@ -1,18 +1,21 @@ -use scraper::{Html, Selector, ElementRef}; +use std::{cell::OnceCell, sync::Arc, time::Instant}; + +use pardus_debug::{Initiator, NetworkRecord, ResourceType}; +use scraper::{ElementRef, Html, Selector}; use serde::Serialize; -use std::sync::Arc; -use std::time::Instant; use url::Url; -use crate::app::App; -use crate::frame::{FrameTree, FrameId}; -use crate::push::EarlyScanner; -use crate::resource::ResourceFetcher; -use crate::semantic::tree::{SemanticTree, SemanticRole, SemanticNode}; -use crate::navigation::graph::NavigationGraph; -use crate::interact::element::{ElementHandle, element_to_handle}; - -use pardus_debug::{NetworkRecord, ResourceType, Initiator}; +use crate::{ + app::App, + frame::{FrameId, FrameTree}, + interact::element::{ElementHandle, element_to_handle}, + navigation::graph::NavigationGraph, + push::EarlyScanner, + resource::{ + ResourceConfig, ResourceFetcher, ResourceKind, ResourceScheduler, scheduler::ResourceTask, + }, + semantic::tree::{SemanticNode, SemanticRole, SemanticTree}, +}; // --------------------------------------------------------------------------- // Redirect chain types @@ -38,14 +41,10 @@ pub struct RedirectChain { } impl RedirectChain { - pub fn is_empty(&self) -> bool { - self.hops.is_empty() - } + pub fn is_empty(&self) -> bool { self.hops.is_empty() } /// The original URL before any redirects. - pub fn original_url(&self) -> Option<&str> { - self.hops.first().map(|h| h.from.as_str()) - } + pub fn original_url(&self) -> Option<&str> { self.hops.first().map(|h| h.from.as_str()) } } /// Serializable snapshot of a page's state. @@ -69,24 +68,69 @@ pub struct Page { pub content_type: Option, pub html: Html, pub base_url: String, - /// CSP policy parsed from response headers (when CSP enforcement is enabled). pub csp: Option, - /// Frame tree with recursively parsed iframe/frame content. - /// `None` if iframe parsing is disabled or not applicable. pub frame_tree: Option, - /// Pre-built semantic tree for non-HTML content (e.g., PDFs). - /// When `Some`, `semantic_tree()` returns this instead of parsing HTML. - pub cached_tree: Option, - /// HTTP redirect chain (empty / None when no redirects occurred). + pub cached_tree: OnceCell>, pub redirect_chain: Option, } +struct FetchedResponse { + final_url: String, + status: u16, + content_type: Option, + resp_headers: Vec<(String, String)>, + http_version: String, + body_bytes: Vec, + redirect_hops: Vec, + started_at: String, + elapsed_ms: u128, +} + impl Page { #[must_use = "ignoring Result may silently swallow navigation errors"] pub async fn from_url(app: &Arc, url: &str) -> anyhow::Result { Self::fetch_and_create(app, url, 0).await } + /// Fetch a URL with streaming semantic parsing. + /// + /// Like `from_url()` but uses `StreamingHtmlParser` to discover elements + /// as HTTP chunks arrive. The optional `event_sink` receives nodes in + /// real-time. Returns `(Page, StreamingParseStats)`. + #[must_use = "ignoring Result may silently swallow navigation errors"] + pub async fn from_url_streaming( + app: &Arc, + url: &str, + event_sink: Option>, + ) -> anyhow::Result<(Self, crate::parser::StreamingParseStats)> { + let mut current_url = url.to_string(); + let mut depth = 0; + + loop { + if depth >= Self::MAX_REDIRECT_DEPTH { + anyhow::bail!( + "Redirect depth exceeded ({} >= {}) for {}", + depth, + Self::MAX_REDIRECT_DEPTH, + current_url + ); + } + + let (page, stats) = + Self::fetch_and_create_single_streaming(app, ¤t_url, event_sink.clone()) + .await?; + + if let Some(refresh_url) = page.meta_refresh_url() { + tracing::debug!(target: "page", "meta refresh redirect: {} -> {}", current_url, refresh_url); + current_url = refresh_url; + depth += 1; + continue; + } + + return Ok((page, stats)); + } + } + /// Fetch a URL and create a Page, routing to PDF extraction when appropriate. /// /// The HTTP pipeline runs in this order: @@ -95,12 +139,21 @@ impl Page { /// 3. HTTP fetch with retry (exponential backoff for transient failures) /// 4. Response interception (after-response: modify / block) /// 5. Meta refresh redirect detection (up to MAX_REDIRECT_DEPTH) - pub(crate) async fn fetch_and_create(app: &Arc, url: &str, mut depth: usize) -> anyhow::Result { + pub(crate) async fn fetch_and_create( + app: &Arc, + url: &str, + mut depth: usize, + ) -> anyhow::Result { let mut current_url = url.to_string(); loop { if depth >= Self::MAX_REDIRECT_DEPTH { - anyhow::bail!("Redirect depth exceeded ({} >= {}) for {}", depth, Self::MAX_REDIRECT_DEPTH, current_url); + anyhow::bail!( + "Redirect depth exceeded ({} >= {}) for {}", + depth, + Self::MAX_REDIRECT_DEPTH, + current_url + ); } let page = Self::fetch_and_create_single(app, ¤t_url).await?; @@ -116,27 +169,29 @@ impl Page { } } - async fn fetch_and_create_single(app: &Arc, url: &str) -> anyhow::Result { - - // --- Phase 1: Request deduplication --- + async fn run_fetch_pipeline( + app: &Arc, + url: &str, + ) -> anyhow::Result<(FetchedResponse, String)> { let url_key = crate::dedup::dedup_key(url); + if app.dedup.is_enabled() { match app.dedup.enter(&url_key).await { crate::dedup::DedupEntry::Cached(result) => { - return Self::from_dedup_result(&result); + let resp = FetchedResponse::from_dedup_result(result); + return Ok((resp, url_key)); } crate::dedup::DedupEntry::Wait(notify) => { notify.notified().await; if let Some(result) = app.dedup.get_completed(&url_key) { - return Self::from_dedup_result(&result); + let resp = FetchedResponse::from_dedup_result(result); + return Ok((resp, url_key)); } - // Result was removed (error path) — fall through to own fetch. } crate::dedup::DedupEntry::Proceed => {} } } - // --- Phase 2: Request interception --- let mut req_ctx = crate::intercept::RequestContext { url: url.to_string(), method: "GET".to_string(), @@ -159,24 +214,23 @@ impl Page { } crate::intercept::InterceptAction::Mock(mock) => { tracing::debug!("interceptor mocked response for {}", url); - return Ok(Self::from_mock_response(&req_ctx.url, &mock)); - } - crate::intercept::InterceptAction::Modify(_) | crate::intercept::InterceptAction::Continue => { - req_ctx.url.clone() + let resp = FetchedResponse::from_mock(&req_ctx.url, &mock); + return Ok((resp, url_key)); } + crate::intercept::InterceptAction::Modify(_) + | crate::intercept::InterceptAction::Continue => req_ctx.url.clone(), }; - // Re-validate if the URL was changed. if effective_url != url { app.validate_url(&effective_url)?; } - // --- Phase 3: HTTP fetch with retry --- let started_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let start = Instant::now(); let retry_config = app.config.read().retry.clone(); - let (response, redirect_hops) = Self::fetch_with_retry(app, &effective_url, &req_ctx.headers, &retry_config).await?; + let (response, redirect_hops) = + Self::fetch_with_retry(app, &effective_url, &req_ctx.headers, &retry_config).await?; let http_version = format_http_version(response.version()); let status = response.status().as_u16(); @@ -193,7 +247,6 @@ impl Page { .filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string()))) .collect(); - // --- Phase 4: Response interception --- let resp_ctx = crate::intercept::ResponseContext { url: final_url.clone(), status, @@ -201,149 +254,82 @@ impl Page { body: None, resource_type: ResourceType::Document, }; - let post_action = app.interceptors.run_after_response(&mut { - let mut ctx = resp_ctx; - // Response interception only runs if interceptors exist - ctx - }).await; + let post_action = app + .interceptors + .run_after_response(&mut { + let mut ctx = resp_ctx; + ctx + }) + .await; - // For after-response, we only block or continue (modify on response is rare) if let crate::intercept::InterceptAction::Block = post_action { app.dedup.remove(&url_key); anyhow::bail!("Response from '{}' blocked by interceptor", final_url); } - // --- Process response body --- - let is_pdf = content_type.as_ref().map_or(false, |ct| { - ct.split(';').next().unwrap_or(ct).trim().to_lowercase() == "application/pdf" - }); - - if is_pdf { - let bytes = response - .bytes() - .await - .map_err(|e| anyhow::anyhow!("Failed to download PDF: {}", e))?; - let body_size = bytes.len(); - - let config = app.config.read(); - if config.sandbox.max_page_size > 0 && body_size > config.sandbox.max_page_size { - app.dedup.remove(&url_key); - anyhow::bail!( - "PDF size ({} bytes) exceeds sandbox limit ({} bytes)", - body_size, - config.sandbox.max_page_size - ); - } - drop(config); - - let timing_ms = start.elapsed().as_millis(); - record_main_request( - app, &effective_url, &final_url, status, &content_type, - body_size, timing_ms, &resp_headers, started_at, &http_version, - ); - - let result = crate::dedup::DedupResult { - url: final_url.clone(), - status, - body: bytes.to_vec(), - content_type: content_type.clone(), - headers: resp_headers.clone(), - http_version: http_version.clone(), - }; - app.dedup.complete(&url_key, result); - - return Self::from_pdf_bytes(&bytes, &final_url, status, content_type); - } - - let body = response.text().await?; - let body_size = body.len(); - - // Check for RSS/Atom feed content - if crate::feed::is_feed_content(body.as_bytes(), content_type.as_deref()) { - let timing_ms = start.elapsed().as_millis(); - record_main_request( - app, &effective_url, &final_url, status, &content_type, - body_size, timing_ms, &resp_headers, started_at, &http_version, - ); - - let result = crate::dedup::DedupResult { - url: final_url.clone(), - status, - body: body.as_bytes().to_vec(), - content_type: content_type.clone(), - headers: resp_headers.clone(), - http_version: http_version.clone(), - }; - app.dedup.complete(&url_key, result); - - return Self::from_feed_bytes(body.as_bytes(), &final_url, status, content_type); - } + let body_bytes = response + .bytes() + .await + .map_err(|e| anyhow::anyhow!("Failed to read response body: {}", e))?; - let config = app.config.read(); - if config.sandbox.max_page_size > 0 && body_size > config.sandbox.max_page_size { - app.dedup.remove(&url_key); - anyhow::bail!( - "Page size ({} bytes) exceeds sandbox limit ({} bytes)", - body_size, - config.sandbox.max_page_size - ); - } + let elapsed_ms = start.elapsed().as_millis(); + let body_size = body_bytes.len(); - let timing_ms = start.elapsed().as_millis(); record_main_request( - app, &effective_url, &final_url, status, &content_type, - body_size, timing_ms, &resp_headers, started_at, &http_version, + app, + &effective_url, + &final_url, + status, + &content_type, + body_size, + elapsed_ms, + &resp_headers, + started_at.clone(), + &http_version, ); - let result = crate::dedup::DedupResult { + let body_bytes_vec = body_bytes.to_vec(); + + let dedup_result = crate::dedup::DedupResult { url: final_url.clone(), status, - body: body.as_bytes().to_vec(), + body: body_bytes_vec.clone(), content_type: content_type.clone(), headers: resp_headers.clone(), http_version: http_version.clone(), }; - app.dedup.complete(&url_key, result); + app.dedup.complete(&url_key, dedup_result); - validate_content_type_pub(content_type.as_deref(), &final_url)?; - - let push_enabled = config.push.enable_push && !config.sandbox.disable_push; - let csp_policy = config.csp.parse_policy(&resp_headers); - drop(config); - - spawn_push_fetches(&app.http_client, &body, &final_url, push_enabled); - - let html = Html::parse_document(&body); - let base_url = Self::extract_base_url(&html, &final_url, csp_policy.as_ref()); + Ok(( + FetchedResponse { + final_url, + status, + content_type, + resp_headers, + http_version, + body_bytes: body_bytes_vec, + redirect_hops, + started_at, + elapsed_ms, + }, + url_key, + )) + } - let config = app.config.read(); - let frame_tree = if config.parse_iframes { - let max_depth = config.max_iframe_depth; - drop(config); - Some( - FrameTree::build(html.clone(), &final_url, &base_url, &app.http_client, max_depth) - .await, - ) - } else { - drop(config); - None - }; + async fn fetch_and_create_single(app: &Arc, url: &str) -> anyhow::Result { + let (fetched, url_key) = Self::run_fetch_pipeline(app, url).await?; + fetched.into_page(app, &url_key, None).await + } - Ok(Self { - url: final_url, - status, - content_type, - html, - base_url, - csp: csp_policy, - frame_tree, - cached_tree: None, - redirect_chain: if redirect_hops.is_empty() { - None - } else { - Some(RedirectChain { hops: redirect_hops }) - }, - }) + async fn fetch_and_create_single_streaming( + app: &Arc, + url: &str, + event_sink: Option>, + ) -> anyhow::Result<(Self, crate::parser::StreamingParseStats)> { + let (fetched, url_key) = Self::run_fetch_pipeline(app, url).await?; + let page = fetched.into_page(app, &url_key, event_sink).await?; + let stats = crate::parser::StreamingParseStats::default(); + Ok((page, stats)) } fn meta_refresh_url(&self) -> Option { @@ -367,7 +353,7 @@ impl Page { base_url: url.to_string(), csp: None, frame_tree: None, - cached_tree: None, + cached_tree: OnceCell::new(), redirect_chain: None, } } @@ -387,7 +373,7 @@ impl Page { base_url: result.url.clone(), csp: None, frame_tree: None, - cached_tree: None, + cached_tree: OnceCell::new(), redirect_chain: None, }) } @@ -421,12 +407,14 @@ impl Page { } // Set custom redirect policy to capture each hop - request_builder = request_builder.redirect( - rquest::redirect::Policy::custom(move |attempt| { + request_builder = + request_builder.redirect(rquest::redirect::Policy::custom(move |attempt| { if attempt.previous().len() >= max { return attempt.error("too many redirects"); } - let from = attempt.previous().last() + let from = attempt + .previous() + .last() .map(|u| u.to_string()) .unwrap_or_default(); let to = attempt.url().to_string(); @@ -435,8 +423,7 @@ impl Page { hops.push(RedirectHop { from, to, status }); } attempt.follow() - }) - ); + })); // Build the request so we can retry it let request = request_builder @@ -453,7 +440,11 @@ impl Page { let delay = compute_backoff(attempt, retry_config); tracing::debug!( "retry {}/{} for {} (status {}), waiting {}ms", - attempt, retry_config.max_retries, url, status, delay, + attempt, + retry_config.max_retries, + url, + status, + delay, ); tokio::time::sleep(std::time::Duration::from_millis(delay)).await; continue; @@ -464,12 +455,18 @@ impl Page { .unwrap_or_default(); return Ok((response, hops)); } - Err(e) if (e.is_timeout() || e.is_connect()) && attempt < retry_config.max_retries => { + Err(e) + if (e.is_timeout() || e.is_connect()) && attempt < retry_config.max_retries => + { attempt += 1; let delay = compute_backoff(attempt, retry_config); tracing::debug!( "retry {}/{} for {} ({}), waiting {}ms", - attempt, retry_config.max_retries, url, e, delay, + attempt, + retry_config.max_retries, + url, + e, + delay, ); tokio::time::sleep(std::time::Duration::from_millis(delay)).await; continue; @@ -497,7 +494,7 @@ impl Page { base_url: url.to_string(), csp: None, frame_tree: None, - cached_tree: Some(tree), + cached_tree: OnceCell::from(Arc::new(tree)), redirect_chain: None, }) } @@ -520,7 +517,7 @@ impl Page { base_url: url.to_string(), csp: None, frame_tree: None, - cached_tree: Some(tree), + cached_tree: OnceCell::from(Arc::new(tree)), redirect_chain: None, }) } @@ -533,12 +530,17 @@ impl Page { loop { if depth >= Self::MAX_REDIRECT_DEPTH { - anyhow::bail!("Redirect depth exceeded ({} >= {}) for {}", depth, Self::MAX_REDIRECT_DEPTH, current_url); + anyhow::bail!( + "Redirect depth exceeded ({} >= {}) for {}", + depth, + Self::MAX_REDIRECT_DEPTH, + current_url + ); } let mut page = Self::fetch_and_create(app, ¤t_url, depth).await?; - if page.cached_tree.is_some() { + if page.cached_tree.get().is_some() { return Ok(page); } @@ -546,8 +548,15 @@ impl Page { let base_url = page.base_url.clone(); let sandbox = &app.config.read().sandbox; let user_agent = app.config.read().user_agent.clone(); - let final_body = - crate::js::execute_js(&html_str, &base_url, wait_ms, Some(sandbox), &user_agent, Some(app.cookie_jar.clone())).await?; + let (final_body, _mutations) = crate::js::execute_js( + &html_str, + &base_url, + wait_ms, + Some(sandbox), + &user_agent, + Some(app.cookie_jar.clone()), + ) + .await?; if let Some(nav_href) = Self::parse_js_navigation_href(&final_body) { let resolved = Url::parse(&page.url) @@ -568,8 +577,14 @@ impl Page { let max_depth = config.max_iframe_depth; drop(config); Some( - FrameTree::build(html.clone(), &page.url, &base_url, &app.http_client, max_depth) - .await, + FrameTree::build( + html.clone(), + &page.url, + &base_url, + &app.http_client, + max_depth, + ) + .await, ) } else { drop(config); @@ -586,7 +601,11 @@ impl Page { /// Returns an error indicating JS support is not compiled in. #[cfg(not(feature = "js"))] - pub async fn from_url_with_js(_app: &Arc, _url: &str, _wait_ms: u32) -> anyhow::Result { + pub async fn from_url_with_js( + _app: &Arc, + _url: &str, + _wait_ms: u32, + ) -> anyhow::Result { anyhow::bail!("JavaScript execution is not available — rebuild with --features js"); } @@ -601,7 +620,7 @@ impl Page { base_url, csp: None, frame_tree: None, - cached_tree: None, + cached_tree: OnceCell::new(), redirect_chain: None, } } @@ -618,7 +637,7 @@ impl Page { base_url, csp: None, frame_tree: Some(frame_tree), - cached_tree: None, + cached_tree: OnceCell::new(), redirect_chain: None, } } @@ -632,7 +651,8 @@ impl Page { ) -> Self { let html = Html::parse_document(html_str); let base_url = Self::extract_base_url(&html, url, None); - let frame_tree = FrameTree::build(html.clone(), url, &base_url, http_client, max_depth).await; + let frame_tree = + FrameTree::build(html.clone(), url, &base_url, http_client, max_depth).await; Self { url: url.to_string(), status: 200, @@ -641,13 +661,13 @@ impl Page { base_url, csp: None, frame_tree: Some(frame_tree), - cached_tree: None, + cached_tree: OnceCell::new(), redirect_chain: None, } } pub fn title(&self) -> Option { - if let Some(ref tree) = self.cached_tree { + if let Some(tree) = self.cached_tree.get() { return tree.root.name.clone(); } @@ -679,14 +699,14 @@ impl Page { /// Find an element by its semantic role and optional name. pub fn find_by_role(&self, role: SemanticRole, name: Option<&str>) -> Option { - let tree = self.semantic_tree(); + let tree = self.semantic_tree_ref()?; let node = find_node_by_role(&tree.root, &role, name)?; node_to_handle(&node, &self.html) } /// Find an element by its semantic action string and optional name. pub fn find_by_action(&self, action: &str, name: Option<&str>) -> Option { - let tree = self.semantic_tree(); + let tree = self.semantic_tree_ref()?; let node = find_node_by_action(&tree.root, action, name)?; node_to_handle(&node, &self.html) } @@ -694,14 +714,16 @@ impl Page { /// Find an interactive element by its element ID (e.g., 1, 2, 3). /// This is the preferred way for AI agents to reference elements. pub fn find_by_element_id(&self, id: usize) -> Option { - let tree = self.semantic_tree(); + let tree = self.semantic_tree_ref()?; let node = find_node_by_element_id(&tree.root, id)?; node_to_handle(&node, &self.html) } /// Get all interactive elements from the semantic tree. pub fn interactive_elements(&self) -> Vec { - let tree = self.semantic_tree(); + let Some(tree) = self.semantic_tree_ref() else { + return Vec::new(); + }; let nodes = collect_interactive(&tree.root); nodes .into_iter() @@ -722,22 +744,25 @@ impl Page { Self::extract_base_url(html, fallback, None) } - pub fn semantic_tree(&self) -> SemanticTree { - if let Some(ref tree) = self.cached_tree { - return tree.clone(); - } - if let Some(ref frame_tree) = self.frame_tree { - SemanticTree::build_with_frames(&self.html, &self.base_url, frame_tree) - } else { - SemanticTree::build(&self.html, &self.base_url) - } + pub fn semantic_tree(&self) -> Arc { + self.cached_tree + .get_or_init(|| { + if let Some(ref frame_tree) = self.frame_tree { + Arc::new(SemanticTree::build_with_frames(&self.html, &self.base_url, frame_tree)) + } else { + Arc::new(SemanticTree::build(&self.html, &self.base_url)) + } + }) + .clone() } - /// Get the frame tree for this page (if iframe parsing was enabled). - pub fn frame_tree(&self) -> Option<&FrameTree> { - self.frame_tree.as_ref() + pub fn semantic_tree_ref(&self) -> Option<&SemanticTree> { + self.cached_tree.get().map(|arc| arc.as_ref()) } + /// Get the frame tree for this page (if iframe parsing was enabled). + pub fn frame_tree(&self) -> Option<&FrameTree> { self.frame_tree.as_ref() } + /// Find an element in a specific frame by CSS selector. pub fn query_in_frame(&self, frame_id: &FrameId, selector: &str) -> Option { let tree = self.frame_tree.as_ref()?; @@ -766,7 +791,8 @@ impl Page { Ok(s) => s, Err(_) => return Vec::new(), }; - let results: Vec = html.select(&sel) + let results: Vec = html + .select(&sel) .map(|el| element_to_handle(&el, &html)) .collect(); results @@ -804,12 +830,17 @@ impl Page { base_url: self.base_url.clone(), csp: self.csp.clone(), frame_tree: None, - cached_tree: self.cached_tree.clone(), + cached_tree: { + let cell = OnceCell::new(); + if let Some(tree) = self.cached_tree.get() { + let _ = cell.set(tree.clone()); + } + cell + }, redirect_chain: self.redirect_chain.clone(), } } - pub fn navigation_graph(&self) -> NavigationGraph { NavigationGraph::build(&self.html, &self.url) } @@ -820,11 +851,8 @@ impl Page { log.next_id() }; - let subresources = pardus_debug::discover::discover_subresources( - &self.html, - &self.base_url, - start_id, - ); + let subresources = + pardus_debug::discover::discover_subresources(&self.html, &self.base_url, start_id); let mut log = log.lock().unwrap(); for record in subresources { @@ -836,7 +864,56 @@ impl Page { client: &rquest::Client, log: &Arc>, ) { - pardus_debug::fetch::fetch_subresources(client, log, 6).await; + // 1. Extract unfetched records with their types and IDs + let entries: Vec<(usize, String, ResourceType)> = { + let guard = log.lock().unwrap(); + guard + .records + .iter() + .filter(|r| r.status.is_none() && r.error.is_none()) + .map(|r| (r.id, r.url.clone(), r.resource_type.clone())) + .collect() + }; + + if entries.is_empty() { + return; + } + + // 2. Map to ResourceTask with priority based on resource type + let tasks: Vec = entries + .iter() + .map(|(_, url, rt)| { + let kind = resource_kind(rt); + let priority = resource_type_priority(rt); + ResourceTask::new(url.clone(), kind, priority) + }) + .collect(); + + // 3. Build scheduler with default config + let cache = Arc::new(crate::cache::ResourceCache::new(10 * 1024 * 1024)); + let config = ResourceConfig::default(); + let scheduler = Arc::new(ResourceScheduler::new(client.clone(), config, cache)); + + // 4. Fetch with priority ordering + let results = scheduler.schedule_batch(tasks).await; + + // 5. Write results back into NetworkLog + let mut guard = log.lock().unwrap(); + for result in &results { + if let Some(record) = guard.records.iter_mut().find(|r| r.url == result.url) { + if result.error.is_none() { + record.status = Some(result.status); + record.status_text = + Some(if result.status < 400 { "OK" } else { "Error" }.to_string()); + } else { + record.error = result.error.clone(); + } + record.body_size = Some(result.size); + record.content_type = result.content_type.clone(); + record.timing_ms = Some(result.duration_ms as u128); + record.response_headers = result.response_headers_vec(); + } + } } /// Resolve a URL relative to this page's base URL, preserving @@ -856,9 +933,12 @@ impl Page { let mut merged = base.clone(); let relative = match Url::parse(&format!("https://dummy.com{}", href)) { Ok(u) => u, - Err(_) => return base.join(href) - .map(|u| u.to_string()) - .unwrap_or_else(|_| href.to_string()), + Err(_) => { + return base + .join(href) + .map(|u| u.to_string()) + .unwrap_or_else(|_| href.to_string()); + } }; let mut pairs: Vec<(String, String)> = base @@ -890,13 +970,15 @@ impl Page { .unwrap_or_else(|_| href.to_string()) } - fn extract_base_url(html: &Html, fallback: &str, csp: Option<&crate::csp::CspPolicySet>) -> String { + fn extract_base_url( + html: &Html, + fallback: &str, + csp: Option<&crate::csp::CspPolicySet>, + ) -> String { if let Ok(selector) = Selector::parse("base[href]") { if let Some(base_el) = html.select(&selector).next() { if let Some(href) = base_el.value().attr("href") { - if let Ok(resolved) = Url::parse(fallback) - .and_then(|base| base.join(href)) - { + if let Ok(resolved) = Url::parse(fallback).and_then(|base| base.join(href)) { // CSP: check base-uri directive if let Some(csp_policy) = csp { if let Ok(fallback_url) = Url::parse(fallback) { @@ -905,14 +987,16 @@ impl Page { let check = csp_policy.check_base_uri(&origin, &resolved_url); if !check.allowed { if let Some(ref directive) = check.violated_directive { - crate::csp::report_violation(&crate::csp::CspViolation { - document_uri: fallback.to_string(), - blocked_uri: resolved.to_string(), - effective_directive: directive.clone(), - original_policy: String::new(), - disposition: crate::csp::Disposition::Enforce, - status_code: 0, - }); + crate::csp::report_violation( + &crate::csp::CspViolation { + document_uri: fallback.to_string(), + blocked_uri: resolved.to_string(), + effective_directive: directive.clone(), + original_policy: String::new(), + disposition: crate::csp::Disposition::Enforce, + status_code: 0, + }, + ); } return fallback.to_string(); } @@ -966,7 +1050,11 @@ impl Page { let lower = url_part.to_lowercase(); if lower.starts_with("url ") { let rest = url_part[4..].trim_start(); - Some(rest.strip_prefix("=").map(|u| u.trim_start()).unwrap_or(rest)) + Some( + rest.strip_prefix("=") + .map(|u| u.trim_start()) + .unwrap_or(rest), + ) } else { None } @@ -998,9 +1086,7 @@ impl Page { doc.select(&selector).next().and_then(|el| { let href = el.value().attr("data-pardus-navigation-href")?; let trimmed = href.trim(); - if trimmed.is_empty() - || trimmed.starts_with('#') - || trimmed.starts_with("javascript:") + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("javascript:") { None } else { @@ -1064,7 +1150,8 @@ fn http_status_text(status: u16) -> String { 502 => "Bad Gateway", 503 => "Service Unavailable", _ => "", - }.to_string() + } + .to_string() } fn format_http_version(version: http::Version) -> String { @@ -1075,12 +1162,16 @@ fn format_http_version(version: http::Version) -> String { http::Version::HTTP_2 => "HTTP/2", http::Version::HTTP_3 => "HTTP/3", _ => "unknown", - }.to_string() + } + .to_string() } /// Validate that the response content type is HTML-compatible. /// Returns an error for binary or non-text responses (e.g. audio, images). -pub(crate) fn validate_content_type_pub(content_type: Option<&str>, url: &str) -> anyhow::Result<()> { +pub(crate) fn validate_content_type_pub( + content_type: Option<&str>, + url: &str, +) -> anyhow::Result<()> { if let Some(ct) = content_type { let ct_lower = ct.to_lowercase(); let is_html = ct_lower.contains("text/html") @@ -1106,12 +1197,7 @@ pub(crate) fn validate_content_type_pub(content_type: Option<&str>, url: &str) - // HTTP/2 push simulation: speculative early resource fetching // --------------------------------------------------------------------------- -fn spawn_push_fetches( - client: &rquest::Client, - html_body: &str, - base_url: &str, - enabled: bool, -) { +fn spawn_push_fetches(client: &rquest::Client, html_body: &str, base_url: &str, enabled: bool) { if !enabled { return; } @@ -1193,7 +1279,10 @@ fn find_node_by_action<'a>( None } -fn find_node_by_element_id<'a>(node: &'a SemanticNode, target_id: usize) -> Option<&'a SemanticNode> { +fn find_node_by_element_id<'a>( + node: &'a SemanticNode, + target_id: usize, +) -> Option<&'a SemanticNode> { if node.element_id == Some(target_id) { return Some(node); } @@ -1333,8 +1422,7 @@ fn element_matches_node(el: &ElementRef, node: &SemanticNode) -> bool { /// Compute exponential backoff delay with jitter. fn compute_backoff(attempt: u32, config: &crate::config::RetryConfig) -> u64 { - let base = config.initial_backoff_ms as f64 - * config.backoff_factor.powi((attempt as i32) - 1); + let base = config.initial_backoff_ms as f64 * config.backoff_factor.powi((attempt as i32) - 1); // Add up to 30% jitter to spread retries let jitter = fastrand::f64() * 0.3 * base; let delay = (base + jitter) as u64; @@ -1343,16 +1431,13 @@ fn compute_backoff(attempt: u32, config: &crate::config::RetryConfig) -> u64 { #[cfg(test)] mod tests { - use super::*; use url::Url; - fn parse(html: &str) -> Html { - Html::parse_document(html) - } + use super::*; - fn base() -> Url { - Url::parse("https://example.com/page").unwrap() - } + fn parse(html: &str) -> Html { Html::parse_document(html) } + + fn base() -> Url { Url::parse("https://example.com/page").unwrap() } // ==================== parse_meta_refresh tests ==================== @@ -1393,7 +1478,8 @@ mod tests { #[test] fn test_meta_refresh_reload_only() { - let html = r#""#; + let html = + r#""#; let result = Page::parse_meta_refresh(&parse(html), &base()); assert_eq!(result, None); } @@ -1457,7 +1543,8 @@ mod tests { #[cfg(feature = "js")] #[test] fn test_parse_js_nav_href_hash() { - let html = r##""##; + let html = + r##""##; let result = Page::parse_js_navigation_href(html); assert_eq!(result, None); } @@ -1481,7 +1568,8 @@ mod tests { #[cfg(feature = "js")] #[test] fn test_parse_js_nav_href_relative() { - let html = r#""#; + let html = + r#""#; let result = Page::parse_js_navigation_href(html); assert_eq!(result, Some("/new-page".to_string())); } @@ -1489,7 +1577,8 @@ mod tests { #[cfg(feature = "js")] #[test] fn test_parse_js_nav_href_whitespace_trimmed() { - let html = r#""#; + let html = + r#""#; let result = Page::parse_js_navigation_href(html); assert_eq!(result, Some("/trimmed".to_string())); } @@ -1516,7 +1605,10 @@ mod tests { fn test_refresh_content_with_query_params() { let html = r#""#; let result = Page::parse_meta_refresh(&parse(html), &base()); - assert_eq!(result, Some("https://example.com/redirect?foo=bar&baz=1".to_string())); + assert_eq!( + result, + Some("https://example.com/redirect?foo=bar&baz=1".to_string()) + ); } #[test] @@ -1574,7 +1666,10 @@ mod tests { fn test_page_meta_refresh_url_with_refresh() { let html = r#""#; let page = Page::from_html(html, "https://example.com"); - assert_eq!(page.meta_refresh_url(), Some("https://other.com/".to_string())); + assert_eq!( + page.meta_refresh_url(), + Some("https://other.com/".to_string()) + ); } #[test] @@ -1588,14 +1683,20 @@ mod tests { fn test_page_meta_refresh_url_relative() { let html = r#""#; let page = Page::from_html(html, "https://example.com/page"); - assert_eq!(page.meta_refresh_url(), Some("https://example.com/new-path".to_string())); + assert_eq!( + page.meta_refresh_url(), + Some("https://example.com/new-path".to_string()) + ); } #[test] fn test_page_meta_refresh_url_with_base_tag() { let html = r#""#; let page = Page::from_html(html, "https://example.com"); - assert_eq!(page.meta_refresh_url(), Some("https://cdn.example.com/assets/page".to_string())); + assert_eq!( + page.meta_refresh_url(), + Some("https://cdn.example.com/assets/page".to_string()) + ); } // ==================== MAX_REDIRECT_DEPTH tests ==================== @@ -1605,3 +1706,350 @@ mod tests { assert_eq!(Page::MAX_REDIRECT_DEPTH, 5); } } + +// --------------------------------------------------------------------------- +// OAuth redirect capture +// --------------------------------------------------------------------------- + +/// Result of an OAuth-aware navigation that captures redirect callbacks. +pub enum OAuthNavigateResult { + /// Landed on an intermediate page (e.g., login form, consent screen). + Page(Page), + /// Captured a redirect to the callback URL with the authorization code. + Callback { + /// The full callback URL. + url: String, + /// The authorization code extracted from the query string. + code: String, + /// The state parameter from the callback (for CSRF validation). + state: String, + }, +} + +impl Page { + /// Navigate to a URL with redirect interception for OAuth callback capture. + /// + /// Behaves like `from_url` but stops following redirects when the target + /// URL matches the given `callback_url` prefix. This allows extracting the + /// `code` and `state` parameters from OAuth/OIDC callbacks. + /// + /// If no redirect to the callback URL is encountered, returns the final + /// page as `OAuthNavigateResult::Page` (e.g., a login form). + pub async fn navigate_with_redirect_capture( + app: &Arc, + url: &str, + callback_url: &str, + ) -> anyhow::Result { + let callback_prefix = callback_url.to_string(); + let captured_redirect: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + + let max_redirects = app.config.read().max_redirects; + let redirect_hops: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + + let hops_clone = redirect_hops.clone(); + let captured_clone = captured_redirect.clone(); + let max = max_redirects; + + let mut request_builder = app.http_client.get(url); + + request_builder = + request_builder.redirect(rquest::redirect::Policy::custom(move |attempt| { + if attempt.previous().len() >= max { + return attempt.error("too many redirects"); + } + + let target_url = attempt.url().to_string(); + + // Check if this redirect targets the callback URL + if target_url.starts_with(&callback_prefix) + || url_matches_callback(attempt.url(), &callback_prefix) + { + if let Ok(mut captured) = captured_clone.lock() { + *captured = Some(target_url); + } + return attempt.stop(); + } + + // Record the redirect hop + let from = attempt + .previous() + .last() + .map(|u| u.to_string()) + .unwrap_or_default(); + let status = attempt.status().as_u16(); + if let Ok(mut hops) = hops_clone.lock() { + hops.push(RedirectHop { + from, + to: target_url, + status, + }); + } + + attempt.follow() + })); + + let request = request_builder + .build() + .map_err(|e| anyhow::anyhow!("failed to build OAuth navigation request: {e}"))?; + + let response = app + .http_client + .execute(request) + .await + .map_err(|e| anyhow::anyhow!("OAuth navigation request failed: {e}"))?; + + // Check if we captured a redirect to the callback URL + let captured = captured_redirect.lock().unwrap().take(); + + if let Some(callback_target) = captured { + let parsed = Url::parse(&callback_target) + .map_err(|e| anyhow::anyhow!("failed to parse callback URL: {e}"))?; + + let params: std::collections::HashMap = + parsed.query_pairs().into_owned().collect(); + + let code = params.get("code").cloned().ok_or_else(|| { + anyhow::anyhow!("callback URL missing 'code' parameter: {}", callback_target) + })?; + let state = params.get("state").cloned().ok_or_else(|| { + anyhow::anyhow!( + "callback URL missing 'state' parameter: {}", + callback_target + ) + })?; + + return Ok(OAuthNavigateResult::Callback { + url: callback_target, + code, + state, + }); + } + + // No redirect captured — landed on an intermediate page (login form, etc.) + let status = response.status().as_u16(); + let final_url = response.url().to_string(); + let body_bytes = response + .bytes() + .await + .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?; + let body_str = String::from_utf8_lossy(&body_bytes).to_string(); + + let html = Html::parse_document(&body_str); + + let page = Self { + url: final_url, + status, + content_type: Some("text/html".to_string()), + html, + base_url: url.to_string(), + csp: None, + frame_tree: None, + cached_tree: OnceCell::new(), + redirect_chain: None, + }; + + Ok(OAuthNavigateResult::Page(page)) + } +} + +/// Check if a URL matches the callback URL by comparing scheme+host+port+path. +fn url_matches_callback(url: &url::Url, callback_prefix: &str) -> bool { + let Ok(cb_url) = url::Url::parse(callback_prefix) else { + return url.as_str().starts_with(callback_prefix); + }; + + url.scheme() == cb_url.scheme() + && url.host() == cb_url.host() + && url.port() == cb_url.port() + && url.path() == cb_url.path() +} + +/// Map `ResourceType` to `ResourceKind` for scheduling. +fn resource_kind(rt: &ResourceType) -> ResourceKind { + match rt { + ResourceType::Document => ResourceKind::Document, + ResourceType::Stylesheet => ResourceKind::Stylesheet, + ResourceType::Script => ResourceKind::Script, + ResourceType::Image => ResourceKind::Image, + ResourceType::Font => ResourceKind::Font, + ResourceType::Media => ResourceKind::Media, + _ => ResourceKind::Other, + } +} + +/// Map `ResourceType` to a priority band (lower = higher priority). +/// +/// | Band | Value | Types | +/// |------------|-------|------------------------------| +/// | Critical | 0 | Document, Stylesheet | +/// | High | 32 | Script, Font | +/// | Normal | 96 | Fetch, Xhr, WebSocket, Other | +/// | Low | 160 | Image | +/// | Background | 224 | Media | +fn resource_type_priority(rt: &ResourceType) -> u8 { + match rt { + ResourceType::Document | ResourceType::Stylesheet => 0, + ResourceType::Script | ResourceType::Font => 32, + ResourceType::Image => 160, + ResourceType::Media => 224, + _ => 96, + } +} + +impl FetchedResponse { + fn from_dedup_result(result: Arc) -> Self { + Self { + final_url: result.url.clone(), + status: result.status, + content_type: result.content_type.clone(), + resp_headers: result.headers.clone(), + http_version: result.http_version.clone(), + body_bytes: result.body.clone(), + redirect_hops: Vec::new(), + started_at: String::new(), + elapsed_ms: 0, + } + } + + fn from_mock(url: &str, mock: &crate::intercept::MockResponse) -> Self { + let content_type = mock.headers.get("content-type").cloned(); + Self { + final_url: url.to_string(), + status: mock.status, + content_type, + resp_headers: mock + .headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + http_version: String::new(), + body_bytes: mock.body.clone(), + redirect_hops: Vec::new(), + started_at: String::new(), + elapsed_ms: 0, + } + } + + async fn into_page( + self, + app: &Arc, + url_key: &str, + event_sink: Option>, + ) -> anyhow::Result { + let is_pdf = self.content_type.as_ref().map_or(false, |ct| { + ct.split(';').next().unwrap_or(ct).trim().to_lowercase() == "application/pdf" + }); + + if is_pdf { + let body_size = self.body_bytes.len(); + let config = app.config.read(); + if config.sandbox.max_page_size > 0 && body_size > config.sandbox.max_page_size { + app.dedup.remove(url_key); + anyhow::bail!( + "PDF size ({} bytes) exceeds sandbox limit ({} bytes)", + body_size, + config.sandbox.max_page_size + ); + } + return Page::from_pdf_bytes( + &self.body_bytes, + &self.final_url, + self.status, + self.content_type, + ); + } + + let body_str = String::from_utf8_lossy(&self.body_bytes).to_string(); + let body_size = body_str.len(); + + if crate::feed::is_feed_content(body_str.as_bytes(), self.content_type.as_deref()) { + let config = app.config.read(); + if config.sandbox.max_page_size > 0 && body_size > config.sandbox.max_page_size { + app.dedup.remove(url_key); + anyhow::bail!( + "Feed size ({} bytes) exceeds sandbox limit ({} bytes)", + body_size, + config.sandbox.max_page_size + ); + } + return Page::from_feed_bytes( + body_str.as_bytes(), + &self.final_url, + self.status, + self.content_type, + ); + } + + let config = app.config.read(); + if config.sandbox.max_page_size > 0 && body_size > config.sandbox.max_page_size { + app.dedup.remove(url_key); + anyhow::bail!( + "Page size ({} bytes) exceeds sandbox limit ({} bytes)", + body_size, + config.sandbox.max_page_size + ); + } + + validate_content_type_pub(self.content_type.as_deref(), &self.final_url)?; + + let push_enabled = config.push.enable_push && !config.sandbox.disable_push; + let csp_policy = config.csp.parse_policy(&self.resp_headers); + drop(config); + + spawn_push_fetches(&app.http_client, &body_str, &self.final_url, push_enabled); + + if let Some(ref sink) = event_sink { + if let Ok(mut stream_parser) = + crate::parser::streaming_semantic::StreamingHtmlParser::new(&self.final_url, None) + { + if let Ok(new_nodes) = stream_parser.feed(&self.body_bytes) { + for node in &new_nodes { + sink.emit(node.clone()); + } + } + } + } + + let html = Html::parse_document(&body_str); + let base_url = Page::extract_base_url(&html, &self.final_url, csp_policy.as_ref()); + + let config = app.config.read(); + let frame_tree = if config.parse_iframes { + let max_depth = config.max_iframe_depth; + drop(config); + Some( + FrameTree::build( + html.clone(), + &self.final_url, + &base_url, + &app.http_client, + max_depth, + ) + .await, + ) + } else { + drop(config); + None + }; + + Ok(Page { + url: self.final_url, + status: self.status, + content_type: self.content_type, + html, + base_url, + csp: csp_policy, + frame_tree, + cached_tree: OnceCell::new(), + redirect_chain: if self.redirect_hops.is_empty() { + None + } else { + Some(RedirectChain { + hops: self.redirect_hops, + }) + }, + }) + } +} diff --git a/crates/pardus-core/src/parser/mod.rs b/crates/pardus-core/src/parser/mod.rs index 3071e33..17f8be9 100644 --- a/crates/pardus-core/src/parser/mod.rs +++ b/crates/pardus-core/src/parser/mod.rs @@ -7,11 +7,13 @@ pub mod streaming; pub mod arena_dom; pub mod lazy; pub mod preload_scanner; +pub mod streaming_semantic; pub use streaming::{StreamingParser, ParseOptions, ParseResult}; pub use arena_dom::{ArenaDom, Node, NodeId, NodeType}; pub use lazy::{LazyHtml, LazyParse, LazyDom}; pub use preload_scanner::{PreloadScanner, ResourceHint, ResourceType, Priority}; +pub use streaming_semantic::{StreamingEventSink, StreamingParseStats}; use bytes::Bytes; use std::sync::Arc; diff --git a/crates/pardus-core/src/parser/streaming_semantic.rs b/crates/pardus-core/src/parser/streaming_semantic.rs new file mode 100644 index 0000000..b63f227 --- /dev/null +++ b/crates/pardus-core/src/parser/streaming_semantic.rs @@ -0,0 +1,440 @@ +//! Streaming semantic HTML parser using lol_html. +//! +//! Discovers semantic elements (links, buttons, inputs, headings, etc.) as HTML +//! chunks arrive from the network. Bytes pass through unchanged so the existing +//! full-DOM `scraper::Html` path can run afterward. + +use lol_html::element; +use lol_html::AsciiCompatibleEncoding; +use lol_html::HtmlRewriter; +use lol_html::MemorySettings; +use lol_html::Settings; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Mutex; +use std::time::Instant; +use url::Url; + +use crate::semantic::extract::{ + compute_action, compute_name_from_attrs, compute_role, check_interactive, AttrMap, +}; + +// --------------------------------------------------------------------------- +// StreamingSemanticNode +// --------------------------------------------------------------------------- + +/// Lightweight semantic element discovered during streaming HTML parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamingSemanticNode { + pub role: String, + pub name: Option, + pub tag: String, + pub interactive: bool, + pub disabled: bool, + pub href: Option, + pub action: Option, + pub input_type: Option, + pub placeholder: Option, + /// Discovery ordinal (1-based, monotonically increasing). + pub ordinal: usize, +} + +// --------------------------------------------------------------------------- +// StreamingParseResult / Stats +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub struct StreamingParseResult { + pub body: Vec, + pub nodes: Vec, + pub stats: StreamingParseStats, +} + +#[derive(Debug, Default)] +pub struct StreamingParseStats { + pub bytes_processed: usize, + pub elements_seen: usize, + pub interactive_elements: usize, + pub first_node_latency_us: Option, + pub total_time_us: u64, +} + +// --------------------------------------------------------------------------- +// StreamingEventSink +// --------------------------------------------------------------------------- + +/// Callback for receiving streaming semantic nodes. +pub trait StreamingEventSink: Send + Sync { + fn emit(&self, node: StreamingSemanticNode); +} + +/// Collects nodes into a Vec for testing. +pub struct VecSink(pub Mutex>); + +impl VecSink { + pub fn new() -> Self { + Self(Mutex::new(Vec::new())) + } +} + +impl StreamingEventSink for VecSink { + fn emit(&self, node: StreamingSemanticNode) { + self.0.lock().unwrap().push(node); + } +} + +// --------------------------------------------------------------------------- +// Internal callback state +// --------------------------------------------------------------------------- + +struct CallbackState { + base_url: String, + pending_nodes: Vec, + all_nodes: Vec, + ordinal: usize, + start: Instant, + first_node_seen: bool, + first_node_latency_us: Option, +} + +/// Tags that never carry semantic meaning for agents. +const SKIP_TAGS: &[&str] = &[ + "script", "style", "link", "meta", "noscript", "head", "html", "body", "br", "hr", + "col", "colgroup", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "title", + "base", "iframe", "frame", +]; + +// --------------------------------------------------------------------------- +// StreamingHtmlParser +// --------------------------------------------------------------------------- + +pub struct StreamingHtmlParser { + rewriter: HtmlRewriter<'static, Box>, + output_buffer: Rc>>, + callback_state: Rc>, + sink: Option>, +} + +impl StreamingHtmlParser { + pub fn new(base_url: &str, sink: Option>) -> anyhow::Result { + let output_buffer = Rc::new(RefCell::new(Vec::with_capacity(64 * 1024))); + let callback_state = Rc::new(RefCell::new(CallbackState { + base_url: base_url.to_string(), + pending_nodes: Vec::new(), + all_nodes: Vec::new(), + ordinal: 0, + start: Instant::now(), + first_node_seen: false, + first_node_latency_us: None, + })); + + let cb = callback_state.clone(); + + let handler = move |el: &mut lol_html::html_content::Element| { + let tag = el.tag_name(); + let tag_lower = tag.to_ascii_lowercase(); + + if SKIP_TAGS.contains(&tag_lower.as_str()) { + return Ok(()); + } + + let attrs: Vec<(String, String)> = el + .attributes() + .iter() + .map(|a| (a.name().to_ascii_lowercase(), a.value())) + .collect(); + + let attr_map = AttrMap::new(tag_lower.clone(), attrs); + + if attr_map.attr("hidden").is_some() + || attr_map.attr("aria-hidden") == Some("true") + { + return Ok(()); + } + if tag_lower == "input" { + if let Some(t) = attr_map.attr("type") { + if t.eq_ignore_ascii_case("hidden") { + return Ok(()); + } + } + } + + let name_from_attrs = compute_name_from_attrs(&attr_map); + let has_name = name_from_attrs.is_some(); + let role = compute_role(&tag_lower, &attr_map, has_name); + let is_interactive = check_interactive(&tag_lower, &attr_map); + let action = compute_action(&tag_lower, &attr_map, is_interactive); + let is_disabled = attr_map.attr("disabled").is_some(); + + let href = if tag_lower == "a" { + attr_map.attr("href").map(|h| { + Url::parse(&cb.borrow().base_url) + .and_then(|base| base.join(&h)) + .map(|u| u.to_string()) + .unwrap_or_else(|_| h.to_string()) + }) + } else { + None + }; + + let input_type = if tag_lower == "input" { + attr_map.attr("type").map(|s| s.to_string()) + } else { + None + }; + let placeholder = if matches!(tag_lower.as_str(), "input" | "textarea") { + attr_map.attr("placeholder").map(|s| s.to_string()) + } else { + None + }; + + let mut st = cb.borrow_mut(); + st.ordinal += 1; + if !st.first_node_seen { + st.first_node_seen = true; + st.first_node_latency_us = Some(st.start.elapsed().as_micros() as u64); + } + + let node = StreamingSemanticNode { + role: format!("{}", role), + name: name_from_attrs, + tag: tag_lower, + interactive: is_interactive, + disabled: is_disabled, + href, + action, + input_type, + placeholder, + ordinal: st.ordinal, + }; + + st.pending_nodes.push(node.clone()); + st.all_nodes.push(node); + Ok(()) + }; + + let ob_clone = output_buffer.clone(); + + let settings = Settings { + element_content_handlers: vec![ + element!("*", handler), + ], + document_content_handlers: vec![], + encoding: AsciiCompatibleEncoding::utf_8(), + memory_settings: MemorySettings { + max_allowed_memory_usage: 10 * 1024 * 1024, + ..MemorySettings::default() + }, + strict: false, + enable_esi_tags: false, + adjust_charset_on_meta_tag: false, + }; + + let output_sink: Box = Box::new(move |chunk: &[u8]| { + ob_clone.borrow_mut().extend_from_slice(chunk); + }); + + let rewriter = HtmlRewriter::new(settings, output_sink); + + Ok(Self { + rewriter, + output_buffer, + callback_state, + sink, + }) + } + + /// Feed a chunk of HTML bytes. Returns new nodes discovered by this chunk. + pub fn feed(&mut self, chunk: &[u8]) -> anyhow::Result> { + self.rewriter.write(chunk)?; + + let new_nodes: Vec = + self.callback_state.borrow_mut().pending_nodes.drain(..).collect(); + + if let Some(ref sink) = self.sink { + for node in &new_nodes { + sink.emit(node.clone()); + } + } + + Ok(new_nodes) + } + + /// Finish the parse. Returns accumulated body, nodes, and stats. + pub fn finish(self) -> anyhow::Result { + self.rewriter.end()?; + + let st = self.callback_state.borrow(); + let ob = self.output_buffer.borrow(); + + let total_time_us = st.start.elapsed().as_micros() as u64; + let interactive = st.all_nodes.iter().filter(|n| n.interactive).count(); + + let stats = StreamingParseStats { + bytes_processed: ob.len(), + elements_seen: st.ordinal, + interactive_elements: interactive, + first_node_latency_us: st.first_node_latency_us, + total_time_us, + }; + + Ok(StreamingParseResult { + body: ob.clone(), + nodes: st.all_nodes.clone(), + stats, + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_streaming_parse() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + + let html = b"\ +

Hello World

\ + \ +
\ +
\ + \ + \ +
\ +
\ + "; + + let new = parser.feed(html).unwrap(); + assert!(!new.is_empty()); + + let result = parser.finish().unwrap(); + assert_eq!(result.body.as_slice(), html); + + let interactive: Vec<_> = result.nodes.iter().filter(|n| n.interactive).collect(); + assert!( + interactive.len() >= 3, + "at least 3 interactive: link, input, button" + ); + + let link = result.nodes.iter().find(|n| n.tag == "a").unwrap(); + assert_eq!(link.href.as_deref(), Some("https://example.com/docs")); + assert_eq!(link.action.as_deref(), Some("navigate")); + + let input = result.nodes.iter().find(|n| n.tag == "input").unwrap(); + assert_eq!(input.action.as_deref(), Some("fill")); + assert_eq!(input.placeholder.as_deref(), Some("Email")); + + let button = result.nodes.iter().find(|n| n.tag == "button").unwrap(); + assert_eq!(button.action.as_deref(), Some("click")); + } + + #[test] + fn test_chunked_input() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + + let chunk1 = b"

Title

"; + let chunk2 = b"Link"; + + let nodes1 = parser.feed(chunk1).unwrap(); + let nodes2 = parser.feed(chunk2).unwrap(); + let result = parser.finish().unwrap(); + + assert_eq!(nodes1.len() + nodes2.len(), result.nodes.len()); + + let mut expected = Vec::new(); + expected.extend_from_slice(chunk1); + expected.extend_from_slice(chunk2); + assert_eq!(result.body, expected); + } + + #[test] + fn test_skips_hidden_and_script() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + + let html = b"\ + \ + \ + \ + \ + \ + "; + + parser.feed(html).unwrap(); + let result = parser.finish().unwrap(); + + assert!(result.nodes.iter().all(|n| n.tag != "script")); + assert!(result.nodes.iter().all(|n| n.tag != "style")); + let button = result.nodes.iter().find(|n| n.tag == "button"); + assert!(button.is_some()); + } + + #[test] + fn test_ordinal_ordering() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + + parser + .feed( + b"First", + ) + .unwrap(); + let result = parser.finish().unwrap(); + + let ordinals: Vec = result.nodes.iter().map(|n| n.ordinal).collect(); + assert_eq!(ordinals, vec![1, 2, 3]); + } + + #[test] + fn test_vec_sink() { + let sink = Rc::new(VecSink::new()); + let mut parser = + StreamingHtmlParser::new("https://example.com", Some(sink.clone())).unwrap(); + + parser + .feed(b"Link") + .unwrap(); + parser.finish().unwrap(); + + let collected = sink.0.lock().unwrap(); + assert_eq!(collected.len(), 2); + } + + #[test] + fn test_relative_url_resolution() { + let mut parser = StreamingHtmlParser::new("https://example.com/page", None).unwrap(); + + parser + .feed(b"DocsSub") + .unwrap(); + let result = parser.finish().unwrap(); + + let links: Vec<_> = result.nodes.iter().filter(|n| n.tag == "a").collect(); + assert_eq!(links[0].href.as_deref(), Some("https://example.com/docs")); + assert_eq!(links[1].href.as_deref(), Some("https://example.com/sub")); + } + + #[test] + fn test_first_node_latency() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + parser + .feed(b"

Title

") + .unwrap(); + let result = parser.finish().unwrap(); + assert!(result.stats.first_node_latency_us.is_some()); + assert!(result.stats.first_node_latency_us.unwrap() < 1_000_000); + } + + #[test] + fn test_empty_input() { + let mut parser = StreamingHtmlParser::new("https://example.com", None).unwrap(); + parser.feed(b"").unwrap(); + let result = parser.finish().unwrap(); + assert!(result.nodes.is_empty()); + assert!(result.stats.first_node_latency_us.is_none()); + } +} diff --git a/crates/pardus-core/src/resource/fetcher.rs b/crates/pardus-core/src/resource/fetcher.rs index e397ccc..9e40ca7 100644 --- a/crates/pardus-core/src/resource/fetcher.rs +++ b/crates/pardus-core/src/resource/fetcher.rs @@ -1,13 +1,16 @@ //! HTTP resource fetcher with optimized client and HTTP cache compliance -use super::ResourceConfig; -use crate::cache::{CachedResource, ResourceCache}; -use crate::push::PushCache; +use std::{sync::Arc, time::Instant}; + use bytes::Bytes; use rquest::header::HeaderMap; -use std::sync::Arc; -use std::time::Instant; -use tracing::{trace, instrument}; +use tracing::{instrument, trace}; + +use super::ResourceConfig; +use crate::{ + cache::{CachedResource, ResourceCache}, + push::PushCache, +}; /// Fetch options #[derive(Debug, Clone)] @@ -80,8 +83,14 @@ impl FetchResult { self.error.is_none() && self.status >= 200 && self.status < 300 } - pub fn is_not_modified(&self) -> bool { - self.status == 304 + pub fn is_not_modified(&self) -> bool { self.status == 304 } + + /// Convert response headers to `Vec<(String, String)>` for NetworkLog compatibility. + pub fn response_headers_vec(&self) -> Vec<(String, String)> { + self.response_headers + .iter() + .filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string()))) + .collect() } } @@ -93,9 +102,7 @@ pub struct ResourceFetcher { } impl ResourceFetcher { - pub fn new(client: rquest::Client, config: ResourceConfig) -> Self { - Self { client, config } - } + pub fn new(client: rquest::Client, config: ResourceConfig) -> Self { Self { client, config } } #[instrument(skip(self), level = "trace")] pub async fn fetch(&self, url: &str) -> FetchResult { @@ -105,7 +112,8 @@ impl ResourceFetcher { match self.client.get(url).send().await { Ok(response) => { let status = response.status().as_u16(); - let content_type = response.headers() + let content_type = response + .headers() .get(rquest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); @@ -116,14 +124,20 @@ impl ResourceFetcher { Ok(body) => { let elapsed = start.elapsed(); trace!("fetch complete: {} ({} bytes)", url, body.len()); - let mut result = FetchResult::success(url, status, body, content_type, elapsed.as_millis() as u64); + let mut result = FetchResult::success( + url, + status, + body, + content_type, + elapsed.as_millis() as u64, + ); result.response_headers = headers; result } - Err(e) => FetchResult::error(url, format!("body read error: {}", e)) + Err(e) => FetchResult::error(url, format!("body read error: {}", e)), } } - Err(e) => FetchResult::error(url, format!("request error: {}", e)) + Err(e) => FetchResult::error(url, format!("request error: {}", e)), } } @@ -137,7 +151,8 @@ impl ResourceFetcher { match request.send().await { Ok(response) => { let status = response.status().as_u16(); - let content_type = response.headers() + let content_type = response + .headers() .get(rquest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); @@ -147,7 +162,13 @@ impl ResourceFetcher { match response.bytes().await { Ok(body) => { let elapsed = start.elapsed(); - let mut result = FetchResult::success(url, status, body, content_type, elapsed.as_millis() as u64); + let mut result = FetchResult::success( + url, + status, + body, + content_type, + elapsed.as_millis() as u64, + ); result.response_headers = headers; result } @@ -159,7 +180,11 @@ impl ResourceFetcher { } /// Fetch a URL with conditional request headers (for cache revalidation). - pub async fn fetch_conditional(&self, url: &str, conditional_headers: &HeaderMap) -> FetchResult { + pub async fn fetch_conditional( + &self, + url: &str, + conditional_headers: &HeaderMap, + ) -> FetchResult { let start = std::time::Instant::now(); trace!("conditional fetch: {}", url); @@ -171,7 +196,8 @@ impl ResourceFetcher { match request.send().await { Ok(response) => { let status = response.status().as_u16(); - let content_type = response.headers() + let content_type = response + .headers() .get(rquest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); @@ -197,7 +223,13 @@ impl ResourceFetcher { match response.bytes().await { Ok(body) => { let elapsed = start.elapsed(); - let mut result = FetchResult::success(url, status, body, content_type, elapsed.as_millis() as u64); + let mut result = FetchResult::success( + url, + status, + body, + content_type, + elapsed.as_millis() as u64, + ); result.response_headers = headers; result } @@ -217,7 +249,8 @@ impl ResourceFetcher { pub async fn content_length(&self, url: &str) -> Option { match self.client.head(url).send().await { - Ok(response) => response.headers() + Ok(response) => response + .headers() .get(rquest::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()), @@ -230,7 +263,11 @@ impl ResourceFetcher { /// If a valid entry exists in the push cache, returns it immediately /// without making an HTTP request. Otherwise falls through to a normal /// fetch and does NOT store the result in the push cache. - pub fn fetch_with_push_cache(&self, url: &str, push_cache: &PushCache) -> std::future::Ready { + pub fn fetch_with_push_cache( + &self, + url: &str, + push_cache: &PushCache, + ) -> std::future::Ready { if let Some(entry) = push_cache.get(url) { trace!("push cache hit for fetch: {}", url); let mut result = FetchResult::success( @@ -247,7 +284,10 @@ impl ResourceFetcher { let url_owned = url.to_string(); let _ = url_owned; - std::future::ready(FetchResult::error(&url_owned, "push cache miss, use fetch() instead")) + std::future::ready(FetchResult::error( + &url_owned, + "push cache miss, use fetch() instead", + )) } } @@ -329,7 +369,8 @@ impl CachedFetcher { let cond_result = self.fetcher.fetch_conditional(url, &cond_headers).await; if cond_result.is_not_modified() { - self.cache.update_from_304(url, &cond_result.response_headers); + self.cache + .update_from_304(url, &cond_result.response_headers); if let Some(entry) = self.cache.get(url) { let guard = entry.read().unwrap(); let cached = (*guard).clone(); @@ -361,17 +402,11 @@ impl CachedFetcher { } /// Fetch without caching (force bypass). - pub async fn fetch_bypass(&self, url: &str) -> FetchResult { - self.fetcher.fetch(url).await - } + pub async fn fetch_bypass(&self, url: &str) -> FetchResult { self.fetcher.fetch(url).await } - pub fn invalidate(&self, url: &str) { - self.cache.invalidate(url); - } + pub fn invalidate(&self, url: &str) { self.cache.invalidate(url); } - pub fn clear(&self) { - self.cache.clear(); - } + pub fn clear(&self) { self.cache.clear(); } } enum CacheAction { diff --git a/crates/pardus-core/src/resource/mod.rs b/crates/pardus-core/src/resource/mod.rs index 2f2a8ed..ad594f4 100644 --- a/crates/pardus-core/src/resource/mod.rs +++ b/crates/pardus-core/src/resource/mod.rs @@ -2,23 +2,25 @@ //! //! Implements parallel subresource fetching with intelligent scheduling. -pub mod scheduler; pub mod fetcher; -pub mod priority; pub mod pool; - -pub use scheduler::{ResourceScheduler, ResourceTask, ScheduleResult}; -pub use fetcher::{ResourceFetcher, FetchOptions, FetchResult}; -pub use priority::{PriorityQueue, PriorityTask}; -pub use pool::{ConnectionPool, H2Connection, PoolConfig}; +pub mod priority; +pub mod scheduler; use std::sync::Arc; +pub use fetcher::{FetchOptions, FetchResult, ResourceFetcher}; +pub use pool::{ConnectionPool, H2Connection, PoolConfig}; +pub use priority::{PriorityQueue, PriorityTask}; +pub use scheduler::{ResourceScheduler, ResourceTask, ScheduleResult}; + /// Resource loading configuration #[derive(Debug, Clone)] pub struct ResourceConfig { /// Max concurrent connections per origin pub max_concurrent: usize, + /// Global concurrency cap across all origins + pub global_concurrency: usize, /// HTTP/2 stream limit pub h2_stream_limit: usize, /// Connection pool size @@ -35,6 +37,7 @@ impl Default for ResourceConfig { fn default() -> Self { Self { max_concurrent: 6, + global_concurrency: 12, h2_stream_limit: 100, pool_size: 32, h2_priority: true, @@ -77,23 +80,19 @@ use crate::cache::ResourceCache; /// Resource manager that coordinates all resource operations pub struct ResourceManager { scheduler: Arc, - #[allow(dead_code)] - config: ResourceConfig, } impl ResourceManager { pub fn new(client: rquest::Client, config: ResourceConfig, cache: Arc) -> Self { let scheduler = Arc::new(ResourceScheduler::new(client, config.clone(), cache)); - Self { scheduler, config } + Self { scheduler } } /// Fetch multiple resources in parallel - pub async fn fetch_batch( - &self, - resources: Vec, - ) -> Vec { + pub async fn fetch_batch(&self, resources: Vec) -> Vec { let scheduler = self.scheduler.clone(); - let tasks: Vec<_> = resources.into_iter() + let tasks: Vec<_> = resources + .into_iter() .map(|r| ResourceTask::from(r)) .collect(); diff --git a/crates/pardus-core/src/resource/pool.rs b/crates/pardus-core/src/resource/pool.rs index 15e0a4f..651e394 100644 --- a/crates/pardus-core/src/resource/pool.rs +++ b/crates/pardus-core/src/resource/pool.rs @@ -1,7 +1,7 @@ //! HTTP/2 connection pool management -use std::collections::HashMap; -use std::time::Duration; +use std::{collections::HashMap, time::Duration}; + use parking_lot::RwLock; use tracing::trace; @@ -45,10 +45,7 @@ impl ConnectionPool { } /// Get or create connection to origin - pub async fn get_connection( - &self, - origin: &str, - ) -> anyhow::Result { + pub async fn get_connection(&self, origin: &str) -> anyhow::Result { self.cleanup(); // Try to get existing idle connection @@ -83,10 +80,7 @@ impl ConnectionPool { } /// Create new connection - async fn create_connection( - &self, - origin: &str, - ) -> anyhow::Result { + async fn create_connection(&self, origin: &str) -> anyhow::Result { trace!("creating new connection to {}", origin); // Parse origin for connection @@ -108,9 +102,7 @@ impl ConnectionPool { { let mut conns = self.connections.write(); - conns.entry(origin.to_string()) - .or_default() - .push(conn); + conns.entry(origin.to_string()).or_default().push(conn); } Ok(ConnectionHandle { @@ -120,9 +112,7 @@ impl ConnectionPool { } /// Release connection back to pool - pub fn release_connection(&self, - handle: ConnectionHandle, - ) { + pub fn release_connection(&self, handle: ConnectionHandle) { let mut conns = self.connections.write(); if let Some(origin_conns) = conns.get_mut(&handle.origin) { if let Some(conn) = origin_conns.iter_mut().find(|c| c.id == handle.id) { @@ -154,7 +144,8 @@ impl ConnectionPool { pub fn stats(&self) -> PoolStats { let conns = self.connections.read(); let total = conns.values().map(|v| v.len()).sum(); - let idle = conns.values() + let idle = conns + .values() .flat_map(|v| v.iter()) .filter(|c| c.is_idle()) .count(); @@ -181,9 +172,7 @@ struct PooledConnection { } impl PooledConnection { - fn is_idle(&self) -> bool { - self.requests_in_flight == 0 - } + fn is_idle(&self) -> bool { self.requests_in_flight == 0 } fn mark_used(&mut self) { self.last_used = std::time::Instant::now(); @@ -228,7 +217,9 @@ impl H2Connection { /// Get next available stream ID pub fn next_stream_id(&self) -> Option { - let id = self.stream_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let id = self + .stream_counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); if id < self.max_concurrent_streams { Some(id) } else { @@ -238,6 +229,7 @@ impl H2Connection { /// Current stream count pub fn stream_count(&self) -> usize { - self.stream_counter.load(std::sync::atomic::Ordering::SeqCst) + self.stream_counter + .load(std::sync::atomic::Ordering::SeqCst) } } diff --git a/crates/pardus-core/src/resource/priority.rs b/crates/pardus-core/src/resource/priority.rs index 67ad496..603a4eb 100644 --- a/crates/pardus-core/src/resource/priority.rs +++ b/crates/pardus-core/src/resource/priority.rs @@ -1,12 +1,11 @@ //! Priority queue for resource scheduling -use std::cmp::Ordering; -use std::collections::BinaryHeap; +use std::{cmp::Ordering, collections::BinaryHeap}; /// Task with priority for the queue #[derive(Debug, Clone)] pub struct PriorityTask { - priority: u8, // Lower = higher priority + priority: u8, // Lower = higher priority sequence: u64, // FIFO for equal priorities task: T, } @@ -31,15 +30,15 @@ impl PartialEq for PriorityTask { impl Eq for PriorityTask {} impl PartialOrd for PriorityTask { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for PriorityTask { fn cmp(&self, other: &Self) -> Ordering { // Reverse ordering: lower priority value = higher actual priority - other.priority.cmp(&self.priority) + other + .priority + .cmp(&self.priority) .then_with(|| other.sequence.cmp(&self.sequence)) } } @@ -67,34 +66,23 @@ impl PriorityQueue { } /// Push task with priority (0 = highest priority) - pub fn push(&mut self, - priority: u8, - task: T, - ) { + pub fn push(&mut self, priority: u8, task: T) { self.sequence += 1; let pt = PriorityTask::new(priority, self.sequence, task); self.heap.push(pt); } /// Pop highest priority task - pub fn pop(&mut self) -> Option<(u8, T)> { - self.heap.pop().map(|pt| (pt.priority, pt.task)) - } + pub fn pop(&mut self) -> Option<(u8, T)> { self.heap.pop().map(|pt| (pt.priority, pt.task)) } /// Peek at highest priority without removing - pub fn peek(&self) -> Option<(&u8, &T)> { - self.heap.peek().map(|pt| (&pt.priority, &pt.task)) - } + pub fn peek(&self) -> Option<(&u8, &T)> { self.heap.peek().map(|pt| (&pt.priority, &pt.task)) } /// Number of tasks - pub fn len(&self) -> usize { - self.heap.len() - } + pub fn len(&self) -> usize { self.heap.len() } /// Is empty - pub fn is_empty(&self) -> bool { - self.heap.is_empty() - } + pub fn is_empty(&self) -> bool { self.heap.is_empty() } /// Drain all tasks pub fn drain(self) -> impl Iterator { @@ -116,16 +104,14 @@ impl PriorityQueue { } impl Default for PriorityQueue { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } /// Multi-level priority queue /// Different queues for different priority levels #[derive(Debug)] pub struct MultiLevelQueue { - critical: Vec, // Priority 0-31 + critical: Vec, // Priority 0-31 high: Vec, // Priority 32-95 normal: Vec, // Priority 96-159 low: Vec, // Priority 160-223 @@ -155,7 +141,8 @@ impl MultiLevelQueue { /// Get all tasks in priority order pub fn all_tasks(self) -> impl Iterator { - self.critical.into_iter() + self.critical + .into_iter() .chain(self.high) .chain(self.normal) .chain(self.low) @@ -164,9 +151,7 @@ impl MultiLevelQueue { } impl Default for MultiLevelQueue { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } #[cfg(test)] diff --git a/crates/pardus-core/src/resource/scheduler.rs b/crates/pardus-core/src/resource/scheduler.rs index ec5735d..84d60db 100644 --- a/crates/pardus-core/src/resource/scheduler.rs +++ b/crates/pardus-core/src/resource/scheduler.rs @@ -1,16 +1,18 @@ //! Resource scheduler with HTTP/2 prioritization and cache support -use super::{Resource, ResourceConfig, ResourceKind}; -use super::priority::PriorityQueue; -use super::fetcher::{CachedFetcher, FetchResult}; - +use std::{collections::HashMap, sync::Arc}; + +use tokio::{ + sync::{Semaphore, mpsc}, + task::JoinSet, +}; +use tracing::{debug, instrument}; + +use super::{ + Resource, ResourceConfig, ResourceKind, + fetcher::{CachedFetcher, FetchResult}, +}; use crate::cache::ResourceCache; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::{mpsc, Semaphore}; -use tokio::task::JoinSet; -use tracing::{instrument, debug}; -use url::Url; #[derive(Debug, Clone)] pub struct ResourceTask { @@ -23,11 +25,16 @@ pub struct ResourceTask { impl ResourceTask { pub fn new(url: String, kind: ResourceKind, priority: u8) -> Self { let origin = Self::extract_origin(&url); - Self { url, kind, priority, origin } + Self { + url, + kind, + priority, + origin, + } } fn extract_origin(url: &str) -> String { - Url::parse(url) + url::Url::parse(url) .ok() .map(|u| u.origin().ascii_serialization()) .unwrap_or_default() @@ -35,9 +42,7 @@ impl ResourceTask { } impl From for ResourceTask { - fn from(r: Resource) -> Self { - Self::new(r.url, r.kind, r.priority) - } + fn from(r: Resource) -> Self { Self::new(r.url, r.kind, r.priority) } } #[derive(Debug)] @@ -51,41 +56,38 @@ pub struct ResourceScheduler { config: ResourceConfig, fetcher: Arc, origin_semaphores: parking_lot::Mutex>>, + global_semaphore: Arc, } impl ResourceScheduler { pub fn new(client: rquest::Client, config: ResourceConfig, cache: Arc) -> Self { let fetcher = Arc::new(CachedFetcher::new(client, config.clone(), cache)); + let global_semaphore = Arc::new(Semaphore::new(config.global_concurrency)); Self { config, fetcher, origin_semaphores: parking_lot::Mutex::new(HashMap::new()), + global_semaphore, } } #[instrument(skip(self, tasks), level = "debug")] - pub async fn schedule_batch( - self: Arc, - tasks: Vec, - ) -> Vec { + pub async fn schedule_batch(self: Arc, tasks: Vec) -> Vec { let start = std::time::Instant::now(); debug!("scheduling {} tasks", tasks.len()); - let mut queue = PriorityQueue::new(); - for task in tasks { - queue.push(task.priority, task); - } + // Sort tasks by priority (lower u8 = higher priority) + let mut sorted_tasks = tasks; + sorted_tasks.sort_by_key(|t| t.priority); - let by_origin = self.group_by_origin(&queue.into_vec()); + let by_origin = self.group_by_origin(&sorted_tasks); let mut results = Vec::new(); let mut join_set = JoinSet::new(); for (origin, origin_tasks) in by_origin { let scheduler = self.clone(); - join_set.spawn(async move { - scheduler.fetch_origin_group(origin, origin_tasks).await - }); + join_set.spawn(async move { scheduler.fetch_origin_group(origin, origin_tasks).await }); } while let Some(Ok(group_results)) = join_set.join_next().await { @@ -93,19 +95,21 @@ impl ResourceScheduler { } let elapsed = start.elapsed(); - debug!("batch fetch completed in {:?}, {} results", elapsed, results.len()); + debug!( + "batch fetch completed in {:?}, {} results", + elapsed, + results.len() + ); results } - fn group_by_origin( - &self, - tasks: &[ResourceTask], - ) -> HashMap> { + fn group_by_origin(&self, tasks: &[ResourceTask]) -> HashMap> { let mut groups: HashMap> = HashMap::new(); for task in tasks { - groups.entry(task.origin.clone()) + groups + .entry(task.origin.clone()) .or_default() .push(task.clone()); } @@ -113,33 +117,51 @@ impl ResourceScheduler { groups } + /// Fetch all tasks for a single origin concurrently, respecting both + /// per-origin and global concurrency limits. Tasks are spawned in + /// priority order so higher-priority tasks acquire semaphore permits first. async fn fetch_origin_group( self: Arc, origin: String, - tasks: Vec, + mut tasks: Vec, ) -> Vec { + // Sort by priority within this origin group + tasks.sort_by_key(|t| t.priority); + let semaphore = self.get_origin_semaphore(&origin); - let mut results = Vec::new(); + let mut join_set = JoinSet::new(); for task in tasks { - let permit = semaphore.clone().acquire_owned().await; - if permit.is_err() { - results.push(FetchResult::error(&task.url, "semaphore closed")); - continue; - } - - let result = self.fetcher.fetch(&task.url).await; - results.push(result); + let sem = semaphore.clone(); + let global_sem = self.global_semaphore.clone(); + let fetcher = self.fetcher.clone(); - drop(permit); + join_set.spawn(async move { + // Acquire per-origin permit first, then global + let _origin_permit = match sem.acquire_owned().await { + Ok(p) => p, + Err(_) => return FetchResult::error(&task.url, "origin semaphore closed"), + }; + let _global_permit = match global_sem.acquire_owned().await { + Ok(p) => p, + Err(_) => return FetchResult::error(&task.url, "global semaphore closed"), + }; + + fetcher.fetch(&task.url).await + }); } + let mut results = Vec::new(); + while let Some(Ok(r)) = join_set.join_next().await { + results.push(r); + } results } fn get_origin_semaphore(&self, origin: &str) -> Arc { let mut semaphores = self.origin_semaphores.lock(); - semaphores.entry(origin.to_string()) + semaphores + .entry(origin.to_string()) .or_insert_with(|| Arc::new(Semaphore::new(self.config.max_concurrent))) .clone() } @@ -151,22 +173,10 @@ impl ResourceScheduler { } pub async fn schedule_with_priority( - &self, + self: Arc, tasks: Vec, - _priority_hints: HashMap, ) -> Vec { - let mut queue = PriorityQueue::new(); - for task in tasks { - queue.push(task.priority, task); - } - - let mut results = Vec::new(); - for (_, task) in queue.drain() { - let result = self.fetcher.fetch(&task.url).await; - results.push(result); - } - - results + self.schedule_batch(tasks).await } } @@ -175,28 +185,26 @@ pub struct CriticalPathFetcher { } impl CriticalPathFetcher { - pub fn new(scheduler: Arc) -> Self { - Self { scheduler } - } + pub fn new(scheduler: Arc) -> Self { Self { scheduler } } - pub async fn fetch_critical( - &self, - resources: Vec, - ) -> Vec { - let (critical, non_critical): (Vec<_>, Vec<_>) = resources.into_iter() - .partition(|r| matches!(r.kind, ResourceKind::Stylesheet | ResourceKind::Script)); + /// Fetch critical resources (stylesheets, scripts) first, then everything else. + pub async fn fetch_critical(self: Arc, resources: Vec) -> Vec { + let (critical, non_critical): (Vec<_>, Vec<_>) = resources + .into_iter() + .partition(|r| matches!(r.kind, ResourceKind::Document | ResourceKind::Stylesheet)); - let critical_tasks: Vec<_> = critical.into_iter() - .map(|r| ResourceTask::from(r)) - .collect(); + let critical_tasks: Vec<_> = critical.into_iter().map(ResourceTask::from).collect(); let mut results = self.scheduler.clone().schedule_batch(critical_tasks).await; if !non_critical.is_empty() { - let non_critical_tasks: Vec<_> = non_critical.into_iter() - .map(|r| ResourceTask::from(r)) - .collect(); - let more_results = self.scheduler.clone().schedule_batch(non_critical_tasks).await; + let non_critical_tasks: Vec<_> = + non_critical.into_iter().map(ResourceTask::from).collect(); + let more_results = self + .scheduler + .clone() + .schedule_batch(non_critical_tasks) + .await; results.extend(more_results); } @@ -221,10 +229,12 @@ impl StreamingResourceFetcher { ) -> anyhow::Result<()> { for task in resources { let tx = self.tx.clone(); - let sched = scheduler.clone(); + let fetcher = scheduler.fetcher.clone(); + let global_sem = scheduler.global_semaphore.clone(); tokio::spawn(async move { - let result = sched.fetcher.fetch(&task.url).await; + let _permit = global_sem.acquire_owned().await.ok(); + let result = fetcher.fetch(&task.url).await; if tx.send(result).await.is_err() { tracing::debug!("fetch result dropped for {}: receiver gone", task.url); } @@ -234,3 +244,38 @@ impl StreamingResourceFetcher { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::resource::ResourceKind; + + /// Verify the priority band ordering: Document/CSS < Script < Other < Image < Media + #[test] + fn test_priority_ordering_by_resource_kind() { + let doc = priority_for_kind(ResourceKind::Document); + let css = priority_for_kind(ResourceKind::Stylesheet); + let js = priority_for_kind(ResourceKind::Script); + let font = priority_for_kind(ResourceKind::Font); + let other = priority_for_kind(ResourceKind::Other); + let img = priority_for_kind(ResourceKind::Image); + let media = priority_for_kind(ResourceKind::Media); + + assert_eq!(doc, css, "document and stylesheet should be same priority"); + assert!(css < js, "CSS should be higher priority than JS"); + assert_eq!(js, font, "script and font should be same priority"); + assert!(js < other, "JS should be higher priority than Other"); + assert!(other < img, "Other should be higher priority than images"); + assert!(img < media, "images should be higher priority than media"); + } + + fn priority_for_kind(kind: ResourceKind) -> u8 { + match kind { + ResourceKind::Document | ResourceKind::Stylesheet => 0, + ResourceKind::Script | ResourceKind::Font => 32, + ResourceKind::Image => 160, + ResourceKind::Media => 224, + ResourceKind::Other => 96, + } + } +} diff --git a/crates/pardus-core/src/semantic/extract.rs b/crates/pardus-core/src/semantic/extract.rs new file mode 100644 index 0000000..aef0f44 --- /dev/null +++ b/crates/pardus-core/src/semantic/extract.rs @@ -0,0 +1,291 @@ +//! Shared element attribute extraction for both full DOM and streaming parsing. +//! +//! Provides the [`ElementAttrs`] trait that abstracts attribute access over +//! different HTML element types (scraper::ElementRef for full DOM, simple +//! key-value maps for streaming). All semantic role/action/interactivity +//! logic lives here so it can be reused by both `TreeBuilder` and +//! `StreamingHtmlParser`. + +use super::tree::SemanticRole; + +/// Trait for accessing HTML element attributes. +/// +/// Implemented for `scraper::ElementRef` (full DOM path) and a simple +/// attribute-map struct used by the streaming lol_html parser. +pub trait ElementAttrs { + /// The lowercased tag name (e.g. "input", "a", "div"). + fn tag_name(&self) -> &str; + /// Get an attribute value by name (case-insensitive for HTML). + fn attr(&self, name: &str) -> Option<&str>; +} + +// --------------------------------------------------------------------------- +// Implementations +// --------------------------------------------------------------------------- + +impl ElementAttrs for scraper::ElementRef<'_> { + fn tag_name(&self) -> &str { scraper::ElementRef::value(self).name() } + + fn attr(&self, name: &str) -> Option<&str> { scraper::ElementRef::value(self).attr(name) } +} + +/// Attribute source for the streaming parser (lol_html provides attributes +/// as a borrowed map, which we convert to an owned Vec for simplicity). +pub struct AttrMap { + tag: String, + attrs: Vec<(String, String)>, +} + +impl AttrMap { + pub fn new(tag: String, attrs: Vec<(String, String)>) -> Self { Self { tag, attrs } } + + /// Get an attribute value by name (case-insensitive). + pub fn attr(&self, name: &str) -> Option<&str> { + let name_lower = name.to_ascii_lowercase(); + self.attrs + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&name_lower)) + .map(|(_, v)| v.as_str()) + } +} + +impl ElementAttrs for AttrMap { + fn tag_name(&self) -> &str { &self.tag } + + fn attr(&self, name: &str) -> Option<&str> { + let name_lower = name.to_ascii_lowercase(); + self.attrs + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&name_lower)) + .map(|(_, v)| v.as_str()) + } +} + +// --------------------------------------------------------------------------- +// Shared computation functions +// --------------------------------------------------------------------------- + +/// Compute the semantic role from tag name and attributes. +pub fn compute_role(tag: &str, el: &dyn ElementAttrs, has_name: bool) -> SemanticRole { + // Check explicit role attribute first + if let Some(role_str) = el.attr("role") { + return parse_role_str(role_str); + } + + // Implicit roles based on tag + match tag { + "nav" => SemanticRole::Navigation, + "main" => SemanticRole::Main, + "header" => SemanticRole::Banner, + "footer" => SemanticRole::ContentInfo, + "aside" => SemanticRole::Complementary, + "search" => SemanticRole::Search, + "section" if has_name => SemanticRole::Region, + "article" => SemanticRole::Article, + "form" if has_name => SemanticRole::Form, + "form" => SemanticRole::Form, + + "h1" => SemanticRole::Heading { level: 1 }, + "h2" => SemanticRole::Heading { level: 2 }, + "h3" => SemanticRole::Heading { level: 3 }, + "h4" => SemanticRole::Heading { level: 4 }, + "h5" => SemanticRole::Heading { level: 5 }, + "h6" => SemanticRole::Heading { level: 6 }, + + "a" => SemanticRole::Link, + "button" => SemanticRole::Button, + "input" => match el.attr("type").unwrap_or("text") { + "checkbox" => SemanticRole::Checkbox, + "radio" => SemanticRole::Radio, + "file" => SemanticRole::FileInput, + "submit" | "reset" | "button" | "image" => SemanticRole::Button, + _ => SemanticRole::TextBox, + }, + "select" => SemanticRole::Combobox, + "textarea" => SemanticRole::TextBox, + "img" => SemanticRole::Image, + "ul" | "ol" => SemanticRole::List, + "li" => SemanticRole::ListItem, + "table" => SemanticRole::Table, + "dialog" => SemanticRole::Dialog, + + _ => SemanticRole::Generic, + } +} + +/// Check whether an element is interactive. +pub fn check_interactive(tag: &str, el: &dyn ElementAttrs) -> bool { + // Native interactive + if matches!( + tag, + "a" | "button" | "input" | "select" | "textarea" | "details" + ) { + return !(tag == "a" && el.attr("href").is_none()); + } + + // ARIA interactive + if let Some(role) = el.attr("role") { + if matches!( + role, + "button" + | "link" + | "textbox" + | "checkbox" + | "radio" + | "combobox" + | "switch" + | "tab" + | "menuitem" + | "option" + ) { + return true; + } + } + + // Focusable + if let Some(tabindex) = el.attr("tabindex") { + if let Ok(idx) = tabindex.parse::() { + if idx >= 0 { + return true; + } + } + } + + false +} + +/// Compute the semantic action string for an interactive element. +pub fn compute_action(tag: &str, el: &dyn ElementAttrs, is_interactive: bool) -> Option { + if !is_interactive { + return None; + } + + match tag { + "a" => Some("navigate".to_string()), + "button" => Some("click".to_string()), + "input" => { + let input_type = el.attr("type").unwrap_or("text"); + Some(match input_type { + "submit" | "reset" | "button" | "image" => "click".to_string(), + "checkbox" | "radio" => "toggle".to_string(), + "file" => "upload".to_string(), + _ => "fill".to_string(), + }) + } + "select" => Some("select".to_string()), + "textarea" => Some("fill".to_string()), + _ => { + if let Some(role) = el.attr("role") { + match role { + "button" => Some("click".to_string()), + "link" => Some("navigate".to_string()), + "textbox" => Some("fill".to_string()), + _ => None, + } + } else { + None + } + } + } +} + +/// Compute the accessible name from element attributes (no text content — +/// the streaming parser calls this for elements where the name comes from +/// attributes only; the full DOM path also checks text content separately). +pub fn compute_name_from_attrs(el: &dyn ElementAttrs) -> Option { + // aria-label + if let Some(label) = el.attr("aria-label") { + let trimmed = label.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + // title + if let Some(title) = el.attr("title") { + let trimmed = title.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + // alt for images + if el.tag_name() == "img" { + if let Some(alt) = el.attr("alt") { + let trimmed = alt.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + // placeholder for inputs + if matches!(el.tag_name(), "input" | "textarea") { + if let Some(p) = el.attr("placeholder") { + let trimmed = p.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + // value for submit/reset buttons + if el.tag_name() == "input" { + let input_type = el.attr("type").unwrap_or("text"); + if matches!(input_type, "submit" | "reset" | "button" | "image") { + if let Some(value) = el.attr("value") { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + return Some(match input_type { + "submit" => "Submit".to_string(), + "reset" => "Reset".to_string(), + _ => "Button".to_string(), + }); + } + } + + // name attribute fallback for form elements + if matches!(el.tag_name(), "input" | "select" | "textarea") { + if let Some(n) = el.attr("name") { + let trimmed = n.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + None +} + +/// Parse a role string into a [`SemanticRole`]. +pub fn parse_role_str(s: &str) -> SemanticRole { + match s { + "document" => SemanticRole::Document, + "banner" => SemanticRole::Banner, + "navigation" => SemanticRole::Navigation, + "main" => SemanticRole::Main, + "contentinfo" => SemanticRole::ContentInfo, + "complementary" => SemanticRole::Complementary, + "region" => SemanticRole::Region, + "form" => SemanticRole::Form, + "search" => SemanticRole::Search, + "article" => SemanticRole::Article, + "link" => SemanticRole::Link, + "button" => SemanticRole::Button, + "textbox" => SemanticRole::TextBox, + "fileinput" => SemanticRole::FileInput, + "checkbox" => SemanticRole::Checkbox, + "radio" => SemanticRole::Radio, + "combobox" => SemanticRole::Combobox, + "list" => SemanticRole::List, + "listitem" => SemanticRole::ListItem, + "table" => SemanticRole::Table, + "img" => SemanticRole::Image, + "dialog" => SemanticRole::Dialog, + "iframe" => SemanticRole::IFrame, + _ => SemanticRole::Other(s.to_string()), + } +} diff --git a/crates/pardus-core/src/semantic/mod.rs b/crates/pardus-core/src/semantic/mod.rs index 3fceb54..5c03bb4 100644 --- a/crates/pardus-core/src/semantic/mod.rs +++ b/crates/pardus-core/src/semantic/mod.rs @@ -1,3 +1,10 @@ +pub mod extract; +pub mod selector; pub mod tree; +pub use extract::{ + AttrMap, ElementAttrs, check_interactive, compute_action, compute_name_from_attrs, + compute_role, parse_role_str as extract_parse_role_str, +}; +pub use selector::build_unique_selector; pub use tree::{SelectOption, SemanticNode, SemanticRole, SemanticTree, TreeStats}; diff --git a/crates/pardus-core/src/semantic/selector.rs b/crates/pardus-core/src/semantic/selector.rs new file mode 100644 index 0000000..d80c18c --- /dev/null +++ b/crates/pardus-core/src/semantic/selector.rs @@ -0,0 +1,115 @@ +use scraper::{ElementRef, Html, Selector}; + +/// Build a unique CSS selector for an element. +/// +/// - If the element has an `id`, uses `#id`. +/// - Otherwise, prefers attribute-based selectors like `input[name="foo"]` if they are unique in +/// the document. +/// - Falls back to a structural path: `body > div:nth-child(2) > form > input` +pub fn build_unique_selector(el: &ElementRef, html: &Html) -> String { + if let Some(id) = el.value().attr("id") { + return format!("#{}", css_escape_id(id)); + } + + if let Some(name) = el.value().attr("name") { + let tag = el.value().name(); + let candidate = format!("{}[name=\"{}\"]", tag, name); + let is_unique = match Selector::parse(&candidate) { + Ok(sel) => html.select(&sel).count() == 1, + Err(_) => false, + }; + if is_unique { + return candidate; + } + } + + if let Some(href) = el.value().attr("href") { + let tag = el.value().name(); + let escaped = css_escape_attr(href); + let candidate = format!("{}[href=\"{}\"]", tag, escaped); + let is_unique = match Selector::parse(&candidate) { + Ok(sel) => html.select(&sel).count() == 1, + Err(_) => false, + }; + if is_unique { + return candidate; + } + } + + if let Some(itype) = el.value().attr("type") { + let tag = el.value().name(); + let candidate = format!("{}[type=\"{}\"]", tag, itype); + let is_unique = match Selector::parse(&candidate) { + Ok(sel) => html.select(&sel).count() == 1, + Err(_) => false, + }; + if is_unique { + return candidate; + } + } + + build_structural_selector(el) +} + +pub fn css_escape_id(id: &str) -> String { + if id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + id.to_string() + } else { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_string() + } else { + format!("\\{:X}", c as u32) + } + }) + .collect() + } +} + +fn css_escape_attr(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } + +pub fn build_structural_selector(el: &ElementRef) -> String { + let mut segments = Vec::new(); + let mut current = Some(*el); + + while let Some(node) = current { + let tag = node.value().name().to_lowercase(); + + if tag == "body" || tag == "html" { + break; + } + + let nth = count_element_position(&node); + segments.push(format!("{}:nth-child({})", tag, nth)); + + current = node.parent().and_then(ElementRef::wrap); + } + + segments.reverse(); + if segments.is_empty() { + el.value().name().to_string() + } else { + segments.join(" > ") + } +} + +/// Count the 1-based position of this element among its parent's children. +pub fn count_element_position(el: &ElementRef) -> usize { + if let Some(parent) = el.parent().and_then(ElementRef::wrap) { + let mut count = 0; + + for child in parent.children() { + if ElementRef::wrap(child).is_some() { + count += 1; + } + if child == **el { + return count; + } + } + } + 1 +} diff --git a/crates/pardus-core/src/semantic/tree.rs b/crates/pardus-core/src/semantic/tree.rs index d530c33..4eab578 100644 --- a/crates/pardus-core/src/semantic/tree.rs +++ b/crates/pardus-core/src/semantic/tree.rs @@ -1,9 +1,10 @@ +use std::{collections::HashMap, fmt}; + use scraper::{ElementRef, Html, Selector}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt; use url::Url; +use super::selector::{build_unique_selector, css_escape_id}; use crate::frame::{FrameData, FrameTree}; // --------------------------------------------------------------------------- @@ -90,9 +91,7 @@ pub struct SemanticNode { pub children: Vec, } -fn is_false(v: &bool) -> bool { - !v -} +fn is_false(v: &bool) -> bool { !v } /// An option within a + setSettingsConfig((c) => ({ ...c, apiKey: e.target.value })) + } + placeholder="sk-..." + /> + + + setSettingsConfig((c) => ({ ...c, model: e.target.value })) + } + placeholder="gpt-4" + /> + + + setSettingsConfig((c) => ({ ...c, baseURL: e.target.value })) + } + placeholder="https://api.openai.com/v1" + /> +
+
+ + + setSettingsConfig((c) => ({ + ...c, + temperature: parseFloat(e.target.value) || 0.7, + })) + } + /> +
+
+ + + setSettingsConfig((c) => ({ + ...c, + maxRounds: parseInt(e.target.value) || 50, + })) + } + /> +
+
+ +
+ +
+ + ) : ( +
+
+ {"\u{1F916}"} + Connect an AI agent to chat + +
+
+ )} + + ); + } + + return ( +
+
+ Chat + {isBusy && ( + {STATUS_INDICATOR[agentRunStatus]} + )} + {messages.length > 0 && ( + {messages.length} + )} + + +
+
+ {messages.length === 0 && ( +
+
Ask the agent to browse the web for you.
+
+ "Go to google.com and search for rust programming" + "Find the pricing on example.com" + "Fill out the form on that page" +
+
+ )} + {messages.map((msg) => ( + + ))} + {isBusy && ( +
+ agent +
+ +
+
+ )} +
+
+