diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8af59dd --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +RUST_TEST_THREADS = "1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8812940 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: obscura-x86_64-linux + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: obscura-aarch64-linux + - target: x86_64-apple-darwin + os: macos-latest + name: obscura-x86_64-macos + - target: aarch64-apple-darwin + os: macos-latest + name: obscura-aarch64-macos + - target: x86_64-pc-windows-msvc + os: windows-latest + name: obscura-x86_64-windows + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Build stealth + run: cargo build --release --target ${{ matrix.target }} --features stealth + continue-on-error: true + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + cd target/${{ matrix.target }}/release + tar czf ../../../${{ matrix.name }}.tar.gz obscura + cd ../../.. + + - name: Package (Windows) + if: runner.os == 'Windows' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../${{ matrix.name }}.zip obscura.exe + cd ../../.. + + - name: Upload + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ matrix.name }}.tar.gz + ${{ matrix.name }}.zip diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6be156f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3531 @@ +# 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 = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "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.61.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.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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", +] + +[[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 = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[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.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[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 = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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 = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boring-sys2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669" +dependencies = [ + "bindgen 0.72.1", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "boring2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28" +dependencies = [ + "bitflags", + "boring-sys2", + "foreign-types 0.5.0", + "libc", + "openssl-macros", +] + +[[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 = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[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", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[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", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[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.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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 = "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.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + +[[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.350.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e273b731fce500130790e777cb2631dc451db412975304d23816c1e444a10c5b" +dependencies = [ + "anyhow", + "az", + "bincode", + "bit-set", + "bit-vec", + "bytes", + "capacity_builder", + "cooked-waker", + "deno_core_icudata", + "deno_error", + "deno_ops", + "deno_path_util", + "deno_unsync", + "futures", + "indexmap", + "libc", + "parking_lot", + "percent-encoding", + "pin-project", + "serde", + "serde_json", + "serde_v8", + "smallvec", + "sourcemap", + "static_assertions", + "thiserror", + "tokio", + "url", + "v8", + "wasm_dep_analyzer", +] + +[[package]] +name = "deno_core_icudata" +version = "0.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4dccb6147bb3f3ba0c7a48e993bfeb999d2c2e47a81badee80e2b370c8d695" + +[[package]] +name = "deno_error" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612ec3fc481fea759141b0c57810889b0a4fb6fee8f10748677bfe492fd30486" +dependencies = [ + "deno_error_macro", + "libc", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "deno_error_macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8380a4224d5d2c3f84da4d764c4326cac62e9a1e3d4960442d29136fc07be863" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deno_ops" +version = "0.226.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28b12489187c71fa123731cc783d48beb17ae5df04da991909cc2ae5a3d0ef9" +dependencies = [ + "indexmap", + "proc-macro-rules", + "proc-macro2", + "quote", + "stringcase", + "strum", + "strum_macros", + "syn", + "thiserror", +] + +[[package]] +name = "deno_path_util" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516f813389095889776b81cc9108ff6f336fd9409b4b12fc0138aea23d2708e1" +dependencies = [ + "deno_error", + "percent-encoding", + "sys_traits", + "thiserror", + "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.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[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 = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[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 0.3.1", +] + +[[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", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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", +] + +[[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-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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[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.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7349aa548b6a413a7e7146d5d7e3db4565b3d0fbc8ff53c72e46b44b083cf9f" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "parking_lot", + "slab", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[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", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[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", + "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 = "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 = "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 = "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_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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 = "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 = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[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 = "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", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[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", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "obscura-browser" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "obscura-dom", + "obscura-js", + "obscura-net", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "obscura-cdp" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures-util", + "obscura-browser", + "obscura-dom", + "obscura-js", + "obscura-net", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "obscura-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "obscura-browser", + "obscura-cdp", + "obscura-dom", + "obscura-js", + "obscura-net", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "obscura-dom" +version = "0.1.0" +dependencies = [ + "cssparser", + "html5ever", + "markup5ever", + "precomputed-hash", + "selectors", + "servo_arc", + "thiserror", + "tracing", +] + +[[package]] +name = "obscura-js" +version = "0.1.0" +dependencies = [ + "anyhow", + "deno_core", + "deno_error", + "html5ever", + "obscura-dom", + "obscura-net", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "obscura-net" +version = "0.1.0" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "wreq", + "wreq-util", +] + +[[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 = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[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", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[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", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[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", +] + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "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", +] + +[[package]] +name = "proc-macro-rules" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" +dependencies = [ + "proc-macro-rules-macros", + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-rules-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207fffb0fe655d1d47f6af98cc2793405e85929bdbc420d685554ff07be27ac7" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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 = "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 = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "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", + "rand_core 0.9.5", +] + +[[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.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.52.0", +] + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schnellru" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649" +dependencies = [ + "ahash", + "cfg-if", + "hashbrown 0.13.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_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", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.259.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e4c5f439cffc03021c8bfd380cd1ef6acb4788a72b7e54bf4a83c73f91f8a0" +dependencies = [ + "deno_error", + "num-bigint", + "serde", + "smallvec", + "thiserror", + "v8", +] + +[[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", + "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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "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.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "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", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringcase" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72abeda133c49d7bddece6c154728f83eec8172380c80ab7096da9487e20d27c" + +[[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", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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", +] + +[[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", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[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 = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[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 = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-boring2" +version = "5.0.0-alpha.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756" +dependencies = [ + "boring2", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "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", +] + +[[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 = "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", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[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 = [ + "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", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[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-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "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", + "wasm-bindgen", +] + +[[package]] +name = "v8" +version = "137.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" +dependencies = [ + "bindgen 0.71.1", + "bitflags", + "fslock", + "gzip-header", + "home", + "miniz_oxide", + "paste", + "which", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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 = "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.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", + "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", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm_dep_analyzer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" +dependencies = [ + "deno_error", + "thiserror", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "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 = "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 = "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 = "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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[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", + "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", + "indexmap", + "prettyplease", + "syn", + "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", + "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", + "indexmap", + "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", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wreq" +version = "6.0.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935" +dependencies = [ + "ahash", + "boring2", + "brotli", + "bytes", + "flate2", + "futures-channel", + "futures-util", + "http", + "http-body", + "http-body-util", + "http2", + "httparse", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "schnellru", + "smallvec", + "socket2", + "tokio", + "tokio-boring2", + "tower", + "url", + "want", + "webpki-root-certs", + "zstd", +] + +[[package]] +name = "wreq-util" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c6bbe24d28beb9ceb58b514bd6a613c759d3b706f768b9d2950d5d35b543c04" +dependencies = [ + "typed-builder", + "wreq", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[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", + "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", +] + +[[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", + "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", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "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", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f9b0507 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[workspace] +resolver = "2" +members = [ + "crates/obscura-dom", + "crates/obscura-net", + "crates/obscura-browser", + "crates/obscura-cdp", + "crates/obscura-js", + "crates/obscura-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/h4ckf0r0day/obscura" + +[workspace.dependencies] +obscura-dom = { path = "crates/obscura-dom" } +obscura-net = { path = "crates/obscura-net" } +obscura-browser = { path = "crates/obscura-browser" } +obscura-cdp = { path = "crates/obscura-cdp" } +obscura-js = { path = "crates/obscura-js" } + +html5ever = "0.29" +markup5ever = "0.14" +selectors = "0.26" +servo_arc = "0.4" +cssparser = "0.34" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = "0.26" +reqwest = { version = "0.12", features = ["cookies", "gzip", "brotli", "deflate", "native-tls-vendored"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4", features = ["derive"] } +uuid = { version = "1", features = ["v4"] } +thiserror = "2" +anyhow = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bb8751 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +

+ Obscura +

+ +

Obscura

+ +

+ The open-source headless browser for AI agents and web scraping.
+ Lightweight, stealthy, and built in Rust. +

+ +--- + +Obscura is a headless browser engine written in Rust, built for web scraping and AI agent automation. It runs real JavaScript via V8, supports the Chrome DevTools Protocol, and acts as a drop-in replacement for headless Chrome with Puppeteer and Playwright. + +### Why Obscura over headless Chrome? + +Designed for automation at scale, not desktop browsing. + +| Metric | Obscura | Headless Chrome | +|--------------|--------------|------------------| +| Memory | **30 MB** | 200+ MB | +| Binary size | **70 MB** | 300+ MB | +| Anti-detect | **Built-in** | None | +| Page load | **85 ms** | ~500 ms | +| Startup | **Instant** | ~2s | +| Puppeteer | **Yes** | Yes | +| Playwright | **Yes** | Yes | + +## Install + +### Download + +Grab the latest binary from [Releases](https://github.com/h4ckf0r0day/obscura/releases): + +```bash +# Linux x86_64 +curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-linux.tar.gz +tar xzf obscura-x86_64-linux.tar.gz +./obscura fetch https://example.com --eval "document.title" + +# macOS Apple Silicon +curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-macos.tar.gz +tar xzf obscura-aarch64-macos.tar.gz + +# macOS Intel +curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-macos.tar.gz +tar xzf obscura-x86_64-macos.tar.gz + +# Windows +Download the `.zip` from the releases page and extract it manually. +``` + +Single binary. No Chrome, no Node.js, no dependencies. + +### Build from source + +```bash +git clone https://github.com/h4ckf0r0day/obscura.git +cd obscura +cargo build --release + +# With stealth mode (anti-detection + tracker blocking) +cargo build --release --features stealth +``` + +Requires Rust 1.75+ ([rustup.rs](https://rustup.rs)). First build takes ~5 min (V8 compiles from source, cached after). + +## Quick Start + +### Fetch a page + +```bash +# Get the page title +obscura fetch https://example.com --eval "document.title" + +# Extract all links +obscura fetch https://example.com --dump links + +# Render JavaScript and dump HTML +obscura fetch https://news.ycombinator.com --dump html + +# Wait for dynamic content +obscura fetch https://example.com --wait-until networkidle0 +``` + +### Start the CDP server + +```bash +obscura serve --port 9222 + +# With stealth mode (anti-detection + tracker blocking) +obscura serve --port 9222 --stealth +``` + +### Scrape in parallel + +```bash +obscura scrape url1 url2 url3 ... \ + --concurrency 25 \ + --eval "document.querySelector('h1').textContent" \ + --format json +``` + +## Puppeteer / Playwright + +### Puppeteer + +```bash +npm install puppeteer-core +``` + +```javascript +import puppeteer from 'puppeteer-core'; + +const browser = await puppeteer.connect({ + browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser', +}); + +const page = await browser.newPage(); +await page.goto('https://news.ycombinator.com'); + +const stories = await page.evaluate(() => + Array.from(document.querySelectorAll('.titleline > a')) + .map(a => ({ title: a.textContent, url: a.href })) +); +console.log(stories); + +await browser.disconnect(); +``` + +### Playwright + +```bash +npm install playwright-core +``` + +```javascript +import { chromium } from 'playwright-core'; + +const browser = await chromium.connectOverCDP({ + endpointURL: 'ws://127.0.0.1:9222', +}); + +const page = await browser.newContext().then(ctx => ctx.newPage()); +await page.goto('https://en.wikipedia.org/wiki/Web_scraping'); +console.log(await page.title()); + +await browser.close(); +``` + +### Form submission & login + +```javascript +await page.goto('https://quotes.toscrape.com/login'); +await page.evaluate(() => { + document.querySelector('#username').value = 'admin'; + document.querySelector('#password').value = 'admin'; + document.querySelector('form').submit(); +}); +// Obscura handles the POST, follows the 302 redirect, maintains cookies +``` + +## Benchmarks + +Page load: + +| Page | Obscura | Chrome | +|------|---------|--------| +| Static HTML | **51 ms** | ~500 ms | +| JS + XHR + fetch | **84 ms** | ~800 ms | +| Dynamic scripts | **78 ms** | ~700 ms | + +## Stealth Mode + +Enable with `--features stealth`. + +### Anti-fingerprinting +- Per-session fingerprint randomization (GPU, screen, canvas, audio, battery) +- Realistic `navigator.userAgentData` (Chrome 145, high-entropy values) +- `event.isTrusted = true` for dispatched events +- Hidden internal properties (`Object.keys(window)` safe) +- Native function masking (`Function.prototype.toString()` → `[native code]`) +- `navigator.webdriver = undefined` (matches real Chrome) + +### Tracker Blocking +- 3,520 domains blocked +- Blocks analytics, ads, telemetry, and fingerprinting scripts +- Prevents trackers from loading entirely +- Enabled automatically with `--stealth` + +## CDP API + +Obscura implements the Chrome DevTools Protocol for Puppeteer/Playwright compatibility. + +| Domain | Methods | +|--------|---------| +| **Target** | createTarget, closeTarget, attachToTarget, createBrowserContext, disposeBrowserContext | +| **Page** | navigate, getFrameTree, addScriptToEvaluateOnNewDocument, lifecycleEvents | +| **Runtime** | evaluate, callFunctionOn, getProperties, addBinding | +| **DOM** | getDocument, querySelector, querySelectorAll, getOuterHTML, resolveNode | +| **Network** | enable, setCookies, getCookies, setExtraHTTPHeaders, setUserAgentOverride | +| **Fetch** | enable, continueRequest, fulfillRequest, failRequest (live interception) | +| **Storage** | getCookies, setCookies, deleteCookies | +| **Input** | dispatchMouseEvent, dispatchKeyEvent | +| **LP** | getMarkdown (DOM-to-Markdown conversion) | +## CLI Reference + +### `obscura serve` + +Start a CDP WebSocket server. + +| Flag | Default | Description | +|------|---------|-------------| +| `--port` | `9222` | WebSocket port | +| `--proxy` | — | HTTP/SOCKS5 proxy URL | +| `--stealth` | off | Enable anti-detection + tracker blocking | +| `--workers` | `1` | Number of parallel worker processes | +| `--obey-robots` | off | Respect robots.txt | + +### `obscura fetch ` + +Fetch and render a single page. + +| Flag | Default | Description | +|------|---------|-------------| +| `--dump` | `html` | Output: `html`, `text`, or `links` | +| `--eval` | — | JavaScript expression to evaluate | +| `--wait-until` | `load` | Wait: `load`, `domcontentloaded`, `networkidle0` | +| `--selector` | — | Wait for CSS selector | +| `--stealth` | off | Anti-detection mode | +| `--quiet` | off | Suppress banner | + +### `obscura scrape ` + +Scrape multiple URLs in parallel with worker processes. + +| Flag | Default | Description | +|------|---------|-------------| +| `--concurrency` | `10` | Parallel workers | +| `--eval` | — | JS expression per page | +| `--format` | `json` | Output: `json` or `text` | + +## License + +MIT + +--- \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..ce80df5 Binary files /dev/null and b/assets/icon.png differ diff --git a/crates/obscura-browser/Cargo.toml b/crates/obscura-browser/Cargo.toml new file mode 100644 index 0000000..bb88efe --- /dev/null +++ b/crates/obscura-browser/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "obscura-browser" +version.workspace = true +edition.workspace = true + +[features] +default = [] +stealth = ["obscura-net/stealth"] + +[dependencies] +obscura-dom = { workspace = true } +obscura-net = { workspace = true } +obscura-js = { workspace = true } +tokio = { workspace = true } +futures = "0.3" +url = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/obscura-browser/src/context.rs b/crates/obscura-browser/src/context.rs new file mode 100644 index 0000000..dace8cb --- /dev/null +++ b/crates/obscura-browser/src/context.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use obscura_net::{CookieJar, ObscuraHttpClient, RobotsCache}; + +pub struct BrowserContext { + pub id: String, + pub cookie_jar: Arc, + pub http_client: Arc, + pub user_agent: String, + pub proxy_url: Option, + pub robots_cache: Arc, + pub obey_robots: bool, + pub stealth: bool, +} + +impl BrowserContext { + pub fn new(id: String) -> Self { + let cookie_jar = Arc::new(CookieJar::new()); + let http_client = Arc::new(ObscuraHttpClient::with_cookie_jar(cookie_jar.clone())); + BrowserContext { + id, + cookie_jar, + http_client, + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string(), + proxy_url: None, + robots_cache: Arc::new(RobotsCache::new()), + obey_robots: false, + stealth: false, + } + } + + pub fn with_options(id: String, proxy_url: Option, stealth: bool) -> Self { + let cookie_jar = Arc::new(CookieJar::new()); + let mut client = ObscuraHttpClient::with_options( + cookie_jar.clone(), + proxy_url.as_deref(), + ); + if stealth { + client.block_trackers = true; + } + let http_client = Arc::new(client); + BrowserContext { + id, + cookie_jar, + http_client, + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string(), + proxy_url, + robots_cache: Arc::new(RobotsCache::new()), + obey_robots: false, + stealth, + } + } + + pub fn with_proxy(id: String, proxy_url: Option) -> Self { + Self::with_options(id, proxy_url, false) + } +} diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs new file mode 100644 index 0000000..1ece192 --- /dev/null +++ b/crates/obscura-browser/src/lib.rs @@ -0,0 +1,7 @@ +pub mod page; +pub mod context; +pub mod lifecycle; + +pub use page::{Page, PageError}; +pub use context::BrowserContext; +pub use lifecycle::{LifecycleState, WaitUntil}; diff --git a/crates/obscura-browser/src/lifecycle.rs b/crates/obscura-browser/src/lifecycle.rs new file mode 100644 index 0000000..ca668f9 --- /dev/null +++ b/crates/obscura-browser/src/lifecycle.rs @@ -0,0 +1,42 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleState { + Idle, + Loading, + DomContentLoaded, + Loaded, + NetworkIdle, + Failed, +} + +impl LifecycleState { + pub fn is_loading(&self) -> bool { + matches!(self, LifecycleState::Loading) + } + + pub fn is_loaded(&self) -> bool { + matches!(self, LifecycleState::Loaded | LifecycleState::NetworkIdle) + } + + pub fn is_network_idle(&self) -> bool { + matches!(self, LifecycleState::NetworkIdle) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WaitUntil { + Load, + DomContentLoaded, + NetworkIdle0, + NetworkIdle2, +} + +impl WaitUntil { + pub fn from_str(s: &str) -> Self { + match s { + "domcontentloaded" => WaitUntil::DomContentLoaded, + "networkidle0" | "networkIdle" | "networkidle" => WaitUntil::NetworkIdle0, + "networkidle2" => WaitUntil::NetworkIdle2, + _ => WaitUntil::Load, + } + } +} diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs new file mode 100644 index 0000000..14557ce --- /dev/null +++ b/crates/obscura-browser/src/page.rs @@ -0,0 +1,859 @@ +use std::sync::Arc; + +use obscura_dom::{parse_html, DomTree}; +use obscura_js::runtime::ObscuraJsRuntime; +use obscura_net::{ObscuraHttpClient, ObscuraNetError, Response}; +use url::Url; + +use crate::context::BrowserContext; +use crate::lifecycle::LifecycleState; + +#[cfg(feature = "stealth")] +use obscura_net::StealthHttpClient; + +#[derive(Debug, Clone)] +pub struct NetworkEvent { + pub request_id: String, + pub url: String, + pub method: String, + pub resource_type: String, + pub status: u16, + pub headers: std::collections::HashMap, + pub response_headers: Arc>, + pub body_size: usize, + pub timestamp: f64, +} + +pub struct Page { + pub id: String, + pub frame_id: String, + pub url: Option, + pub dom: Option, + pub js: Option, + pub lifecycle: LifecycleState, + pub http_client: Arc, + pub context: Arc, + pub title: String, + pub network_events: Vec, + network_event_counter: u32, + pub intercept_enabled: bool, + pub intercept_block_patterns: Vec, + intercept_tx: Option>, + #[cfg(feature = "stealth")] + pub stealth_client: Option>, +} + +impl Page { + pub fn new(id: String, context: Arc) -> Self { + let http_client = context.http_client.clone(); + let frame_id = format!("{}.1", id); + #[cfg(feature = "stealth")] + let stealth_client = if context.stealth { + Some(Arc::new(StealthHttpClient::new(context.cookie_jar.clone()))) + } else { + None + }; + + Page { + id, + frame_id, + url: None, + dom: None, + js: None, + lifecycle: LifecycleState::Idle, + http_client, + context, + title: String::new(), + network_events: Vec::new(), + network_event_counter: 0, + intercept_enabled: false, + intercept_block_patterns: Vec::new(), + intercept_tx: None, + #[cfg(feature = "stealth")] + stealth_client, + } + } + + fn should_block_url(&self, url: &str) -> bool { + if !self.intercept_enabled || self.intercept_block_patterns.is_empty() { + return false; + } + for pattern in &self.intercept_block_patterns { + if pattern == "*" { return true; } + if pattern.starts_with('*') && pattern.ends_with('*') { + if url.contains(&pattern[1..pattern.len()-1]) { return true; } + } else if pattern.starts_with('*') { + if url.ends_with(&pattern[1..]) { return true; } + } else if pattern.ends_with('*') { + if url.starts_with(&pattern[..pattern.len()-1]) { return true; } + } else if url.contains(pattern) { + return true; + } + } + false + } + + async fn do_fetch(&self, url: &Url) -> Result { + #[cfg(feature = "stealth")] + if let Some(ref stealth) = self.stealth_client { + return stealth.fetch(url).await; + } + self.http_client.fetch(url).await + } + fn init_js(&mut self) { + if self.js.is_some() { + let url_str = self.url_string(); + let title = self.title.clone(); + let dom = self.dom.take(); + + let js = self.js.as_mut().unwrap(); + js.set_url(&url_str); + js.set_title(&title); + + if let Some(d) = dom { + js.set_dom(d); + } + + let _ = js.execute_script("", + "_cache.clear(); globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; \ + _iframeRegistry.length = 0; globalThis.length = 0; \ + globalThis._formValues = {}; globalThis._formChecked = {}; \ + globalThis._eventRegistry = {}; \ + globalThis.document = new Document(+_dom('document_node_id'));"); + + return; + } + + let mut rt = ObscuraJsRuntime::with_base_url(&self.url_string()); + rt.set_url(&self.url_string()); + rt.set_title(&self.title); + + #[cfg(feature = "stealth")] + if self.stealth_client.is_some() { + rt.set_user_agent(obscura_net::STEALTH_USER_AGENT); + } else if let Ok(ua) = self.http_client.user_agent.try_read() { + rt.set_user_agent(&ua); + } + #[cfg(not(feature = "stealth"))] + if let Ok(ua) = self.http_client.user_agent.try_read() { + rt.set_user_agent(&ua); + } + + rt.set_cookie_jar(self.context.cookie_jar.clone()); + rt.set_http_client(self.http_client.clone()); + + if let Some(tx) = &self.intercept_tx { + rt.set_intercept_tx(tx.clone()); + } + + if let Some(dom) = self.dom.take() { + rt.set_dom(dom); + } + + self.js = Some(rt); + } + + async fn execute_scripts(&mut self) { + tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); + + #[derive(Debug)] + struct ScriptInfo { + src: Option, + inline: String, + is_defer: bool, + is_async: bool, + is_module: bool, + } + + let all_scripts = match &self.js { + Some(js) => { + js.with_dom(|dom| { + let script_ids = dom.query_selector_all("script").unwrap_or_default(); + let mut scripts = Vec::new(); + + for sid in script_ids { + if let Some(node) = dom.get_node(sid) { + let src = node.get_attribute("src").map(|s| s.to_string()); + let script_type = node.get_attribute("type").unwrap_or("").to_string(); + let is_defer = node.get_attribute("defer").is_some(); + let is_async = node.get_attribute("async").is_some(); + let is_module = script_type == "module"; + + if !script_type.is_empty() + && script_type != "text/javascript" + && script_type != "application/javascript" + && script_type != "module" + { + continue; + } + + let inline_code = if src.is_none() { + dom.text_content(sid) + } else { + String::new() + }; + + if src.is_some() || !inline_code.trim().is_empty() { + scripts.push(ScriptInfo { + src, + inline: inline_code, + is_defer, + is_async, + is_module, + }); + } + } + } + scripts + }).unwrap_or_default() + } + None => return, + }; + + let mut regular = Vec::new(); + let mut deferred = Vec::new(); + let mut async_scripts = Vec::new(); + + let mut module_scripts = Vec::new(); + + for script in all_scripts { + if script.is_module { + module_scripts.push(script); + continue; + } + if script.is_defer { + deferred.push(script); + } else if script.is_async { + async_scripts.push(script); + } else { + regular.push(script); + } + } + + let scripts = regular; + + tracing::info!("Found {} regular + {} deferred + {} async scripts", scripts.len(), deferred.len(), async_scripts.len()); + let all_to_execute: Vec = scripts.into_iter() + .chain(deferred.into_iter()) + .chain(async_scripts.into_iter()) + .collect(); + + let mut resolved: Vec<(usize, String)> = Vec::new(); + let mut fetch_tasks: Vec<(usize, String)> = Vec::new(); + + for (i, script) in all_to_execute.iter().enumerate() { + if let Some(src_url) = &script.src { + let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") { + src_url.clone() + } else if let Some(base) = &self.url { + base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone()) + } else { + src_url.clone() + }; + + if self.should_block_url(&full_url) { + tracing::info!("Blocked script by interception: {}", full_url); + continue; + } + resolved.push((i, full_url.clone())); + fetch_tasks.push((i, full_url)); + } + } + + let client = self.http_client.clone(); + let fetch_futures: Vec<_> = fetch_tasks.iter().map(|(idx, url)| { + let client = client.clone(); + let url = url.clone(); + let idx = *idx; + async move { + let parsed = Url::parse(&url).unwrap_or_else(|_| Url::parse("about:blank").unwrap()); + match client.fetch(&parsed).await { + Ok(resp) => Some((idx, url, resp)), + Err(e) => { + tracing::warn!("Failed to fetch script {}: {}", url, e); + None + } + } + } + }).collect(); + + let fetch_results = futures::future::join_all(fetch_futures).await; + + let mut fetched: std::collections::HashMap = std::collections::HashMap::new(); + for result in fetch_results { + if let Some((idx, url, resp)) = result { + let code = String::from_utf8_lossy(&resp.body).to_string(); + fetched.insert(idx, (url, code, resp)); + } + } + + for (i, script) in all_to_execute.iter().enumerate() { + if script.src.is_some() { + if let Some((url, code, resp)) = fetched.remove(&i) { + tracing::info!("Executing script ({} bytes): {}", code.len(), url); + self.record_network_event(&url, "GET", "Script", resp.status, &resp.headers, resp.body.len()); + if let Some(js) = &mut self.js { + if let Err(e) = js.execute_script_guarded(&url, &code) { + tracing::warn!("Script error ({}): {}", url, e); + } + } + } + } else if !script.inline.is_empty() { + if let Some(js) = &mut self.js { + if let Err(e) = js.execute_script_guarded("", &script.inline) { + tracing::warn!("Inline script error: {}", e); + } + } + } + } + + for module_script in &module_scripts { + if let Some(ref src) = module_script.src { + let full_url = if src.starts_with("http://") || src.starts_with("https://") { + src.clone() + } else if let Some(base) = &self.url { + base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone()) + } else { + src.clone() + }; + + tracing::info!("Loading ES module: {}", full_url); + if let Some(js) = &mut self.js { + match js.load_module(&full_url).await { + Ok(()) => { + tracing::info!("ES module loaded: {}", full_url); + self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0); + } + Err(e) => { + tracing::warn!("ES module error ({}): {}", full_url, e); + } + } + } + } else if !module_script.inline.is_empty() { + let base = self.url_string(); + if let Some(js) = &mut self.js { + if let Err(e) = js.load_inline_module(&module_script.inline, &base).await { + tracing::warn!("Inline ES module error: {}", e); + } + } + } + } + + if let Some(js) = &mut self.js { + let _ = js.execute_script("", + "if (typeof window.onload === 'function') { try { window.onload(); } catch(e) {} }\n\ + try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch(e) {}\n\ + try { window.dispatchEvent(new Event('load')); } catch(e) {}"); + } + + if let Some(js) = &mut self.js { + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500); + let mut idle_count = 0u32; + loop { + let result = tokio::time::timeout( + tokio::time::Duration::from_millis(10), + js.run_event_loop(), + ).await; + + match result { + Ok(Ok(())) => { + if self.http_client.active_requests() == 0 { + idle_count += 1; + if idle_count >= 2 { + break; + } + tokio::task::yield_now().await; + } else { + idle_count = 0; + tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; + } + } + Ok(Err(_)) => break, + Err(_) => { + idle_count = 0; + if tokio::time::Instant::now() >= deadline { + break; + } + } + } + } + } + } + + pub async fn navigate(&mut self, url_str: &str) -> Result<(), PageError> { + self.navigate_with_wait(url_str, crate::lifecycle::WaitUntil::Load).await + } + + pub async fn navigate_with_wait( + &mut self, + url_str: &str, + wait_until: crate::lifecycle::WaitUntil, + ) -> Result<(), PageError> { + self.navigate_with_wait_post(url_str, wait_until, "GET", "").await + } + + pub async fn navigate_with_wait_post( + &mut self, + url_str: &str, + wait_until: crate::lifecycle::WaitUntil, + method: &str, + body: &str, + ) -> Result<(), PageError> { + let mut current_url = url_str.to_string(); + let mut current_method = method.to_string(); + let mut current_body = body.to_string(); + for _chain in 0..10 { + self.navigate_single(¤t_url, wait_until, ¤t_method, ¤t_body).await?; + if let Some((next_url, next_method, next_body)) = self.take_pending_navigation() { + tracing::info!("JS-triggered navigation chain: {} {} -> {}", current_method, current_url, next_url); + current_url = next_url; + current_method = next_method; + current_body = next_body; + continue; + } + break; + } + Ok(()) + } + + async fn navigate_single( + &mut self, + url_str: &str, + wait_until: crate::lifecycle::WaitUntil, + method: &str, + body: &str, + ) -> Result<(), PageError> { + let url = Url::parse(url_str).map_err(|e| PageError::InvalidUrl(e.to_string()))?; + + self.lifecycle = LifecycleState::Loading; + self.url = Some(url.clone()); + self.network_events.clear(); + + if self.context.obey_robots { + if let Some(domain) = url.host_str() { + if self.context.robots_cache.is_allowed(domain, "/robots.txt") { + let robots_url = format!("{}://{}/robots.txt", url.scheme(), domain); + if let Ok(robots_url) = Url::parse(&robots_url) { + if let Ok(resp) = self.http_client.fetch(&robots_url).await { + if resp.status == 200 { + let body = String::from_utf8_lossy(&resp.body); + self.context.robots_cache.parse_and_store( + domain, + &body, + &self.context.user_agent, + ); + } + } + } + } + + if !self.context.robots_cache.is_allowed(domain, url.path()) { + self.lifecycle = LifecycleState::Failed; + return Err(PageError::NetworkError(format!( + "Blocked by robots.txt: {}", + url + ))); + } + } + } + + let response = if method == "POST" { + self.http_client.post_form(&url, body).await + } else { + self.do_fetch(&url).await + }.map_err(|e| { + self.lifecycle = LifecycleState::Failed; + PageError::NetworkError(e.to_string()) + })?; + + self.record_network_event( + url.as_str(), + "GET", + "Document", + response.status, + &response.headers, + response.body.len(), + ); + + if !response.redirected_from.is_empty() { + self.url = Some(response.url.clone()); + } + + let body_text = String::from_utf8_lossy(&response.body).to_string(); + let dom = parse_html(&body_text); + + self.title = dom + .query_selector("title") + .ok() + .flatten() + .map(|title_id| dom.text_content(title_id)) + .unwrap_or_default(); + + let stylesheet_urls: Vec = dom + .query_selector_all("link") + .unwrap_or_default() + .iter() + .filter_map(|&nid| { + let node = dom.get_node(nid)?; + let rel = node.get_attribute("rel")?; + if rel.to_lowercase() != "stylesheet" { + return None; + } + node.get_attribute("href").map(|s| s.to_string()) + }) + .collect(); + + let mut css_fetch_urls: Vec = Vec::new(); + for href in &stylesheet_urls { + let full_url = if href.starts_with("http://") || href.starts_with("https://") { + href.clone() + } else if let Some(base) = &self.url { + base.join(href).map(|u| u.to_string()).unwrap_or_else(|_| href.clone()) + } else { + href.clone() + }; + if self.should_block_url(&full_url) { + tracing::info!("Blocked stylesheet by interception: {}", full_url); + continue; + } + css_fetch_urls.push(full_url); + } + + let client = self.http_client.clone(); + let css_futures: Vec<_> = css_fetch_urls.iter().map(|full_url| { + let client = client.clone(); + let url_str = full_url.clone(); + async move { + let parsed = Url::parse(&url_str).unwrap_or_else(|_| Url::parse("about:blank").unwrap()); + match client.fetch(&parsed).await { + Ok(resp) => Some((url_str, resp)), + Err(e) => { + tracing::debug!("Failed to fetch stylesheet {}: {}", url_str, e); + None + } + } + } + }).collect(); + + let css_results = futures::future::join_all(css_futures).await; + let mut css_sources = Vec::new(); + for result in css_results { + if let Some((url_str, resp)) = result { + let css = String::from_utf8_lossy(&resp.body).to_string(); + self.record_network_event(&url_str, "GET", "Stylesheet", resp.status, &resp.headers, resp.body.len()); + css_sources.push(css); + } + } + + self.dom = Some(dom); + self.lifecycle = LifecycleState::DomContentLoaded; + + if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded { + self.init_js(); + return Ok(()); + } + + self.init_js(); + + if !css_sources.is_empty() { + if let Some(js) = &mut self.js { + let combined_css = css_sources.join("\n"); + let escaped = combined_css + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${"); + let code = format!("globalThis.__obscura_css = `{}`;", escaped); + let _ = js.execute_script("", &code); + } + } + if let Some(js) = &mut self.js { + let _ = js.execute_script("", + "(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()"); + } + + self.execute_scripts().await; + + if let Some(js) = &mut self.js { + if let Ok(new_title) = js.evaluate("document.title") { + if let Some(t) = new_title.as_str() { + self.title = t.to_string(); + } + } + } + + self.lifecycle = LifecycleState::Loaded; + + if matches!( + wait_until, + crate::lifecycle::WaitUntil::NetworkIdle0 | crate::lifecycle::WaitUntil::NetworkIdle2 + ) { + let threshold = match wait_until { + crate::lifecycle::WaitUntil::NetworkIdle0 => 0, + crate::lifecycle::WaitUntil::NetworkIdle2 => 2, + _ => 0, + }; + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5); + let mut idle_since: Option = None; + + loop { + let active = self.http_client.active_requests(); + let now = tokio::time::Instant::now(); + + if active <= threshold { + if idle_since.is_none() { + idle_since = Some(now); + } + if now.duration_since(idle_since.unwrap()) >= tokio::time::Duration::from_millis(500) { + break; + } + } else { + idle_since = None; + } + + if now >= deadline { + tracing::debug!("Network idle timeout reached with {} active requests", active); + break; + } + + if let Some(js) = &mut self.js { + let _ = tokio::time::timeout( + tokio::time::Duration::from_millis(50), + js.run_event_loop(), + ).await; + } else { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + } + + self.lifecycle = LifecycleState::NetworkIdle; + } + + Ok(()) + } + + pub fn navigate_blank(&mut self) { + self.js = None; + self.url = Some(Url::parse("about:blank").unwrap()); + self.dom = Some(parse_html("")); + self.title = String::new(); + self.lifecycle = LifecycleState::Loaded; + } + + pub fn url_string(&self) -> String { + self.url + .as_ref() + .map(|u| u.to_string()) + .unwrap_or_else(|| "about:blank".to_string()) + } + + pub fn with_dom(&self, f: impl FnOnce(&DomTree) -> R) -> Option { + if let Some(js) = &self.js { + return js.with_dom(f); + } + self.dom.as_ref().map(f) + } + + pub fn dom(&self) -> Option<&DomTree> { + self.dom.as_ref() + } + + pub fn evaluate(&mut self, expression: &str) -> serde_json::Value { + if let Some(js) = &mut self.js { + match js.evaluate(expression) { + Ok(val) => val, + Err(e) => { + tracing::debug!("JS eval error for '{}': {}", &expression[..expression.len().min(80)], e); + serde_json::Value::Null + } + } + } else { + match expression.trim() { + "document.title" => serde_json::Value::String(self.title.clone()), + "document.URL" | "document.location.href" | "window.location.href" => { + serde_json::Value::String(self.url_string()) + } + _ => serde_json::Value::Null, + } + } + } + + pub fn evaluate_for_cdp( + &mut self, + expression: &str, + return_by_value: bool, + ) -> obscura_js::runtime::RemoteObjectInfo { + if let Some(js) = &mut self.js { + match js.evaluate_for_cdp(expression, return_by_value) { + Ok(info) => info, + Err(e) => { + tracing::debug!("evaluate_for_cdp error: {}", e); + obscura_js::runtime::RemoteObjectInfo { + js_type: "undefined".into(), + subtype: None, + class_name: String::new(), + description: String::new(), + object_id: None, + value: None, + } + } + } + } else { + let val = self.evaluate(expression); + obscura_js::runtime::RemoteObjectInfo { + js_type: match &val { + serde_json::Value::String(_) => "string".into(), + serde_json::Value::Number(_) => "number".into(), + serde_json::Value::Bool(_) => "boolean".into(), + _ => "undefined".into(), + }, + subtype: None, + class_name: String::new(), + description: String::new(), + object_id: None, + value: Some(val), + } + } + } + + pub async fn call_function_on_for_cdp( + &mut self, + function_declaration: &str, + object_id: Option<&str>, + args: &[serde_json::Value], + return_by_value: bool, + await_promise: bool, + ) -> obscura_js::runtime::RemoteObjectInfo { + if let Some(js) = &mut self.js { + match js.call_function_on_for_cdp(function_declaration, object_id, args, return_by_value, await_promise).await { + Ok(info) => info, + Err(e) => { + tracing::debug!("callFunctionOn error: {}", e); + obscura_js::runtime::RemoteObjectInfo { + js_type: "undefined".into(), + subtype: None, + class_name: String::new(), + description: String::new(), + object_id: None, + value: None, + } + } + } + } else { + obscura_js::runtime::RemoteObjectInfo { + js_type: "undefined".into(), + subtype: None, + class_name: String::new(), + description: String::new(), + object_id: None, + value: None, + } + } + } + + pub fn set_blocked_urls(&mut self, patterns: Vec) { + if let Some(js) = &self.js { + js.set_blocked_urls(patterns); + } + } + + pub fn release_object(&mut self, object_id: &str) { + if let Some(js) = &mut self.js { + js.release_object(object_id); + } + } + + fn record_network_event( + &mut self, + url: &str, + method: &str, + resource_type: &str, + status: u16, + response_headers: &std::collections::HashMap, + body_size: usize, + ) { + self.network_event_counter += 1; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + self.network_events.push(NetworkEvent { + request_id: format!("{}.{}", self.id, self.network_event_counter), + url: url.to_string(), + method: method.to_string(), + resource_type: resource_type.to_string(), + status, + headers: std::collections::HashMap::new(), + response_headers: Arc::new(response_headers.clone()), + body_size, + timestamp, + }); + } + + pub fn execute_preload_script(&mut self, source: &str) -> Result<(), String> { + if let Some(js) = &mut self.js { + js.execute_script("", source) + } else { + Err("No JS runtime".to_string()) + } + } + + pub fn suspend_js(&mut self) { + if let Some(js) = &self.js { + if let Some(dom) = js.take_dom() { + self.dom = Some(dom); + } + } + self.js = None; + } + + pub fn resume_js(&mut self) { + if self.js.is_some() { + return; + } + self.init_js(); + } + + pub fn has_js(&self) -> bool { + self.js.is_some() + } + + pub fn release_object_group(&mut self) { + if let Some(js) = &mut self.js { + js.release_object_group(); + } + } + + pub fn take_pending_navigation(&self) -> Option<(String, String, String)> { + if let Some(js) = &self.js { + js.take_pending_navigation() + } else { + None + } + } + + pub fn set_intercept_tx(&mut self, tx: tokio::sync::mpsc::UnboundedSender) { + self.intercept_tx = Some(tx.clone()); + if let Some(js) = &self.js { + js.set_intercept_tx(tx); + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PageError { + #[error("Invalid URL: {0}")] + InvalidUrl(String), + + #[error("Network error: {0}")] + NetworkError(String), + + #[error("Parse error: {0}")] + ParseError(String), +} + +impl From for PageError { + fn from(e: ObscuraNetError) -> Self { + PageError::NetworkError(e.to_string()) + } +} diff --git a/crates/obscura-cdp/Cargo.toml b/crates/obscura-cdp/Cargo.toml new file mode 100644 index 0000000..eec400d --- /dev/null +++ b/crates/obscura-cdp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "obscura-cdp" +version.workspace = true +edition.workspace = true + +[dependencies] +obscura-dom = { workspace = true } +obscura-browser = { workspace = true } +obscura-js = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +futures-util = "0.3" +obscura-net = { workspace = true } diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs new file mode 100644 index 0000000..c2cee0d --- /dev/null +++ b/crates/obscura-cdp/src/dispatch.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use obscura_browser::{BrowserContext, Page}; +use obscura_js::ops::{InterceptResolution, InterceptedRequest}; +use serde_json::json; + +use crate::domains; +use crate::domains::fetch::FetchInterceptState; +use crate::types::{CdpEvent, CdpRequest, CdpResponse}; + +pub struct CdpContext { + pub pages: Vec, + pub sessions: HashMap, // session_id -> page_id + pub pending_events: Vec, + pub default_context: Arc, + page_counter: u32, + pub preload_scripts: Vec<(String, String)>, // (identifier, source) + pub preload_counter: u32, + pub fetch_intercept: FetchInterceptState, + pub intercept_tx: Option>, +} + +impl CdpContext { + pub fn new() -> Self { + Self::new_with_proxy(None) + } + + pub fn new_with_proxy(proxy: Option) -> Self { + let default_context = Arc::new(BrowserContext::with_proxy("default".to_string(), proxy)); + CdpContext { + pages: Vec::new(), + sessions: HashMap::new(), + pending_events: Vec::new(), + default_context, + page_counter: 0, + preload_scripts: Vec::new(), + preload_counter: 0, + fetch_intercept: FetchInterceptState::new(), + intercept_tx: None, + } + } + + pub fn create_page(&mut self) -> String { + self.page_counter += 1; + let page_id = format!("page-{}", self.page_counter); + let mut page = Page::new(page_id.clone(), self.default_context.clone()); + page.navigate_blank(); + self.pages.push(page); + page_id + } + + pub fn get_page(&self, id: &str) -> Option<&Page> { + self.pages.iter().find(|p| p.id == id) + } + + pub fn get_page_mut(&mut self, id: &str) -> Option<&mut Page> { + self.pages.iter_mut().find(|p| p.id == id) + } + + pub fn remove_page(&mut self, id: &str) { + self.pages.retain(|p| p.id != id); + self.sessions.retain(|_, v| v != id); + } + + pub fn get_session_page(&self, session_id: &Option) -> Option<&Page> { + let page_id = session_id + .as_ref() + .and_then(|sid| self.sessions.get(sid))?; + self.get_page(page_id) + } + + pub fn get_session_page_mut(&mut self, session_id: &Option) -> Option<&mut Page> { + let page_id = session_id + .as_ref() + .and_then(|sid| self.sessions.get(sid)) + .cloned()?; + + let target_has_js = self.pages.iter().any(|p| p.id == page_id && p.has_js()); + + if !target_has_js { + for page in &mut self.pages { + if page.id != page_id && page.has_js() { + page.suspend_js(); + break; + } + } + if let Some(target) = self.pages.iter_mut().find(|p| p.id == page_id) { + target.resume_js(); + } + } + + self.get_page_mut(&page_id) + } +} + +pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { + let (domain, method) = match req.method.split_once('.') { + Some((d, m)) => (d, m), + None => { + return CdpResponse::error( + req.id, + -32601, + format!("Invalid method format: {}", req.method), + req.session_id.clone(), + ); + } + }; + + let result = match domain { + "Target" => domains::target::handle(method, &req.params, ctx).await, + "Browser" => domains::browser::handle(method, &req.params).await, + "Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await, + "DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await, + "Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await, + "Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await, + "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await, + "Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await, + "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await, + "LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await, + "Emulation" | "Log" | "Performance" | "Security" | "CSS" + | "Accessibility" | "ServiceWorker" | "Inspector" + | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" => { + Ok(json!({})) + } + _ => Err(format!("Unknown domain: {}", domain)), + }; + + match result { + Ok(value) => CdpResponse::success(req.id, value, req.session_id.clone()), + Err(msg) => { + tracing::warn!("CDP error for {}: {}", req.method, msg); + CdpResponse::error(req.id, -32601, msg, req.session_id.clone()) + } + } +} diff --git a/crates/obscura-cdp/src/domains/browser.rs b/crates/obscura-cdp/src/domains/browser.rs new file mode 100644 index 0000000..c96d5cf --- /dev/null +++ b/crates/obscura-cdp/src/domains/browser.rs @@ -0,0 +1,31 @@ +use serde_json::{json, Value}; + +pub async fn handle(method: &str, _params: &Value) -> Result { + match method { + "getVersion" => Ok(json!({ + "protocolVersion": "1.3", + "product": "Obscura/0.1.0", + "revision": "0", + "userAgent": "Obscura/0.1.0 (Headless Browser)", + "jsVersion": "N/A", + })), + "close" => { + Ok(json!({})) + } + "getWindowForTarget" => Ok(json!({ + "windowId": 1, + "bounds": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720, + "windowState": "normal", + } + })), + "setDownloadBehavior" => Ok(json!({})), + "getWindowBounds" => Ok(json!({ + "bounds": { "left": 0, "top": 0, "width": 1280, "height": 720, "windowState": "normal" } + })), + _ => Err(format!("Unknown Browser method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/domains/dom.rs b/crates/obscura-cdp/src/domains/dom.rs new file mode 100644 index 0000000..18e08dc --- /dev/null +++ b/crates/obscura-cdp/src/domains/dom.rs @@ -0,0 +1,204 @@ +use obscura_dom::{DomTree, NodeData, NodeId}; +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => Ok(json!({})), + "getDocument" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(2); + page.with_dom(|dom| { + let node = serialize_node(dom, dom.document(), depth as u32, 0); + json!({ "root": node }) + }).ok_or_else(|| "No DOM loaded".to_string()) + } + "querySelector" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?; + let result = page.with_dom(|dom| { + dom.query_selector(selector).ok().flatten().map(|id| id.index()).unwrap_or(0) + }).unwrap_or(0); + Ok(json!({ "nodeId": result })) + } + "querySelectorAll" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?; + let ids = page.with_dom(|dom| { + dom.query_selector_all(selector).ok() + .map(|ids| ids.iter().map(|id| id.index() as u64).collect::>()) + .unwrap_or_default() + }).unwrap_or_default(); + Ok(json!({ "nodeIds": ids })) + } + "getOuterHTML" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let node_id = params.get("nodeId").and_then(|v| v.as_u64()) + .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) + .ok_or("nodeId required")?; + let html = page.with_dom(|dom| { + dom.outer_html(NodeId::new(node_id as u32)) + }).unwrap_or_default(); + Ok(json!({ "outerHTML": html })) + } + "describeNode" => { + let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; + let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(0); + + let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64()) + .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) + { + nid + } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { + let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'"); + let code = format!( + "(function() {{ var o = globalThis.__obscura_objects['{}']; if (!o) return -1; return (typeof o._nid === 'number') ? o._nid : -1; }})()", + escaped_oid + ); + let result = page.evaluate(&code); + result.as_f64().map(|n| n as u64).unwrap_or(0) + } else { + return Err("nodeId or objectId required".to_string()); + }; + + let node = page.with_dom(|dom| { + serialize_node(dom, NodeId::new(node_id as u32), depth as u32, 0) + }).unwrap_or(json!(null)); + Ok(json!({ "node": node })) + } + "resolveNode" => { + let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; + let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64()) + .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) + { + nid + } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { + let code = format!( + "(function() {{ var o = globalThis.__obscura_objects['{}']; return (o && typeof o._nid === 'number') ? o._nid : -1; }})()", + oid + ); + let result = page.evaluate(&code); + result.as_f64().map(|n| n as u64).unwrap_or(0) + } else { + return Err("nodeId or objectId required".to_string()); + }; + + let js_code = format!( + "(function() {{\ + var nid = {};\ + var node = null;\ + if (globalThis._cache && globalThis._cache.has(nid)) {{\ + node = globalThis._cache.get(nid);\ + }} else {{\ + var t = +Deno.core.ops.op_dom('node_type', String(nid), '');\ + if (t === 1) node = new Element(nid);\ + else if (t === 9) node = globalThis.document;\ + else node = new Node(nid);\ + if (globalThis._cache) globalThis._cache.set(nid, node);\ + }}\ + return node;\ + }})()", + node_id, + ); + + let info = if let Some(js) = &mut page.js { + match js.store_object_with_meta(&js_code) { + Ok(info) => info, + Err(_) => { + return Ok(json!({ + "object": { + "type": "object", + "subtype": "node", + "className": "HTMLElement", + "objectId": format!("node-{}", node_id), + } + })); + } + } + } else { + return Err("No JS runtime".to_string()); + }; + + Ok(json!({ + "object": { + "type": "object", + "subtype": "node", + "className": if info.class_name.is_empty() { "HTMLElement".to_string() } else { info.class_name }, + "description": info.description, + "objectId": info.object_id.unwrap_or_else(|| format!("node-{}", node_id)), + } + })) + } + "setAttributeValue" => Ok(json!({})), + "removeNode" => Ok(json!({})), + "getBoxModel" => { + Ok(json!({ + "model": { + "content": [8,8, 108,8, 108,28, 8,28], + "padding": [8,8, 108,8, 108,28, 8,28], + "border": [8,8, 108,8, 108,28, 8,28], + "margin": [0,0, 116,0, 116,36, 0,36], + "width": 100, "height": 20, + } + })) + } + _ => Err(format!("Unknown DOM method: {}", method)), + } +} + +fn serialize_node(dom: &DomTree, node_id: NodeId, max_depth: u32, current_depth: u32) -> Value { + let node = match dom.get_node(node_id) { + Some(n) => n, + None => return json!(null), + }; + let children_ids = dom.children(node_id); + let child_count = children_ids.len(); + let mut result = json!({ "nodeId": node_id.index(), "backendNodeId": node_id.index(), "childNodeCount": child_count }); + + match &node.data { + NodeData::Document => { + result["nodeType"] = json!(9); result["nodeName"] = json!("#document"); + result["localName"] = json!(""); result["nodeValue"] = json!(""); + result["documentURL"] = json!(""); result["baseURL"] = json!(""); result["xmlVersion"] = json!(""); + } + NodeData::Doctype { name, public_id, system_id } => { + result["nodeType"] = json!(10); result["nodeName"] = json!(name); + result["localName"] = json!(""); result["nodeValue"] = json!(""); + result["publicId"] = json!(public_id); result["systemId"] = json!(system_id); + } + NodeData::Element { name, attrs, .. } => { + result["nodeType"] = json!(1); + result["nodeName"] = json!(name.local.as_ref().to_ascii_uppercase()); + result["localName"] = json!(name.local.as_ref()); + result["nodeValue"] = json!(""); + let cdp_attrs: Vec = attrs.iter() + .flat_map(|a| vec![a.name.local.to_string(), a.value.clone()]).collect(); + result["attributes"] = json!(cdp_attrs); + } + NodeData::Text { contents } => { + result["nodeType"] = json!(3); result["nodeName"] = json!("#text"); + result["localName"] = json!(""); result["nodeValue"] = json!(contents); + } + NodeData::Comment { contents } => { + result["nodeType"] = json!(8); result["nodeName"] = json!("#comment"); + result["localName"] = json!(""); result["nodeValue"] = json!(contents); + } + NodeData::ProcessingInstruction { target, data } => { + result["nodeType"] = json!(7); result["nodeName"] = json!(target); + result["localName"] = json!(""); result["nodeValue"] = json!(data); + } + } + + if current_depth < max_depth && !children_ids.is_empty() { + let children: Vec = children_ids.iter() + .map(|&cid| serialize_node(dom, cid, max_depth, current_depth + 1)).collect(); + result["children"] = json!(children); + } + result +} diff --git a/crates/obscura-cdp/src/domains/fetch.rs b/crates/obscura-cdp/src/domains/fetch.rs new file mode 100644 index 0000000..4096856 --- /dev/null +++ b/crates/obscura-cdp/src/domains/fetch.rs @@ -0,0 +1,189 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use crate::dispatch::CdpContext; +use crate::types::CdpEvent; + +pub struct PausedRequest { + pub request_id: String, + pub url: String, + pub method: String, + pub headers: HashMap, + pub resource_type: String, + pub resolver: tokio::sync::oneshot::Sender, +} + +pub enum FetchResolution { + Continue { + url: Option, + method: Option, + headers: Option>, + post_data: Option, + }, + Fulfill { + status: u16, + headers: Vec<(String, String)>, + body: String, + }, + Fail { + reason: String, + }, +} + +pub struct FetchInterceptState { + pub enabled: bool, + pub patterns: Vec, + pub paused: HashMap, + request_counter: u64, +} + +impl FetchInterceptState { + pub fn new() -> Self { + FetchInterceptState { + enabled: false, + patterns: Vec::new(), + paused: HashMap::new(), + request_counter: 0, + } + } + + pub fn next_request_id(&mut self) -> String { + self.request_counter += 1; + format!("interception-{}", self.request_counter) + } +} + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => { + let patterns = params + .get("patterns") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + p.get("urlPattern") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect::>() + }) + .unwrap_or_else(|| vec!["*".to_string()]); + + ctx.fetch_intercept.enabled = true; + ctx.fetch_intercept.patterns = patterns.clone(); + let tx_clone = ctx.intercept_tx.clone(); + if let Some(page) = ctx.get_session_page_mut(session_id) { + page.intercept_enabled = false; + page.intercept_block_patterns.clear(); + if let Some(tx) = tx_clone { + page.set_intercept_tx(tx); + } + } + + tracing::info!("Fetch interception enabled"); + Ok(json!({})) + } + "disable" => { + ctx.fetch_intercept.enabled = false; + ctx.fetch_intercept.patterns.clear(); + if let Some(page) = ctx.get_session_page_mut(session_id) { + page.intercept_enabled = false; + page.intercept_block_patterns.clear(); + } + let paused: Vec<_> = ctx.fetch_intercept.paused.drain().collect(); + for (_, req) in paused { + let _ = req.resolver.send(FetchResolution::Continue { + url: None, + method: None, + headers: None, + post_data: None, + }); + } + Ok(json!({})) + } + "continueRequest" => { + let request_id = params + .get("requestId") + .and_then(|v| v.as_str()) + .ok_or("requestId required")?; + + if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) { + let _ = paused.resolver.send(FetchResolution::Continue { + url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()), + method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()), + headers: None, + post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()), + }); + } + Ok(json!({})) + } + "fulfillRequest" => { + let request_id = params + .get("requestId") + .and_then(|v| v.as_str()) + .ok_or("requestId required")?; + + let status = params + .get("responseCode") + .and_then(|v| v.as_u64()) + .unwrap_or(200) as u16; + let headers: HashMap = params + .get("responseHeaders") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|h| { + let name = h.get("name")?.as_str()?.to_string(); + let value = h.get("value")?.as_str()?.to_string(); + Some((name, value)) + }) + .collect() + }) + .unwrap_or_default(); + let body = params + .get("body") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) { + let _ = paused.resolver.send(FetchResolution::Fulfill { + status, + headers: headers.into_iter().collect(), + body, + }); + } + Ok(json!({})) + } + "failRequest" => { + let request_id = params + .get("requestId") + .and_then(|v| v.as_str()) + .ok_or("requestId required")?; + + let reason = params + .get("errorReason") + .and_then(|v| v.as_str()) + .unwrap_or("Failed") + .to_string(); + + if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) { + let _ = paused.resolver.send(FetchResolution::Fail { reason }); + } + Ok(json!({})) + } + "getResponseBody" => { + Ok(json!({ "body": "", "base64Encoded": false })) + } + _ => Err(format!("Unknown Fetch method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs new file mode 100644 index 0000000..ace79f6 --- /dev/null +++ b/crates/obscura-cdp/src/domains/input.rs @@ -0,0 +1,155 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "dispatchMouseEvent" => { + let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + let button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left"); + let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1); + + if event_type == "mousePressed" { + if let Some(page) = ctx.get_session_page_mut(session_id) { + let code = format!( + "(function() {{\ + var target = globalThis.__obscura_click_target || document.activeElement || document.body;\ + if (!target) return;\ + var evt = new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\ + target.dispatchEvent(evt);\ + var click = new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\ + var cancelled = !target.dispatchEvent(click);\ + if (!cancelled) {{\ + var link = target.closest ? target.closest('a[href]') : null;\ + if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\ + if (link) {{\ + var href = link.getAttribute('href');\ + if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\ + location.assign(href);\ + }}\ + }}\ + }}\ + }})()", + x = x, y = y, + ); + page.evaluate(&code); + } + } else if event_type == "mouseReleased" { + if let Some(page) = ctx.get_session_page_mut(session_id) { + let code = format!( + "(function() {{\ + var target = globalThis.__obscura_click_target || document.activeElement || document.body;\ + if (!target) return;\ + var evt = new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\ + target.dispatchEvent(evt);\ + }})()", + x = x, y = y, + ); + page.evaluate(&code); + } + } + + Ok(json!({})) + } + "dispatchKeyEvent" => { + let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let key = params.get("key").and_then(|v| v.as_str()).unwrap_or(""); + let code = params.get("code").and_then(|v| v.as_str()).unwrap_or(""); + let text = params.get("text").and_then(|v| v.as_str()).unwrap_or(""); + + if let Some(page) = ctx.get_session_page_mut(session_id) { + match event_type { + "keyDown" | "rawKeyDown" => { + let js = format!( + "(function() {{\ + var target = document.activeElement || document.body;\ + var evt = new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:'{key}',code:'{code}'}});\ + target.dispatchEvent(evt);\ + }})()", + key = key.replace('\'', "\\'"), + code = code.replace('\'', "\\'"), + ); + page.evaluate(&js); + + if !text.is_empty() && text != "\r" && text != "\n" { + let js = format!( + "(function() {{\ + var target = document.activeElement;\ + if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\ + target.value = (target.value || '') + '{text}';\ + target.dispatchEvent(new Event('input', {{bubbles:true}}));\ + }}\ + }})()", + text = text.replace('\'', "\\'").replace('\\', "\\\\"), + ); + page.evaluate(&js); + } + + if key == "Enter" { + let js = "(function() {\ + var target = document.activeElement;\ + if (target) {\ + target.dispatchEvent(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'}));\ + var form = target.form || target.closest && target.closest('form');\ + if (form && typeof form.submit === 'function') form.submit();\ + }\ + })()"; + page.evaluate(js); + } + + if key == "Backspace" { + let js = "(function() {\ + var target = document.activeElement;\ + if (target && (target.localName === 'input' || target.localName === 'textarea')) {\ + target.value = target.value.slice(0, -1);\ + target.dispatchEvent(new Event('input', {bubbles:true}));\ + }\ + })()"; + page.evaluate(js); + } + } + "keyUp" => { + let js = format!( + "(function() {{\ + var target = document.activeElement || document.body;\ + var evt = new KeyboardEvent('keyup', {{bubbles:true,key:'{key}',code:'{code}'}});\ + target.dispatchEvent(evt);\ + }})()", + key = key.replace('\'', "\\'"), + code = code.replace('\'', "\\'"), + ); + page.evaluate(&js); + } + "char" => { + if !text.is_empty() { + let js = format!( + "(function() {{\ + var target = document.activeElement;\ + if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\ + target.value = (target.value || '') + '{text}';\ + target.dispatchEvent(new Event('input', {{bubbles:true}}));\ + }}\ + }})()", + text = text.replace('\'', "\\'").replace('\\', "\\\\"), + ); + page.evaluate(&js); + } + } + _ => {} + } + } + + Ok(json!({})) + } + "dispatchTouchEvent" => Ok(json!({})), + "setIgnoreInputEvents" => Ok(json!({})), + _ => Err(format!("Unknown Input method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/domains/lp.rs b/crates/obscura-cdp/src/domains/lp.rs new file mode 100644 index 0000000..518df67 --- /dev/null +++ b/crates/obscura-cdp/src/domains/lp.rs @@ -0,0 +1,85 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + _params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "getMarkdown" => { + let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; + let code = r#" +(function() { + function toMd(el, depth) { + if (!el) return ''; + var out = ''; + if (el.nodeType === 3) return el.textContent || ''; + if (el.nodeType !== 1) return ''; + var tag = (el.tagName || '').toLowerCase(); + var children = ''; + var cn = el.childNodes || []; + for (var i = 0; i < cn.length; i++) children += toMd(cn[i], depth); + children = children.replace(/\n{3,}/g, '\n\n'); + switch(tag) { + case 'h1': return '\n# ' + children.trim() + '\n\n'; + case 'h2': return '\n## ' + children.trim() + '\n\n'; + case 'h3': return '\n### ' + children.trim() + '\n\n'; + case 'h4': return '\n#### ' + children.trim() + '\n\n'; + case 'h5': return '\n##### ' + children.trim() + '\n\n'; + case 'h6': return '\n###### ' + children.trim() + '\n\n'; + case 'p': return '\n' + children.trim() + '\n\n'; + case 'br': return '\n'; + case 'hr': return '\n---\n\n'; + case 'strong': case 'b': return '**' + children + '**'; + case 'em': case 'i': return '*' + children + '*'; + case 'code': return '`' + children + '`'; + case 'pre': return '\n```\n' + children + '\n```\n\n'; + case 'blockquote': return '\n> ' + children.trim().replace(/\n/g, '\n> ') + '\n\n'; + case 'a': + var href = el.getAttribute('href') || ''; + if (href && children.trim()) return '[' + children.trim() + '](' + href + ')'; + return children; + case 'img': + var src = el.getAttribute('src') || ''; + var alt = el.getAttribute('alt') || ''; + return '![' + alt + '](' + src + ')'; + case 'ul': case 'ol': + return '\n' + children + '\n'; + case 'li': + var parent = el.parentNode; + var isOrdered = parent && parent.tagName && parent.tagName.toLowerCase() === 'ol'; + var bullet = isOrdered ? '1. ' : '- '; + return bullet + children.trim() + '\n'; + case 'table': return '\n' + children + '\n'; + case 'thead': case 'tbody': case 'tfoot': return children; + case 'tr': + var cells = []; + var tds = el.childNodes || []; + for (var j = 0; j < tds.length; j++) { + if (tds[j].nodeType === 1) cells.push(toMd(tds[j], depth).trim()); + } + return '| ' + cells.join(' | ') + ' |\n'; + case 'th': case 'td': return children; + case 'script': case 'style': case 'noscript': case 'link': case 'meta': return ''; + case 'div': case 'section': case 'article': case 'main': case 'aside': case 'nav': case 'header': case 'footer': + return '\n' + children; + case 'span': return children; + default: return children; + } + } + var body = document.body || document.documentElement; + var md = toMd(body, 0); + md = md.replace(/\n{3,}/g, '\n\n').trim(); + return md; +})() +"#; + let result = page.evaluate(code); + let markdown = result.as_str().unwrap_or("").to_string(); + Ok(json!({ "markdown": markdown })) + } + _ => Err(format!("Unknown LP method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/domains/mod.rs b/crates/obscura-cdp/src/domains/mod.rs new file mode 100644 index 0000000..4ed300d --- /dev/null +++ b/crates/obscura-cdp/src/domains/mod.rs @@ -0,0 +1,10 @@ +pub mod target; +pub mod browser; +pub mod page; +pub mod dom; +pub mod runtime; +pub mod network; +pub mod fetch; +pub mod input; +pub mod storage; +pub mod lp; diff --git a/crates/obscura-cdp/src/domains/network.rs b/crates/obscura-cdp/src/domains/network.rs new file mode 100644 index 0000000..e3b6cd0 --- /dev/null +++ b/crates/obscura-cdp/src/domains/network.rs @@ -0,0 +1,81 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => Ok(json!({})), + "setExtraHTTPHeaders" => { + let headers = params.get("headers").and_then(|v| v.as_object()); + if let Some(page) = ctx.get_session_page(session_id) { + if let Some(headers) = headers { + let header_map: std::collections::HashMap = headers + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect(); + page.http_client.set_extra_headers(header_map).await; + } + } + Ok(json!({})) + } + "setUserAgentOverride" => { + let ua = params.get("userAgent").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(page) = ctx.get_session_page(session_id) { + page.http_client.set_user_agent(ua).await; + } + Ok(json!({})) + } + "getCookies" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let cookies = page.context.cookie_jar.get_all_cookies(); + let cdp_cookies: Vec = cookies.iter().map(|c| { + json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": -1, + "size": c.name.len() + c.value.len(), + "httpOnly": c.http_only, + "secure": c.secure, + "session": true, + "sameParty": false, + "sourceScheme": "Secure", + "sourcePort": 443, + }) + }).collect(); + Ok(json!({ "cookies": cdp_cookies })) + } + "setCookies" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) { + let cookie_infos: Vec = cookies.iter().filter_map(|c| { + Some(obscura_net::CookieInfo { + name: c.get("name")?.as_str()?.to_string(), + value: c.get("value")?.as_str()?.to_string(), + domain: c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string(), + path: c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string(), + secure: c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false), + http_only: c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false), + }) + }).collect(); + page.context.cookie_jar.set_cookies_from_cdp(cookie_infos); + } + Ok(json!({})) + } + "clearBrowserCookies" => { + if let Some(page) = ctx.get_session_page(session_id) { + page.context.cookie_jar.clear(); + } + Ok(json!({})) + } + "setCacheDisabled" => Ok(json!({})), + "setRequestInterception" => Ok(json!({})), + _ => Err(format!("Unknown Network method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs new file mode 100644 index 0000000..e33bb05 --- /dev/null +++ b/crates/obscura-cdp/src/domains/page.rs @@ -0,0 +1,221 @@ +use obscura_browser::lifecycle::WaitUntil; +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; +use crate::types::CdpEvent; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => Ok(json!({})), + "navigate" => { + let url = params.get("url").and_then(|v| v.as_str()) + .ok_or("url required")?; + + let wait_until = params.get("waitUntil") + .and_then(|v| { + if let Some(s) = v.as_str() { + Some(WaitUntil::from_str(s)) + } else if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|item| item.as_str()) + .map(WaitUntil::from_str) + .max_by_key(|w| match w { + WaitUntil::DomContentLoaded => 0, + WaitUntil::Load => 1, + WaitUntil::NetworkIdle2 => 2, + WaitUntil::NetworkIdle0 => 3, + }) + } else { + None + } + }) + .unwrap_or(WaitUntil::Load); + + let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect(); + + let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = { + let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; + let frame_id = page.frame_id.clone(); + let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); + + let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET"); + let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or(""); + if nav_method == "POST" && !nav_body.is_empty() { + page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?; + } else { + page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?; + } + + for source in &preload_scripts { + if let Err(e) = page.execute_preload_script(source) { + tracing::debug!("Preload script error: {}", e); + } + } + + let reached_network_idle = page.lifecycle.is_network_idle(); + let network_events: Vec<_> = page.network_events.drain(..).collect(); + let page_url = page.url_string(); + let page_id = page.id.clone(); + (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) + }; + + let es = session_id.clone(); + let ts = timestamp(); + + let phase1 = vec![ + CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, + CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, + CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, + CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, + CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 100, "origin": page_url, "name": "__puppeteer_utility_world__24.40.0", "uniqueId": format!("ctx-isolated-nav-{}", page_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), session_id: es.clone() }, + CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }, + ]; + ctx.pending_events.extend(phase1); + + if ctx.fetch_intercept.enabled { + for net_event in &network_events { + ctx.pending_events.push(CdpEvent { + method: "Fetch.requestPaused".into(), + params: json!({ + "requestId": net_event.request_id, + "request": { + "url": net_event.url, + "method": net_event.method, + "headers": net_event.headers, + }, + "frameId": frame_id, + "resourceType": net_event.resource_type, + "networkId": net_event.request_id, + }), + session_id: es.clone(), + }); + } + } + + for net_event in &network_events { + ctx.pending_events.push(CdpEvent { + method: "Network.requestWillBeSent".into(), + params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), + session_id: es.clone(), + }); + ctx.pending_events.push(CdpEvent { + method: "Network.responseReceived".into(), + params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}), + session_id: es.clone(), + }); + ctx.pending_events.push(CdpEvent { + method: "Network.loadingFinished".into(), + params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), + session_id: es.clone(), + }); + } + + let mut phase3 = vec![ + CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() }, + CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, + CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() }, + CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, + ]; + if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) { + let idle_ts = timestamp(); + phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() }); + } + phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es }); + ctx.pending_events.extend(phase3); + + Ok(json!({ + "frameId": frame_id, + "loaderId": loader_id, + })) + } + "getFrameTree" => { + let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + Ok(json!({ + "frameTree": { + "frame": { + "id": page.frame_id, + "loaderId": "initial-loader", + "url": page.url_string(), + "domainAndRegistry": "", + "securityOrigin": page.url_string(), + "mimeType": "text/html", + "adFrameStatus": { "adFrameType": "none" }, + }, + "childFrames": [], + } + })) + } + "createIsolatedWorld" => { + let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + let frame_id_param = params.get("frameId").and_then(|v| v.as_str()) + .unwrap_or(&page.frame_id).to_string(); + let world_name = params.get("worldName").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + let page_url = page.url_string(); + let page_id = page.id.clone(); + let context_id = 100; + + ctx.pending_events.push(CdpEvent { + method: "Runtime.executionContextCreated".to_string(), + params: json!({ + "context": { + "id": context_id, + "origin": page_url, + "name": world_name, + "uniqueId": format!("ctx-isolated-{}", page_id), + "auxData": { + "isDefault": false, + "type": "isolated", + "frameId": frame_id_param, + } + } + }), + session_id: session_id.clone(), + }); + + Ok(json!({ "executionContextId": context_id })) + } + "setLifecycleEventsEnabled" => Ok(json!({})), + "addScriptToEvaluateOnNewDocument" => { + let source = params.get("source").and_then(|v| v.as_str()).unwrap_or(""); + ctx.preload_counter += 1; + let identifier = format!("{}", ctx.preload_counter); + if !source.is_empty() { + ctx.preload_scripts.push((identifier.clone(), source.to_string())); + } + Ok(json!({ "identifier": identifier })) + } + "removeScriptToEvaluateOnNewDocument" => { + let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or(""); + ctx.preload_scripts.retain(|(id, _)| id != identifier); + Ok(json!({})) + } + "setInterceptFileChooserDialog" => Ok(json!({})), + "getNavigationHistory" => { + let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + Ok(json!({ + "currentIndex": 0, + "entries": [{ + "id": 0, + "url": page.url_string(), + "userTypedURL": page.url_string(), + "title": page.title, + "transitionType": "typed", + }] + })) + } + _ => Err(format!("Unknown Page method: {}", method)), + } +} + +fn timestamp() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} diff --git a/crates/obscura-cdp/src/domains/runtime.rs b/crates/obscura-cdp/src/domains/runtime.rs new file mode 100644 index 0000000..5feb664 --- /dev/null +++ b/crates/obscura-cdp/src/domains/runtime.rs @@ -0,0 +1,213 @@ +use obscura_js::runtime::RemoteObjectInfo; +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => { + let page = ctx.get_session_page(session_id).ok_or("No page")?; + let event = crate::types::CdpEvent { + method: "Runtime.executionContextCreated".to_string(), + params: json!({ + "context": { + "id": 1, + "origin": page.url_string(), + "name": "", + "uniqueId": format!("ctx-{}", page.id), + "auxData": { + "isDefault": true, + "type": "default", + "frameId": page.frame_id, + } + } + }), + session_id: session_id.clone(), + }; + ctx.pending_events.push(event); + Ok(json!({})) + } + "evaluate" => { + let expression = params + .get("expression") + .and_then(|v| v.as_str()) + .ok_or("expression required")?; + let return_by_value = params + .get("returnByValue") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page")?; + let info = page.evaluate_for_cdp(expression, return_by_value); + + Ok(json!({ "result": remote_object_from_info(&info) })) + } + "callFunctionOn" => { + let function_declaration = params + .get("functionDeclaration") + .and_then(|v| v.as_str()) + .unwrap_or("() => undefined"); + let return_by_value = params + .get("returnByValue") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let await_promise = params + .get("awaitPromise") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let object_id = params.get("objectId").and_then(|v| v.as_str()); + let arguments = params + .get("arguments") + .and_then(|v| v.as_array()) + .map(|a| a.to_vec()) + .unwrap_or_default(); + + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page")?; + let info = + page.call_function_on_for_cdp(function_declaration, object_id, &arguments, return_by_value, await_promise).await; + + Ok(json!({ "result": remote_object_from_info(&info) })) + } + "getProperties" => { + let object_id = params.get("objectId").and_then(|v| v.as_str()); + if let Some(oid) = object_id { + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page")?; + let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'"); + let code = format!( + "(function() {{\ + var obj = globalThis.__obscura_objects['{oid}'];\ + if (!obj || typeof obj !== 'object') return [];\ + return Object.keys(obj).map(function(k) {{\ + var v = obj[k];\ + return {{ name: k, value: v, type: typeof v }};\ + }});\ + }})()", + oid = escaped_oid, + ); + let result = page.evaluate(&code); + if let serde_json::Value::Array(props) = result { + let descriptors: Vec = props + .iter() + .map(|p| { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = p.get("value").unwrap_or(&Value::Null); + let prop_type = + p.get("type").and_then(|v| v.as_str()).unwrap_or("undefined"); + let mut remote = json!({ + "type": prop_type, + }); + match value { + Value::Null => { + remote["type"] = json!("object"); + remote["subtype"] = json!("null"); + remote["value"] = json!(null); + } + Value::String(s) => { + remote["type"] = json!("string"); + remote["value"] = json!(s); + } + Value::Number(n) => { + remote["type"] = json!("number"); + remote["value"] = json!(n); + } + Value::Bool(b) => { + remote["type"] = json!("boolean"); + remote["value"] = json!(b); + } + _ => { + remote["value"] = value.clone(); + } + } + json!({ + "name": name, + "value": remote, + "configurable": true, + "enumerable": true, + "writable": true, + "isOwn": true, + }) + }) + .collect(); + Ok(json!({ "result": descriptors, "internalProperties": [] })) + } else { + Ok(json!({ "result": [], "internalProperties": [] })) + } + } else { + Ok(json!({ "result": [], "internalProperties": [] })) + } + } + "releaseObject" => { + if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { + if let Some(page) = ctx.get_session_page_mut(session_id) { + page.release_object(oid); + } + } + Ok(json!({})) + } + "releaseObjectGroup" => { + if let Some(page) = ctx.get_session_page_mut(session_id) { + page.release_object_group(); + } + Ok(json!({})) + } + "addBinding" => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if !name.is_empty() { + if name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$') + && !name.chars().next().unwrap_or('0').is_ascii_digit() { + if let Some(page) = ctx.get_session_page_mut(session_id) { + let code = format!( + "if (typeof globalThis.{name} === 'undefined') {{\ + globalThis.{name} = function() {{ return null; }};\ + }}", + name = name, + ); + page.evaluate(&code); + } + } + } + Ok(json!({})) + } + "runIfWaitingForDebugger" => Ok(json!({})), + "getExceptionDetails" => Ok(json!({ "exceptionDetails": null })), + "discardConsoleEntries" => Ok(json!({})), + _ => Err(format!("Unknown Runtime method: {}", method)), + } +} + +fn remote_object_from_info(info: &RemoteObjectInfo) -> Value { + let mut obj = json!({ "type": info.js_type }); + + if let Some(ref subtype) = info.subtype { + obj["subtype"] = json!(subtype); + } + + if !info.class_name.is_empty() { + obj["className"] = json!(info.class_name); + } + + if !info.description.is_empty() { + obj["description"] = json!(info.description); + } + + if let Some(ref oid) = info.object_id { + obj["objectId"] = json!(oid); + } + + if let Some(ref value) = info.value { + obj["value"] = value.clone(); + } + + obj +} diff --git a/crates/obscura-cdp/src/domains/storage.rs b/crates/obscura-cdp/src/domains/storage.rs new file mode 100644 index 0000000..01e1ed9 --- /dev/null +++ b/crates/obscura-cdp/src/domains/storage.rs @@ -0,0 +1,117 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + _session_id: &Option, +) -> Result { + match method { + "getCookies" => { + let cookies = ctx.default_context.cookie_jar.get_all_cookies(); + let cdp_cookies: Vec = cookies + .iter() + .map(|c| { + json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": -1, + "size": c.name.len() + c.value.len(), + "httpOnly": c.http_only, + "secure": c.secure, + "session": true, + "priority": "Medium", + "sameParty": false, + "sourceScheme": if c.secure { "Secure" } else { "NonSecure" }, + "sourcePort": 80, + }) + }) + .collect(); + Ok(json!({ "cookies": cdp_cookies })) + } + "setCookies" => { + if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + for c in cookies { + let name = match c.get("name").and_then(|v| v.as_str()) { + Some(n) => n.to_string(), + None => continue, + }; + let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + let domain = c + .get("domain") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + c.get("url") + .and_then(|v| v.as_str()) + .and_then(|u| url::Url::parse(u).ok()) + .and_then(|u| u.host_str().map(|h| h.to_string())) + }) + .unwrap_or_default(); + + let expires = c.get("expires").and_then(|v| v.as_f64()); + if let Some(exp) = expires { + if exp > 0.0 && exp < now { + ctx.default_context.cookie_jar.delete_cookie(&name, &domain); + continue; + } + } + + let path = c + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("/") + .to_string(); + let secure = + c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false); + let http_only = + c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false); + + ctx.default_context.cookie_jar.set_cookies_from_cdp(vec![ + obscura_net::CookieInfo { + name, + value, + domain, + path, + secure, + http_only, + }, + ]); + } + } + Ok(json!({})) + } + "deleteCookies" => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let domain_from_url = params + .get("url") + .and_then(|v| v.as_str()) + .and_then(|u| url::Url::parse(u).ok()) + .and_then(|u| u.host_str().map(|h| h.to_string())); + let domain = params + .get("domain") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or(domain_from_url) + .unwrap_or_default(); + + if !name.is_empty() { + ctx.default_context + .cookie_jar + .delete_cookie(name, &domain); + } + Ok(json!({})) + } + _ => Ok(json!({})), + } +} diff --git a/crates/obscura-cdp/src/domains/target.rs b/crates/obscura-cdp/src/domains/target.rs new file mode 100644 index 0000000..bd9d1a2 --- /dev/null +++ b/crates/obscura-cdp/src/domains/target.rs @@ -0,0 +1,196 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; +use crate::types::CdpEvent; + +pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Result { + match method { + "setDiscoverTargets" => { + ctx.pending_events.push(CdpEvent::new( + "Target.targetCreated", + json!({ + "targetInfo": { + "targetId": "browser", + "type": "browser", + "title": "", + "url": "", + "attached": true, + "browserContextId": "", + } + }), + )); + for page in &ctx.pages { + ctx.pending_events.push(CdpEvent::new( + "Target.targetCreated", + json!({ + "targetInfo": { + "targetId": page.id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": false, + "browserContextId": page.context.id, + } + }), + )); + } + Ok(json!({})) + } + "getTargets" => { + let targets: Vec = ctx + .pages + .iter() + .map(|page| { + json!({ + "targetId": page.id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": true, + "browserContextId": page.context.id, + }) + }) + .collect(); + Ok(json!({ "targetInfos": targets })) + } + "createTarget" => { + let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank"); + let page_id = ctx.create_page(); + let session_id = format!("{}-session", page_id); + + if let Some(page) = ctx.get_page_mut(&page_id) { + if url == "about:blank" || url.is_empty() { + page.navigate_blank(); + } else { + let _ = page.navigate(url).await; + } + } + + ctx.sessions.insert(session_id.clone(), page_id.clone()); + + if let Some(page) = ctx.get_page(&page_id) { + ctx.pending_events.push(CdpEvent::new( + "Target.targetCreated", + json!({ + "targetInfo": { + "targetId": page_id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": false, + "browserContextId": page.context.id, + } + }), + )); + } + + if let Some(page) = ctx.get_page(&page_id) { + ctx.pending_events.push(CdpEvent::new( + "Target.attachedToTarget", + json!({ + "sessionId": session_id, + "targetInfo": { + "targetId": page_id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": true, + "browserContextId": page.context.id, + }, + "waitingForDebugger": false, + }), + )); + } + + Ok(json!({ "targetId": page_id })) + } + "attachToTarget" => { + let target_id = params.get("targetId").and_then(|v| v.as_str()) + .ok_or("targetId required")?; + let session_id = format!("{}-session", target_id); + ctx.sessions.insert(session_id.clone(), target_id.to_string()); + + if let Some(page) = ctx.get_page(target_id) { + ctx.pending_events.push(CdpEvent::new( + "Target.attachedToTarget", + json!({ + "sessionId": session_id, + "targetInfo": { + "targetId": target_id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": true, + "browserContextId": page.context.id, + }, + "waitingForDebugger": false, + }), + )); + } + + Ok(json!({ "sessionId": session_id })) + } + "closeTarget" => { + let target_id = params.get("targetId").and_then(|v| v.as_str()) + .ok_or("targetId required")?; + let session_id = format!("{}-session", target_id); + + ctx.pending_events.push(CdpEvent::new( + "Target.detachedFromTarget", + json!({ + "sessionId": session_id, + "targetId": target_id, + }), + )); + ctx.pending_events.push(CdpEvent::new( + "Target.targetDestroyed", + json!({ "targetId": target_id }), + )); + + ctx.remove_page(target_id); + Ok(json!({ "success": true })) + } + "setAutoAttach" => Ok(json!({})), + "getBrowserContexts" => { + Ok(json!({ "browserContextIds": [ctx.default_context.id] })) + } + "createBrowserContext" => { + ctx.default_context.cookie_jar.clear(); + Ok(json!({ "browserContextId": ctx.default_context.id })) + } + "disposeBrowserContext" => { + ctx.default_context.cookie_jar.clear(); + Ok(json!({})) + } + "getTargetInfo" => { + let target_id = params.get("targetId").and_then(|v| v.as_str()); + match target_id { + Some(id) => { + let page = ctx.get_page(id).ok_or("Target not found")?; + Ok(json!({ + "targetInfo": { + "targetId": id, + "type": "page", + "title": page.title, + "url": page.url_string(), + "attached": true, + "browserContextId": page.context.id, + } + })) + } + None => { + Ok(json!({ + "targetInfo": { + "targetId": "browser", + "type": "browser", + "title": "", + "url": "", + "attached": true, + } + })) + } + } + } + _ => Err(format!("Unknown Target method: {}", method)), + } +} diff --git a/crates/obscura-cdp/src/lib.rs b/crates/obscura-cdp/src/lib.rs new file mode 100644 index 0000000..2bafcdf --- /dev/null +++ b/crates/obscura-cdp/src/lib.rs @@ -0,0 +1,6 @@ +pub mod server; +pub mod dispatch; +pub mod types; +pub mod domains; + +pub use server::{start, start_with_options}; diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs new file mode 100644 index 0000000..31e0ec4 --- /dev/null +++ b/crates/obscura-cdp/src/server.rs @@ -0,0 +1,629 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tracing::{error, info, warn}; + +use crate::dispatch::{self, CdpContext}; +use crate::types::CdpRequest; + +struct CdpMessage { + text: String, + reply_tx: mpsc::UnboundedSender, +} + +enum ServerMessage { + Cdp(CdpMessage), + NewConnection { + reply_tx: mpsc::UnboundedSender, + }, +} + +pub async fn start(port: u16) -> anyhow::Result<()> { + start_with_options(port, None).await +} + +pub async fn start_with_options(port: u16, proxy: Option) -> anyhow::Result<()> { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(&addr).await?; + + info!("Obscura CDP server listening on ws://127.0.0.1:{}", port); + info!( + "DevTools endpoint: ws://127.0.0.1:{}/devtools/browser", + port + ); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (msg_tx, msg_rx) = mpsc::unbounded_channel::(); + + let processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy)); + + loop { + match listener.accept().await { + Ok((stream, peer_addr)) => { + info!("New connection from {}", peer_addr); + let tx = msg_tx.clone(); + tokio::task::spawn_local(async move { + if let Err(e) = handle_connection(stream, port, tx).await { + if !format!("{}", e).contains("close") { + error!("Connection error from {}: {}", peer_addr, e); + } + } + }); + } + Err(e) => error!("Accept error: {}", e), + } + } + }) + .await +} + +async fn cdp_processor( + mut rx: mpsc::UnboundedReceiver, + proxy: Option, +) { + let mut ctx = CdpContext::new_with_proxy(proxy); + let (itx, irx) = mpsc::unbounded_channel::(); + ctx.intercept_tx = Some(itx); + let mut intercept_rx: Option> = Some(irx); + let mut intercepted_paused: HashMap> = HashMap::new(); + + while let Some(msg) = rx.recv().await { + match msg { + ServerMessage::NewConnection { reply_tx } => { + let _ = reply_tx.send( + json!({"__init": true}) + .to_string(), + ); + } + ServerMessage::Cdp(cdp_msg) => { + let is_navigation = cdp_msg.text.contains("Page.navigate"); + let has_interception = ctx.fetch_intercept.enabled; + + if is_navigation && has_interception { + process_with_interception( + &cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut rx, + &mut intercept_rx, &mut intercepted_paused, + ).await; + } else { + if cdp_msg.text.contains("Fetch.") { + handle_fetch_resolution(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut intercepted_paused); + } + process_cdp_message(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx).await; + } + } + } + + } +} + +fn handle_fetch_resolution( + text: &str, + ctx: &mut CdpContext, + reply_tx: &mpsc::UnboundedSender, + intercepted_paused: &mut HashMap>, +) { + if let Ok(req) = serde_json::from_str::(text) { + let method = req.method.as_str(); + let request_id = req.params.get("requestId").and_then(|v| v.as_str()).unwrap_or(""); + tracing::info!("INTERCEPTION resolution: {} for {}, paused_count={}", method, request_id, intercepted_paused.len()); + + if let Some(resolver) = intercepted_paused.remove(request_id) { + tracing::info!("INTERCEPTION resolved: {}", request_id); + let resolution = match method { + "Fetch.continueRequest" => obscura_js::ops::InterceptResolution::Continue { + url: None, method: None, headers: None, body: None, + }, + "Fetch.fulfillRequest" => { + let status = req.params.get("responseCode").and_then(|v| v.as_u64()).unwrap_or(200) as u16; + let raw_body = req.params.get("body").and_then(|v| v.as_str()).unwrap_or(""); + let body = decode_base64(raw_body); + let headers = req.params.get("responseHeaders") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|h| { + Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string())) + }).collect()) + .unwrap_or_default(); + obscura_js::ops::InterceptResolution::Fulfill { status, headers, body } + } + "Fetch.failRequest" => { + let reason = req.params.get("errorReason").and_then(|v| v.as_str()).unwrap_or("Failed").to_string(); + obscura_js::ops::InterceptResolution::Fail { reason } + } + _ => return, + }; + let _ = resolver.send(resolution); + let resp = crate::types::CdpResponse::success(req.id, json!({}), req.session_id); + if let Ok(json) = serde_json::to_string(&resp) { + let _ = reply_tx.send(json); + } + } + } +} + +async fn process_with_interception( + text: &str, + ctx: &mut CdpContext, + reply_tx: &mpsc::UnboundedSender, + rx: &mut mpsc::UnboundedReceiver, + intercept_rx: &mut Option>, + intercepted_paused: &mut HashMap>, +) { + let req: CdpRequest = match serde_json::from_str(text) { + Ok(r) => r, + Err(e) => { + warn!("Invalid CDP: {}", e); + return; + } + }; + + tracing::info!("INTERCEPTION navigate: {} (id={})", req.method, req.id); + + let session_id = &req.session_id; + let page_id = session_id + .as_ref() + .and_then(|sid| ctx.sessions.get(sid)) + .cloned(); + + let page_id = match page_id { + Some(id) => id, + None => { + process_cdp_message(text, ctx, reply_tx).await; + return; + } + }; + + let page_index = ctx.pages.iter().position(|p| p.id == page_id); + let mut page = match page_index { + Some(idx) => ctx.pages.remove(idx), + None => { + process_cdp_message(text, ctx, reply_tx).await; + return; + } + }; + + let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let wait_until = req.params.get("waitUntil") + .and_then(|v| { + if let Some(s) = v.as_str() { + Some(obscura_browser::WaitUntil::from_str(s)) + } else if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|item| item.as_str()) + .map(obscura_browser::WaitUntil::from_str) + .max_by_key(|w| match w { + obscura_browser::WaitUntil::DomContentLoaded => 0, + obscura_browser::WaitUntil::Load => 1, + obscura_browser::WaitUntil::NetworkIdle2 => 2, + obscura_browser::WaitUntil::NetworkIdle0 => 3, + }) + } else { + None + } + }) + .unwrap_or(obscura_browser::WaitUntil::Load); + + let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect(); + + if let Some(tx) = &ctx.intercept_tx { + page.set_intercept_tx(tx.clone()); + } + + let session_for_events = req.session_id.clone(); + let frame_id = page.frame_id.clone(); + let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); + + let (nav_done_tx, mut nav_done_rx) = mpsc::channel::<(obscura_browser::Page, Result<(), String>)>(1); + let url_owned = url.to_string(); + + tokio::task::spawn_local(async move { + let result = page.navigate_with_wait(&url_owned, wait_until).await.map_err(|e| e.to_string()); + for source in &preload_scripts { + if let Err(e) = page.execute_preload_script(source) { + tracing::debug!("Preload script error: {}", e); + } + } + let _ = nav_done_tx.send((page, result)).await; + }); + + let mut navigate_result: Result<(), String> = Ok(()); + let mut page_back: Option = None; + + loop { + let has_irx = intercept_rx.is_some(); + + tokio::select! { + Some((returned_page, result)) = nav_done_rx.recv() => { + page_back = Some(returned_page); + navigate_result = result; + break; + } + Some(intercepted) = async { + if let Some(ref mut irx) = intercept_rx { + irx.recv().await + } else { + std::future::pending().await + } + }, if has_irx => { + tracing::info!("INTERCEPTION: requestPaused for {} {} (sending to client)", intercepted.method, intercepted.url); + let rws_event = json!({ + "method": "Network.requestWillBeSent", + "params": { + "requestId": intercepted.request_id, + "loaderId": "", + "documentURL": "", + "request": { + "url": intercepted.url, + "method": intercepted.method, + "headers": intercepted.headers, + "initialPriority": "High", + "referrerPolicy": "strict-origin-when-cross-origin", + }, + "timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(), + "wallTime": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(), + "initiator": {"type": "script"}, + "type": intercepted.resource_type, + "frameId": frame_id, + }, + "sessionId": session_for_events, + }); + let _ = reply_tx.send(rws_event.to_string()); + + let event_json = json!({ + "method": "Fetch.requestPaused", + "params": { + "requestId": intercepted.request_id, + "request": { + "url": intercepted.url, + "method": intercepted.method, + "headers": intercepted.headers, + "initialPriority": "High", + "referrerPolicy": "strict-origin-when-cross-origin", + }, + "frameId": frame_id, + "resourceType": intercepted.resource_type, + "networkId": intercepted.request_id, + "responseErrorReason": null, + "responseStatusCode": null, + "responseHeaders": null, + }, + "sessionId": session_for_events, + }); + let event_str = event_json.to_string(); + tracing::info!("INTERCEPTION event JSON: {}", &event_str[..event_str.len().min(300)]); + let _ = reply_tx.send(event_str); + intercepted_paused.insert(intercepted.request_id.clone(), intercepted.resolver); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + Some(msg) = rx.recv() => { + tracing::info!("INTERCEPTION select: received CDP message during navigation"); + match msg { + ServerMessage::NewConnection { reply_tx: new_tx } => { + let pid = ctx.create_page(); + let sid = format!("{}-session", pid); + ctx.sessions.insert(sid.clone(), pid.clone()); + let _ = new_tx.send(json!({"__init": true, "pageId": pid, "sessionId": sid}).to_string()); + } + ServerMessage::Cdp(msg) => { + if msg.text.contains("Fetch.") { + handle_fetch_resolution(&msg.text, ctx, &msg.reply_tx, intercepted_paused); + } else { + process_cdp_message(&msg.text, ctx, &msg.reply_tx).await; + } + } + } + } + } + } + + let mut page = page_back.expect("navigation task should return the page"); + + let network_events: Vec<_> = page.network_events.drain(..).collect(); + let page_url = page.url_string(); + let page_id_for_events = page.id.clone(); + let reached_network_idle = page.lifecycle.is_network_idle(); + + ctx.pages.push(page); + + let response = match navigate_result { + Ok(()) => crate::types::CdpResponse::success( + req.id, + json!({"frameId": frame_id, "loaderId": loader_id}), + req.session_id.clone(), + ), + Err(e) => crate::types::CdpResponse::error(req.id, -32000, e, req.session_id.clone()), + }; + + if let Ok(json) = serde_json::to_string(&response) { + let _ = reply_tx.send(json); + } + + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(); + let es = session_for_events; + + for event in [ + crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id_for_events), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }, + ] { + if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); } + } + + for net_event in &network_events { + for event in [ + crate::types::CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Network.responseReceived".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": ""}, "frameId": frame_id}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Network.loadingFinished".into(), params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), session_id: es.clone() }, + ] { + if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); } + } + } + + for event in [ + crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() }, + crate::types::CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, + ] { + if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); } + } + if reached_network_idle { + let idle_event = crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": ts}), session_id: es.clone() }; + if let Ok(json) = serde_json::to_string(&idle_event) { let _ = reply_tx.send(json); } + } + let stop_event = crate::types::CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es }; + if let Ok(json) = serde_json::to_string(&stop_event) { let _ = reply_tx.send(json); } +} + +async fn process_cdp_message( + text: &str, + ctx: &mut CdpContext, + reply_tx: &mpsc::UnboundedSender, +) { + let req: CdpRequest = match serde_json::from_str(text) { + Ok(r) => r, + Err(e) => { + warn!("Invalid CDP: {}: {}", e, &text[..text.len().min(200)]); + return; + } + }; + + tracing::debug!("CDP: {} (id={}, s={:?})", req.method, req.id, req.session_id); + + let response = dispatch::dispatch(&req, ctx).await; + + if let Ok(json) = serde_json::to_string(&response) { + let _ = reply_tx.send(json); + } + + for event in ctx.pending_events.drain(..) { + if let Ok(json) = serde_json::to_string(&event) { + let _ = reply_tx.send(json); + } + } + + if let Some((nav_url, nav_method, nav_body)) = check_pending_navigation(ctx, &req.session_id) { + tracing::info!("JS-triggered nav: {} {} (body: {} bytes)", nav_method, nav_url, nav_body.len()); + let nav_req = CdpRequest { + id: 0, + method: "Page.navigate".to_string(), + params: json!({"url": nav_url, "__method": nav_method, "__body": nav_body}), + session_id: req.session_id.clone(), + }; + let _ = dispatch::dispatch(&nav_req, ctx).await; + for event in ctx.pending_events.drain(..) { + if let Ok(json) = serde_json::to_string(&event) { + let _ = reply_tx.send(json); + } + } + } +} + +fn decode_base64(input: &str) -> String { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes: Vec = input.bytes().filter_map(val).collect(); + let mut out = Vec::with_capacity(bytes.len() * 3 / 4); + for chunk in bytes.chunks(4) { + let b = [ + chunk.first().copied().unwrap_or(0), + chunk.get(1).copied().unwrap_or(0), + chunk.get(2).copied().unwrap_or(0), + chunk.get(3).copied().unwrap_or(0), + ]; + out.push((b[0] << 2) | (b[1] >> 4)); + if chunk.len() > 2 { out.push((b[1] << 4) | (b[2] >> 2)); } + if chunk.len() > 3 { out.push((b[2] << 6) | b[3]); } + } + String::from_utf8_lossy(&out).to_string() +} + +fn fast_path_response(text: &str) -> Option { + let req: CdpRequest = serde_json::from_str(text).ok()?; + + let result = match req.method.as_str() { + "Network.enable" | "Network.setCacheDisabled" | "Network.setRequestInterception" | + "Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" | + "Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" | + "Performance.enable" | "Log.enable" | "Security.enable" | + "Emulation.setDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled" | + "CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" | + "Inspector.enable" | "Debugger.enable" | "Profiler.enable" | + "HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" | + "Target.setAutoAttach" => { + Some(json!({})) + } + "Browser.getVersion" => { + Some(json!({ + "protocolVersion": "1.3", + "product": "Obscura/0.1.0", + "revision": "0", + "userAgent": "Obscura/0.1.0", + "jsVersion": "V8", + })) + } + "Browser.setDownloadBehavior" | "Browser.getWindowBounds" => { + Some(json!({})) + } + _ => None, + }; + + if let Some(value) = result { + let resp = crate::types::CdpResponse::success(req.id, value, req.session_id); + serde_json::to_string(&resp).ok() + } else { + None + } +} + +fn check_pending_navigation(ctx: &CdpContext, session_id: &Option) -> Option<(String, String, String)> { + let page_id = session_id + .as_ref() + .and_then(|sid| ctx.sessions.get(sid))?; + let page = ctx.pages.iter().find(|p| &p.id == page_id)?; + page.take_pending_navigation() +} + +async fn handle_connection( + stream: TcpStream, + port: u16, + msg_tx: mpsc::UnboundedSender, +) -> anyhow::Result<()> { + let mut buf = [0u8; 4]; + stream.peek(&mut buf).await?; + + if &buf == b"GET " { + let mut peek_buf = [0u8; 1024]; + let n = stream.peek(&mut peek_buf).await?; + let line = String::from_utf8_lossy(&peek_buf[..n]); + + if line.contains("/json/version") { + return handle_http_json(stream, port, "version").await; + } else if line.contains("/json/list") || line.contains("/json\r\n") || line.contains("/json HTTP") { + return handle_http_json(stream, port, "list").await; + } else if line.contains("/json/protocol") { + return handle_http_json(stream, port, "protocol").await; + } + } + + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + info!("WebSocket connected"); + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + + let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::(); + + let _ = msg_tx.send(ServerMessage::NewConnection { + reply_tx: reply_tx.clone(), + }); + if let Some(init_msg) = reply_rx.recv().await { + tracing::debug!("Connection init: {}", &init_msg[..init_msg.len().min(100)]); + } + + let send_task = tokio::task::spawn_local(async move { + while let Some(msg) = reply_rx.recv().await { + if msg.contains("\"__init\"") { + continue; + } + if ws_sender.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + while let Some(msg) = ws_receiver.next().await { + let msg = match msg { + Ok(m) => m, + Err(e) => { + warn!("WS read error: {}", e); + break; + } + }; + + match msg { + Message::Text(text) => { + if text.contains("\"Browser.close\"") { + if let Ok(req) = serde_json::from_str::(&text) { + let resp = crate::types::CdpResponse::success(req.id, json!({}), None); + if let Ok(json) = serde_json::to_string(&resp) { + let _ = reply_tx.send(json); + } + } + break; + } + + if let Some(resp) = fast_path_response(&text) { + let _ = reply_tx.send(resp); + } else { + let _ = msg_tx.send(ServerMessage::Cdp(CdpMessage { + text: text.to_string(), + reply_tx: reply_tx.clone(), + })); + } + } + Message::Close(_) => { + info!("WS closed by client"); + break; + } + _ => {} + } + } + + send_task.abort(); + Ok(()) +} + +async fn handle_http_json(stream: TcpStream, port: u16, endpoint: &str) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut stream = stream; + let mut buf = vec![0u8; 4096]; + let _ = stream.read(&mut buf).await?; + + let body = match endpoint { + "version" => serde_json::to_string_pretty(&json!({ + "Browser": "Obscura/0.1.0", + "Protocol-Version": "1.3", + "User-Agent": "Obscura/0.1.0 (Headless Browser)", + "V8-Version": "N/A", + "WebKit-Version": "N/A", + "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port), + }))?, + "list" => serde_json::to_string_pretty(&json!([{ + "description": "", + "devtoolsFrontendUrl": "", + "id": "page-1", + "title": "", + "type": "page", + "url": "about:blank", + "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/page/page-1", port), + }]))?, + "protocol" => { + serde_json::to_string_pretty(&json!({ "version": { "major": "1", "minor": "3" } }))? + } + _ => "{}".to_string(), + }; + + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), body, + ); + stream.write_all(resp.as_bytes()).await?; + stream.flush().await?; + Ok(()) +} diff --git a/crates/obscura-cdp/src/types.rs b/crates/obscura-cdp/src/types.rs new file mode 100644 index 0000000..4a50cc7 --- /dev/null +++ b/crates/obscura-cdp/src/types.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct CdpRequest { + pub id: u64, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, + #[serde(rename = "sessionId")] + pub session_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct CdpResponse { + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl CdpResponse { + pub fn success(id: u64, result: serde_json::Value, session_id: Option) -> Self { + CdpResponse { + id, + result: Some(result), + error: None, + session_id, + } + } + + pub fn error(id: u64, code: i64, message: String, session_id: Option) -> Self { + CdpResponse { + id, + result: None, + error: Some(CdpError { code, message }), + session_id, + } + } +} + +#[derive(Debug, Serialize)] +pub struct CdpError { + pub code: i64, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct CdpEvent { + pub method: String, + pub params: serde_json::Value, + #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl CdpEvent { + pub fn new(method: &str, params: serde_json::Value) -> Self { + CdpEvent { + method: method.to_string(), + params, + session_id: None, + } + } + + pub fn with_session(method: &str, params: serde_json::Value, session_id: String) -> Self { + CdpEvent { + method: method.to_string(), + params, + session_id: Some(session_id), + } + } +} diff --git a/crates/obscura-cli/Cargo.toml b/crates/obscura-cli/Cargo.toml new file mode 100644 index 0000000..81fdc57 --- /dev/null +++ b/crates/obscura-cli/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "obscura-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "obscura" +path = "src/main.rs" + +[[bin]] +name = "obscura-worker" +path = "src/worker.rs" + +[features] +default = [] +stealth = ["obscura-browser/stealth", "obscura-net/stealth"] + +[dependencies] +obscura-browser = { workspace = true } +obscura-cdp = { workspace = true } +obscura-dom = { workspace = true } +obscura-net = { workspace = true } +obscura-js = { workspace = true } +tokio = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +url = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs new file mode 100644 index 0000000..61beca1 --- /dev/null +++ b/crates/obscura-cli/src/main.rs @@ -0,0 +1,604 @@ +use std::sync::Arc; +use std::time::Instant; + +use clap::{Parser, Subcommand}; +use obscura_browser::{BrowserContext, Page}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command as TokioCommand; + +#[derive(Parser)] +#[command(name = "obscura", about = "Obscura - A lightweight headless browser for web scraping and automation")] +struct Args { + #[arg(short, long, global = true)] + verbose: bool, + + #[command(subcommand)] + command: Option, + + #[arg(short, long, default_value_t = 9222)] + port: u16, + + #[arg(long)] + proxy: Option, + + #[arg(long)] + obey_robots: bool, + + #[arg(long)] + user_agent: Option, +} + +#[derive(Subcommand)] +enum Command { + Serve { + #[arg(short, long, default_value_t = 9222)] + port: u16, + + #[arg(long)] + proxy: Option, + + #[arg(long)] + user_agent: Option, + + #[arg(long)] + stealth: bool, + + #[arg(long, default_value_t = 1)] + workers: u16, + }, + + Fetch { + url: String, + + #[arg(long, default_value = "html")] + dump: DumpFormat, + + #[arg(long)] + selector: Option, + + #[arg(long, default_value_t = 5)] + wait: u64, + + #[arg(long, default_value = "load")] + wait_until: String, + + #[arg(long)] + user_agent: Option, + + #[arg(long)] + stealth: bool, + + #[arg(long, short)] + eval: Option, + + #[arg(long, short)] + quiet: bool, + }, + + Scrape { + urls: Vec, + + #[arg(long, short)] + eval: Option, + + #[arg(long, default_value_t = 10)] + concurrency: usize, + + #[arg(long, default_value = "json")] + format: String, + }, + +} + + +#[derive(Clone, Debug, clap::ValueEnum)] +enum DumpFormat { + Html, + Text, + Links, +} + +fn print_banner(port: u16) { + println!(r#" + ____ _ + / __ \| | + | | | | |__ ___ ___ _ _ _ __ __ _ + | | | | '_ \/ __|/ __| | | | '__/ _` | + | |__| | |_) \__ \ (__| |_| | | | (_| | + \____/|_.__/|___/\___|\__,_|_| \__,_| + + Headless Browser v0.1.0 + CDP server: ws://127.0.0.1:{}/devtools/browser +"#, port); +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + let filter = if args.verbose { "debug" } else { "warn" }; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), + ) + .with_writer(std::io::stderr) + .init(); + + match args.command { + Some(Command::Serve { port, proxy, user_agent, stealth, workers }) => { + print_banner(port); + if let Some(ref proxy) = proxy { + tracing::info!("Using proxy: {}", proxy); + } + if let Some(ref ua) = user_agent { + tracing::info!("User-Agent: {}", ua); + } + if stealth { + tracing::info!("Stealth mode enabled (TLS fingerprint spoofing)"); + } + let _ = stealth; + + if workers > 1 { + tracing::info!("{} worker processes", workers); + run_multi_worker_serve(port, workers, proxy, stealth).await?; + } else { + obscura_cdp::start_with_options(port, proxy).await?; + } + } + Some(Command::Fetch { url, dump, selector, wait, wait_until, user_agent, stealth, eval, quiet }) => { + run_fetch(&url, dump, selector, wait, &wait_until, user_agent, stealth, eval, quiet).await?; + } + Some(Command::Scrape { urls, eval, concurrency, format }) => { + run_parallel_scrape(urls, eval, concurrency, &format).await?; + } + None => { + print_banner(args.port); + if let Some(ref proxy) = args.proxy { + tracing::info!("Using proxy: {}", proxy); + } + obscura_cdp::start_with_options(args.port, args.proxy).await?; + } + } + + Ok(()) +} + +async fn run_multi_worker_serve( + port: u16, + workers: u16, + proxy: Option, + stealth: bool, +) -> anyhow::Result<()> { + use tokio::net::TcpListener; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let exe = std::env::current_exe()?; + let mut children = Vec::new(); + + for i in 0..workers { + let worker_port = port + 1 + i; + let mut cmd = std::process::Command::new(&exe); + cmd.arg("serve").arg("--port").arg(worker_port.to_string()); + if let Some(ref p) = proxy { + cmd.arg("--proxy").arg(p); + } + if stealth { + cmd.arg("--stealth"); + } + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + + let child = cmd.spawn()?; + tracing::info!("Worker {} on port {}", i + 1, worker_port); + children.push(child); + } + + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(&addr).await?; + tracing::info!("Load balancer on port {}, {} workers", port, workers); + + let mut next_worker: u16 = 0; + + loop { + let (client_stream, peer_addr) = listener.accept().await?; + let worker_port = port + 1 + (next_worker % workers); + next_worker = next_worker.wrapping_add(1); + + tracing::debug!("Routing {} to worker port {}", peer_addr, worker_port); + + let mut peek_buf = [0u8; 4]; + client_stream.peek(&mut peek_buf).await?; + + if &peek_buf == b"GET " { + let mut full_peek = [0u8; 256]; + let n = client_stream.peek(&mut full_peek).await?; + let request_line = String::from_utf8_lossy(&full_peek[..n]); + + if request_line.contains("/json") { + let worker_addr = format!("127.0.0.1:{}", port + 1); + if let Ok(mut worker_stream) = tokio::net::TcpStream::connect(&worker_addr).await { + tokio::spawn(async move { + let _ = tokio::io::copy_bidirectional(&mut tokio::net::TcpStream::from_std(client_stream.into_std().unwrap()).unwrap(), &mut worker_stream).await; + }); + } + continue; + } + } + + let worker_addr = format!("127.0.0.1:{}", worker_port); + tokio::spawn(async move { + if let Ok(mut worker_stream) = tokio::net::TcpStream::connect(&worker_addr).await { + let mut client = client_stream; + let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await; + } + }); + } +} + +async fn run_fetch( + url_str: &str, + dump: DumpFormat, + selector: Option, + wait_secs: u64, + wait_until: &str, + user_agent: Option, + stealth: bool, + eval: Option, + quiet: bool, +) -> anyhow::Result<()> { + let context = Arc::new(BrowserContext::with_options("fetch".to_string(), None, stealth)); + let mut page = Page::new("fetch-page".to_string(), context); + + if let Some(ref ua) = user_agent { + page.http_client.set_user_agent(ua).await; + } + + let wait_condition = obscura_browser::lifecycle::WaitUntil::from_str(wait_until); + + if !quiet { + eprintln!("Fetching {}...", url_str); + } + + page.navigate_with_wait(url_str, wait_condition).await.map_err(|e| { + anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e) + })?; + + if !quiet { + eprintln!("Page loaded: {} - \"{}\"", page.url_string(), page.title); + } + + if let Some(ref sel) = selector { + let found = wait_for_selector(&mut page, sel, wait_secs).await; + if !found { + eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs); + } + } + + if let Some(ref expr) = eval { + let result = page.evaluate(expr); + match result { + serde_json::Value::String(s) => println!("{}", s), + serde_json::Value::Null => println!("null"), + other => println!("{}", other), + } + return Ok(()); + } + + match dump { + DumpFormat::Html => { + dump_html(&page); + } + DumpFormat::Text => { + dump_text(&mut page); + } + DumpFormat::Links => { + dump_links(&page); + } + } + + Ok(()) +} + +async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool { + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); + loop { + let found = page.with_dom(|dom| { + dom.query_selector(selector).ok().flatten().is_some() + }).unwrap_or(false); + + if found { + return true; + } + + if tokio::time::Instant::now() >= deadline { + return false; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } +} + +fn dump_html(page: &Page) { + page.with_dom(|dom| { + if let Ok(Some(html_node)) = dom.query_selector("html") { + let html = dom.outer_html(html_node); + println!(""); + println!("{}", html); + } else { + let doc = dom.document(); + let html = dom.inner_html(doc); + println!("{}", html); + } + }); +} + +fn dump_text(page: &mut Page) { + page.with_dom(|dom| { + if let Ok(Some(body)) = dom.query_selector("body") { + let text = extract_readable_text(dom, body); + println!("{}", text.trim()); + } + }); +} + +fn extract_readable_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeId) -> String { + use obscura_dom::NodeData; + + let mut result = String::new(); + let node = match dom.get_node(node_id) { + Some(n) => n, + None => return result, + }; + + match &node.data { + NodeData::Text { contents } => { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + result.push_str(trimmed); + } + } + NodeData::Element { name, .. } => { + let tag = name.local.as_ref(); + let is_block = matches!( + tag, + "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" + | "li" | "tr" | "br" | "hr" | "blockquote" | "pre" + | "section" | "article" | "header" | "footer" | "nav" + | "main" | "aside" | "figure" | "figcaption" | "table" + | "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd" + | "ul" | "ol" + ); + + if tag == "script" || tag == "style" { + return result; + } + + if is_block { + result.push('\n'); + } + + for child_id in dom.children(node_id) { + result.push_str(&extract_readable_text(dom, child_id)); + } + + if is_block { + result.push('\n'); + } + } + _ => { + for child_id in dom.children(node_id) { + result.push_str(&extract_readable_text(dom, child_id)); + } + } + } + + result +} + +async fn run_parallel_scrape( + urls: Vec, + eval: Option, + concurrency: usize, + format: &str, +) -> anyhow::Result<()> { + let total = urls.len(); + let start = Instant::now(); + + eprintln!( + "Scraping {} URLs with {} concurrent workers...", + total, concurrency + ); + + let worker_path = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("obscura-worker"))) + .unwrap_or_else(|| std::path::PathBuf::from("obscura-worker")); + + if !worker_path.exists() { + anyhow::bail!( + "Worker binary not found at {}. Build with: cargo build --release", + worker_path.display() + ); + } + + let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency)); + let eval = Arc::new(eval); + let worker_path = Arc::new(worker_path); + + let mut handles = Vec::new(); + + for (i, url) in urls.into_iter().enumerate() { + let sem = semaphore.clone(); + let eval = eval.clone(); + let worker_path = worker_path.clone(); + + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + let task_start = Instant::now(); + + let mut child = match TokioCommand::new(worker_path.as_ref()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + return serde_json::json!({ + "url": url, + "error": format!("Failed to spawn worker: {}", e), + "time_ms": task_start.elapsed().as_millis(), + }); + } + }; + + let stdin = child.stdin.as_mut().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url}); + let mut line = serde_json::to_string(&nav_cmd).unwrap(); + line.push('\n'); + if stdin.write_all(line.as_bytes()).await.is_err() { + let _ = child.kill().await; + return serde_json::json!({"url": url, "error": "Write failed"}); + } + let _ = stdin.flush().await; + + let mut resp_line = String::new(); + if reader.read_line(&mut resp_line).await.is_err() { + let _ = child.kill().await; + return serde_json::json!({"url": url, "error": "Read failed"}); + } + + let nav_resp: serde_json::Value = + serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false})); + + if !nav_resp["ok"].as_bool().unwrap_or(false) { + let _ = child.kill().await; + return serde_json::json!({ + "url": url, + "error": nav_resp["error"].as_str().unwrap_or("navigate failed"), + "time_ms": task_start.elapsed().as_millis(), + }); + } + + let title = nav_resp["result"]["title"] + .as_str() + .unwrap_or("") + .to_string(); + + let eval_result = if let Some(ref expr) = *eval { + let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr}); + let mut line = serde_json::to_string(&eval_cmd).unwrap(); + line.push('\n'); + let _ = stdin.write_all(line.as_bytes()).await; + let _ = stdin.flush().await; + + let mut resp_line = String::new(); + if reader.read_line(&mut resp_line).await.is_ok() { + let resp: serde_json::Value = serde_json::from_str(resp_line.trim()) + .unwrap_or(serde_json::json!({"ok": false})); + resp["result"].clone() + } else { + serde_json::Value::Null + } + } else { + serde_json::Value::Null + }; + + let shutdown_cmd = serde_json::json!({"cmd": "shutdown"}); + let mut line = serde_json::to_string(&shutdown_cmd).unwrap(); + line.push('\n'); + let _ = stdin.write_all(line.as_bytes()).await; + let _ = stdin.flush().await; + let _ = child.wait().await; + + let elapsed = task_start.elapsed().as_millis(); + + serde_json::json!({ + "url": url, + "title": title, + "eval": eval_result, + "time_ms": elapsed, + "worker": i, + }) + }); + + handles.push(handle); + } + + let mut results = Vec::new(); + for handle in handles { + match handle.await { + Ok(result) => results.push(result), + Err(e) => results.push(serde_json::json!({"error": e.to_string()})), + } + } + + let total_time = start.elapsed(); + + if format == "json" { + let output = serde_json::json!({ + "total_urls": total, + "concurrency": concurrency, + "total_time_ms": total_time.as_millis(), + "avg_time_ms": total_time.as_millis() as f64 / total as f64, + "results": results, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + for r in &results { + let url = r["url"].as_str().unwrap_or("?"); + let title = r["title"].as_str().unwrap_or(""); + let time = r["time_ms"].as_u64().unwrap_or(0); + let eval = &r["eval"]; + if eval.is_null() { + println!("{}ms\t{}\t{}", time, url, title); + } else { + println!("{}ms\t{}\t{}", time, url, eval); + } + } + eprintln!( + "\nTotal: {}ms for {} URLs ({} concurrent)", + total_time.as_millis(), + total, + concurrency + ); + } + + Ok(()) +} + +fn dump_links(page: &Page) { + let base_url = page.url.clone(); + page.with_dom(|dom| { + let links = dom.query_selector_all("a").unwrap_or_default(); + for link_id in links { + if let Some(node) = dom.get_node(link_id) { + let href = node.get_attribute("href").unwrap_or_default().to_string(); + let text = dom.text_content(link_id); + let text = text.trim(); + + let full_url = if href.starts_with("http://") || href.starts_with("https://") { + href.clone() + } else if let Some(ref base) = base_url { + base.join(&href).map(|u| u.to_string()).unwrap_or(href.clone()) + } else { + href.clone() + }; + + if !full_url.is_empty() { + if text.is_empty() { + println!("{}", full_url); + } else { + println!("{}\t{}", full_url, text); + } + } + } + } + }); +} diff --git a/crates/obscura-cli/src/worker.rs b/crates/obscura-cli/src/worker.rs new file mode 100644 index 0000000..57a6123 --- /dev/null +++ b/crates/obscura-cli/src/worker.rs @@ -0,0 +1,138 @@ + +use std::sync::Arc; + +use obscura_browser::{BrowserContext, Page}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +#[derive(Debug, Deserialize)] +#[serde(tag = "cmd")] +enum WorkerCommand { + #[serde(rename = "navigate")] + Navigate { url: String }, + #[serde(rename = "evaluate")] + Evaluate { expression: String }, + #[serde(rename = "title")] + Title, + #[serde(rename = "dump_html")] + DumpHtml, + #[serde(rename = "dump_text")] + DumpText, + #[serde(rename = "shutdown")] + Shutdown, +} + +#[derive(Debug, Serialize)] +struct WorkerResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl WorkerResponse { + fn success(result: serde_json::Value) -> Self { + WorkerResponse { ok: true, result: Some(result), error: None } + } + fn error(msg: String) -> Self { + WorkerResponse { ok: false, result: None, error: Some(msg) } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("warn") + .with_writer(std::io::stderr) + .init(); + + let context = Arc::new(BrowserContext::new("worker".to_string())); + let mut page = Page::new("page-1".to_string(), context); + + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut reader = BufReader::new(stdin); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => {} + Err(e) => { + eprintln!("Worker stdin error: {}", e); + break; + } + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let cmd: WorkerCommand = match serde_json::from_str(trimmed) { + Ok(c) => c, + Err(e) => { + let resp = WorkerResponse::error(format!("Invalid command: {}", e)); + let mut out = serde_json::to_string(&resp).unwrap(); + out.push('\n'); + let _ = stdout.write_all(out.as_bytes()).await; + let _ = stdout.flush().await; + continue; + } + }; + + let resp = match cmd { + WorkerCommand::Navigate { url } => { + match page.navigate(&url).await { + Ok(()) => WorkerResponse::success(serde_json::json!({ + "title": page.title, + "url": page.url_string(), + })), + Err(e) => WorkerResponse::error(e.to_string()), + } + } + WorkerCommand::Evaluate { expression } => { + let result = page.evaluate(&expression); + WorkerResponse::success(result) + } + WorkerCommand::Title => { + WorkerResponse::success(serde_json::json!(page.title)) + } + WorkerCommand::DumpHtml => { + let html = page.with_dom(|dom| { + if let Ok(Some(html_node)) = dom.query_selector("html") { + dom.outer_html(html_node) + } else { + dom.inner_html(dom.document()) + } + }).unwrap_or_default(); + WorkerResponse::success(serde_json::json!(html)) + } + WorkerCommand::DumpText => { + let text = page.with_dom(|dom| { + if let Ok(Some(body)) = dom.query_selector("body") { + dom.text_content(body) + } else { + String::new() + } + }).unwrap_or_default(); + WorkerResponse::success(serde_json::json!(text)) + } + WorkerCommand::Shutdown => { + let resp = WorkerResponse::success(serde_json::json!("bye")); + let mut out = serde_json::to_string(&resp).unwrap(); + out.push('\n'); + let _ = stdout.write_all(out.as_bytes()).await; + let _ = stdout.flush().await; + break; + } + }; + + let mut out = serde_json::to_string(&resp).unwrap(); + out.push('\n'); + let _ = stdout.write_all(out.as_bytes()).await; + let _ = stdout.flush().await; + } +} diff --git a/crates/obscura-dom/Cargo.toml b/crates/obscura-dom/Cargo.toml new file mode 100644 index 0000000..58260db --- /dev/null +++ b/crates/obscura-dom/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "obscura-dom" +version.workspace = true +edition.workspace = true + +[dependencies] +html5ever = { workspace = true } +markup5ever = { workspace = true } +selectors = { workspace = true } +servo_arc = { workspace = true } +cssparser = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +precomputed-hash = "0.1" + +[dev-dependencies] diff --git a/crates/obscura-dom/src/lib.rs b/crates/obscura-dom/src/lib.rs new file mode 100644 index 0000000..6d3d6fc --- /dev/null +++ b/crates/obscura-dom/src/lib.rs @@ -0,0 +1,10 @@ +#[macro_use] +extern crate html5ever; + +pub mod tree; +pub mod tree_sink; +pub mod selector; +pub mod serialize; + +pub use tree::{Attribute, DomTree, Node, NodeData, NodeId}; +pub use tree_sink::{parse_html, parse_fragment}; diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs new file mode 100644 index 0000000..d0298c0 --- /dev/null +++ b/crates/obscura-dom/src/selector.rs @@ -0,0 +1,641 @@ +use cssparser::{CowRcStr, ToCss}; +use html5ever::{LocalName, Namespace}; +use precomputed_hash::PrecomputedHash; +use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint}; +use selectors::bloom::BloomFilter; +use selectors::context::QuirksMode; +use selectors::matching::{ + ElementSelectorFlags, MatchingContext, MatchingForInvalidation, MatchingMode, + NeedsSelectorFlags, +}; +use selectors::parser::{self, ParseRelative, SelectorParseErrorKind}; +use selectors::{Element, OpaqueElement, SelectorList}; +use selectors::visitor::SelectorVisitor; + +use crate::tree::{DomTree, NodeData, NodeId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObscuraSelector; + +impl parser::SelectorImpl for ObscuraSelector { + type ExtraMatchingData<'a> = (); + type AttrValue = CssString; + type Identifier = CssString; + type LocalName = CssLocalName; + type NamespaceUrl = CssNamespace; + type NamespacePrefix = CssString; + type BorrowedLocalName = CssLocalName; + type BorrowedNamespaceUrl = CssNamespace; + type NonTSPseudoClass = PseudoClass; + type PseudoElement = PseudoElement; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CssString(pub String); + +impl<'a> From<&'a str> for CssString { + fn from(s: &'a str) -> Self { + CssString(s.to_string()) + } +} + +impl AsRef for CssString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl ToCss for CssString { + fn to_css(&self, dest: &mut W) -> std::fmt::Result { + cssparser::serialize_string(&self.0, dest) + } +} + +impl PrecomputedHash for CssString { + fn precomputed_hash(&self) -> u32 { + let mut h: u32 = 5381; + for b in self.0.as_bytes() { + h = h.wrapping_mul(33).wrapping_add(*b as u32); + } + h + } +} + +impl Default for CssString { + fn default() -> Self { + CssString(String::new()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CssLocalName(pub LocalName); + +impl<'a> From<&'a str> for CssLocalName { + fn from(s: &'a str) -> Self { + CssLocalName(LocalName::from(s)) + } +} + +impl ToCss for CssLocalName { + fn to_css(&self, dest: &mut W) -> std::fmt::Result { + dest.write_str(&self.0) + } +} + +impl PrecomputedHash for CssLocalName { + fn precomputed_hash(&self) -> u32 { + self.0.precomputed_hash() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct CssNamespace(pub Namespace); + +impl PrecomputedHash for CssNamespace { + fn precomputed_hash(&self) -> u32 { + self.0.precomputed_hash() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PseudoClass { + Hover, + Active, + Focus, + Enabled, + Disabled, + Checked, +} + +impl parser::NonTSPseudoClass for PseudoClass { + type Impl = ObscuraSelector; + + fn is_active_or_hover(&self) -> bool { + matches!(self, PseudoClass::Hover | PseudoClass::Active) + } + + fn is_user_action_state(&self) -> bool { + matches!( + self, + PseudoClass::Hover | PseudoClass::Active | PseudoClass::Focus + ) + } + + fn visit(&self, _visitor: &mut V) -> bool + where + V: SelectorVisitor, + { + true + } +} + +impl ToCss for PseudoClass { + fn to_css(&self, dest: &mut W) -> std::fmt::Result { + match self { + PseudoClass::Hover => dest.write_str(":hover"), + PseudoClass::Active => dest.write_str(":active"), + PseudoClass::Focus => dest.write_str(":focus"), + PseudoClass::Enabled => dest.write_str(":enabled"), + PseudoClass::Disabled => dest.write_str(":disabled"), + PseudoClass::Checked => dest.write_str(":checked"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PseudoElement { + Before, + After, +} + +impl parser::PseudoElement for PseudoElement { + type Impl = ObscuraSelector; +} + +impl ToCss for PseudoElement { + fn to_css(&self, dest: &mut W) -> std::fmt::Result { + match self { + PseudoElement::Before => dest.write_str("::before"), + PseudoElement::After => dest.write_str("::after"), + } + } +} + +pub struct ObscuraSelectorParser; + +impl<'i> parser::Parser<'i> for ObscuraSelectorParser { + type Impl = ObscuraSelector; + type Error = SelectorParseErrorKind<'i>; + + fn parse_non_ts_pseudo_class( + &self, + _location: cssparser::SourceLocation, + name: CowRcStr<'i>, + ) -> Result> { + match name.as_ref() { + "hover" => Ok(PseudoClass::Hover), + "active" => Ok(PseudoClass::Active), + "focus" => Ok(PseudoClass::Focus), + "enabled" => Ok(PseudoClass::Enabled), + "disabled" => Ok(PseudoClass::Disabled), + "checked" => Ok(PseudoClass::Checked), + _ => Err(cssparser::ParseError { + kind: cssparser::ParseErrorKind::Custom( + SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name), + ), + location: _location, + }), + } + } +} + +#[derive(Clone, Copy)] +pub struct DomElement<'a> { + pub tree: &'a DomTree, + pub node_id: NodeId, +} + +impl<'a> DomElement<'a> { + pub fn new(tree: &'a DomTree, node_id: NodeId) -> Self { + DomElement { tree, node_id } + } +} + +impl<'a> std::fmt::Debug for DomElement<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "DomElement({:?})", self.node_id) + } +} + +impl<'a> PartialEq for DomElement<'a> { + fn eq(&self, other: &Self) -> bool { + self.node_id == other.node_id + } +} + +impl<'a> Eq for DomElement<'a> {} + +impl<'a> Element for DomElement<'a> { + type Impl = ObscuraSelector; + + fn opaque(&self) -> OpaqueElement { + OpaqueElement::new(self) + } + + fn parent_element(&self) -> Option { + let node = self.tree.get_node(self.node_id)?; + let parent_id = node.parent?; + let parent = self.tree.get_node(parent_id)?; + if parent.is_element() { + Some(DomElement::new(self.tree, parent_id)) + } else { + None + } + } + + fn parent_node_is_shadow_root(&self) -> bool { + false + } + + fn containing_shadow_host(&self) -> Option { + None + } + + fn pseudo_element_originating_element(&self) -> Option { + None + } + + fn is_pseudo_element(&self) -> bool { + false + } + + fn prev_sibling_element(&self) -> Option { + let node = self.tree.get_node(self.node_id)?; + let mut current = node.prev_sibling; + while let Some(sibling_id) = current { + let sibling = self.tree.get_node(sibling_id)?; + if sibling.is_element() { + return Some(DomElement::new(self.tree, sibling_id)); + } + current = sibling.prev_sibling; + } + None + } + + fn next_sibling_element(&self) -> Option { + let node = self.tree.get_node(self.node_id)?; + let mut current = node.next_sibling; + while let Some(sibling_id) = current { + let sibling = self.tree.get_node(sibling_id)?; + if sibling.is_element() { + return Some(DomElement::new(self.tree, sibling_id)); + } + current = sibling.next_sibling; + } + None + } + + fn first_element_child(&self) -> Option { + let node = self.tree.get_node(self.node_id)?; + let mut current = node.first_child; + while let Some(child_id) = current { + let child = self.tree.get_node(child_id)?; + if child.is_element() { + return Some(DomElement::new(self.tree, child_id)); + } + current = child.next_sibling; + } + None + } + + fn is_html_element_in_html_document(&self) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.as_element() + .map(|name| name.ns == ns!(html)) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn has_local_name(&self, local_name: &CssLocalName) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.as_element() + .map(|name| name.local == local_name.0) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn has_namespace(&self, ns: &CssNamespace) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.as_element() + .map(|name| name.ns == ns.0) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn is_same_type(&self, other: &Self) -> bool { + let self_name = self.tree.with_node(self.node_id, |n| { + n.as_element().map(|name| (name.local.clone(), name.ns.clone())) + }).flatten(); + let other_name = self.tree.with_node(other.node_id, |n| { + n.as_element().map(|name| (name.local.clone(), name.ns.clone())) + }).flatten(); + match (self_name, other_name) { + (Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns, + _ => false, + } + } + + fn attr_matches( + &self, + ns: &NamespaceConstraint<&CssNamespace>, + local_name: &CssLocalName, + operation: &AttrSelectorOperation<&CssString>, + ) -> bool { + self.tree + .with_node(self.node_id, |node| { + let attrs = match node.attrs() { + Some(a) => a, + None => return false, + }; + attrs.iter().any(|attr| { + let ns_match = match ns { + NamespaceConstraint::Any => true, + NamespaceConstraint::Specific(expected_ns) => attr.name.ns == expected_ns.0, + }; + if !ns_match || attr.name.local != local_name.0 { + return false; + } + operation.eval_str(&attr.value) + }) + }) + .unwrap_or(false) + } + + fn has_attr_in_no_namespace(&self, local_name: &CssLocalName) -> bool { + self.tree + .with_node(self.node_id, |node| { + node.attrs() + .map(|attrs| { + attrs.iter().any(|a| { + a.name.ns == html5ever::ns!() + && a.name.local == local_name.0 + }) + }) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn match_non_ts_pseudo_class( + &self, + _pc: &PseudoClass, + _context: &mut MatchingContext<'_, Self::Impl>, + ) -> bool { + false + } + + fn match_pseudo_element( + &self, + _pe: &PseudoElement, + _context: &mut MatchingContext<'_, Self::Impl>, + ) -> bool { + false + } + + fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {} + + fn is_link(&self) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.as_element() + .map(|name| { + matches!(name.local.as_ref(), "a" | "area" | "link") + && n.get_attribute("href").is_some() + }) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn is_html_slot_element(&self) -> bool { + false + } + + fn assigned_slot(&self) -> Option { + None + } + + fn has_id(&self, id: &CssString, case_sensitivity: CaseSensitivity) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.get_attribute("id") + .map(|value| case_sensitivity.eq(value.as_bytes(), id.0.as_bytes())) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn has_class(&self, name: &CssString, case_sensitivity: CaseSensitivity) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.get_attribute("class") + .map(|class_attr| { + class_attr + .split_whitespace() + .any(|c| case_sensitivity.eq(c.as_bytes(), name.0.as_bytes())) + }) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn has_custom_state(&self, _name: &CssString) -> bool { + false + } + + fn imported_part(&self, _name: &CssString) -> Option { + None + } + + fn is_part(&self, _name: &CssString) -> bool { + false + } + + fn is_empty(&self) -> bool { + self.tree + .with_node(self.node_id, |node| { + let mut child = node.first_child; + while let Some(child_id) = child { + if let Some(child_node) = self.tree.get_node(child_id) { + match &child_node.data { + NodeData::Element { .. } => return false, + NodeData::Text { contents } if !contents.is_empty() => return false, + _ => {} + } + child = child_node.next_sibling; + } else { + break; + } + } + true + }) + .unwrap_or(true) + } + + fn is_root(&self) -> bool { + self.tree + .with_node(self.node_id, |n| { + n.parent + .map(|parent_id| { + self.tree + .with_node(parent_id, |p| p.is_document()) + .unwrap_or(false) + }) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn ignores_nth_child_selectors(&self) -> bool { + false + } + + fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool { + false + } +} + +pub fn parse_selector(selector: &str) -> Result, String> { + let mut parser_input = cssparser::ParserInput::new(selector); + let mut parser = cssparser::Parser::new(&mut parser_input); + SelectorList::parse(&ObscuraSelectorParser, &mut parser, ParseRelative::No) + .map_err(|e| format!("Failed to parse selector '{}': {:?}", selector, e)) +} + +impl DomTree { + pub fn query_selector(&self, selector: &str) -> Result, String> { + let selector_list = parse_selector(selector)?; + let mut caches = selectors::context::SelectorCaches::default(); + let mut context = MatchingContext::new( + MatchingMode::Normal, + None, + &mut caches, + QuirksMode::NoQuirks, + NeedsSelectorFlags::No, + MatchingForInvalidation::No, + ); + + let doc = self.document(); + for desc_id in self.descendants(doc) { + let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); + if is_element { + let element = DomElement::new(self, desc_id); + if selectors::matching::matches_selector_list( + &selector_list, + &element, + &mut context, + ) { + return Ok(Some(desc_id)); + } + } + } + Ok(None) + } + + pub fn query_selector_all(&self, selector: &str) -> Result, String> { + let selector_list = parse_selector(selector)?; + let mut caches = selectors::context::SelectorCaches::default(); + let mut context = MatchingContext::new( + MatchingMode::Normal, + None, + &mut caches, + QuirksMode::NoQuirks, + NeedsSelectorFlags::No, + MatchingForInvalidation::No, + ); + let mut results = Vec::new(); + + let doc = self.document(); + for desc_id in self.descendants(doc) { + let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); + if is_element { + let element = DomElement::new(self, desc_id); + if selectors::matching::matches_selector_list( + &selector_list, + &element, + &mut context, + ) { + results.push(desc_id); + } + } + } + Ok(results) + } +} + +#[cfg(test)] +mod tests { + use crate::tree_sink::parse_html; + + #[test] + fn test_query_selector_tag() { + let tree = parse_html("

Title

Text

"); + let result = tree.query_selector("h1").unwrap(); + assert!(result.is_some()); + let node = tree.get_node(result.unwrap()).unwrap(); + assert_eq!(node.as_element().unwrap().local.as_ref(), "h1"); + } + + #[test] + fn test_query_selector_class() { + let tree = + parse_html(r#"
Content
Other
"#); + let result = tree.query_selector(".foo").unwrap(); + assert!(result.is_some()); + let node = tree.get_node(result.unwrap()).unwrap(); + assert_eq!(node.get_attribute("class"), Some("foo bar")); + } + + #[test] + fn test_query_selector_id() { + let tree = parse_html(r#"
Content
"#); + let result = tree.query_selector("#main").unwrap(); + assert!(result.is_some()); + } + + #[test] + fn test_query_selector_all() { + let tree = parse_html("
  • 1
  • 2
  • 3
"); + let results = tree.query_selector_all("li").unwrap(); + assert_eq!(results.len(), 3); + } + + #[test] + fn test_query_selector_descendant() { + let tree = + parse_html(r#"
Target
"#); + let result = tree.query_selector("#outer span").unwrap(); + assert!(result.is_some()); + let node = tree.get_node(result.unwrap()).unwrap(); + assert_eq!(node.as_element().unwrap().local.as_ref(), "span"); + } + + #[test] + fn test_query_selector_attribute() { + let tree = parse_html( + r#""#, + ); + let result = tree.query_selector(r#"input[type="password"]"#).unwrap(); + assert!(result.is_some()); + let node = tree.get_node(result.unwrap()).unwrap(); + assert_eq!(node.get_attribute("name"), Some("pass")); + } + + #[test] + fn test_query_selector_no_match() { + let tree = parse_html("
Hello
"); + let result = tree.query_selector("span").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_query_selector_complex() { + let tree = parse_html( + r#"
+
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
+
"#, + ); + let results = tree.query_selector_all(".list .item.active").unwrap(); + assert_eq!(results.len(), 2); + } +} diff --git a/crates/obscura-dom/src/serialize.rs b/crates/obscura-dom/src/serialize.rs new file mode 100644 index 0000000..7a489a5 --- /dev/null +++ b/crates/obscura-dom/src/serialize.rs @@ -0,0 +1,178 @@ +use crate::tree::{DomTree, NodeData, NodeId}; + +impl DomTree { + pub fn outer_html(&self, node_id: NodeId) -> String { + let mut buf = String::new(); + self.serialize_node(node_id, true, &mut buf); + buf + } + + pub fn inner_html(&self, node_id: NodeId) -> String { + let mut buf = String::new(); + self.serialize_children(node_id, &mut buf); + buf + } + + fn serialize_node(&self, node_id: NodeId, include_self: bool, buf: &mut String) { + let node = match self.get_node(node_id) { + Some(n) => n, + None => return, + }; + + match &node.data { + NodeData::Document => { + self.serialize_children(node_id, buf); + } + NodeData::Doctype { name, .. } => { + buf.push_str("'); + } + NodeData::Element { name, attrs, .. } => { + let tag = name.local.as_ref(); + if include_self { + buf.push('<'); + buf.push_str(tag); + for attr in attrs { + buf.push(' '); + let attr_name = attr.name.local.as_ref(); + buf.push_str(attr_name); + buf.push_str("=\""); + escape_attr(&attr.value, buf); + buf.push('"'); + } + buf.push('>'); + } + + if !is_void_element(tag) { + self.serialize_children(node_id, buf); + if include_self { + buf.push_str("'); + } + } + } + NodeData::Text { contents } => { + let parent_is_raw = node.parent + .and_then(|pid| { + self.with_node(pid, |p| { + p.as_element() + .map(|name| is_raw_text_element(name.local.as_ref())) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if parent_is_raw { + buf.push_str(contents); + } else { + escape_text(contents, buf); + } + } + NodeData::Comment { contents } => { + buf.push_str(""); + } + NodeData::ProcessingInstruction { target, data } => { + buf.push_str("'); + } + } + } + + fn serialize_children(&self, node_id: NodeId, buf: &mut String) { + for child_id in self.children(node_id) { + self.serialize_node(child_id, true, buf); + } + } +} + +fn escape_text(s: &str, buf: &mut String) { + for c in s.chars() { + match c { + '&' => buf.push_str("&"), + '<' => buf.push_str("<"), + '>' => buf.push_str(">"), + _ => buf.push(c), + } + } +} + +fn escape_attr(s: &str, buf: &mut String) { + for c in s.chars() { + match c { + '&' => buf.push_str("&"), + '"' => buf.push_str("""), + _ => buf.push(c), + } + } +} + +fn is_void_element(tag: &str) -> bool { + matches!( + tag, + "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link" | "meta" + | "param" | "source" | "track" | "wbr" + ) +} + +fn is_raw_text_element(tag: &str) -> bool { + matches!(tag, "script" | "style" | "textarea" | "title") +} + +#[cfg(test)] +mod tests { + use crate::tree_sink::parse_html; + + #[test] + fn test_outer_html() { + let tree = parse_html(r#"

Hello

"#); + let div = tree.get_element_by_id("test").unwrap(); + let html = tree.outer_html(div); + assert!(html.contains(r#"
"#)); + assert!(html.contains("

Hello

")); + assert!(html.contains("
")); + } + + #[test] + fn test_inner_html() { + let tree = parse_html(r#"

Hello

World

"#); + let div = tree.get_element_by_id("test").unwrap(); + let html = tree.inner_html(div); + assert!(html.contains("

Hello

")); + assert!(html.contains("

World

")); + assert!(!html.contains("Click"#); + let a = tree.query_selector("a").unwrap().unwrap(); + let html = tree.outer_html(a); + assert!(html.contains("href=\"https://example.com\"")); + assert!(html.contains("class=\"link\"")); + } + + #[test] + fn test_serialize_special_chars() { + let tree = parse_html("

Hello & World <3

"); + let p = tree.query_selector("p").unwrap().unwrap(); + let html = tree.outer_html(p); + assert!(html.contains("&")); + assert!(html.contains("<")); + } + + #[test] + fn test_void_elements() { + let tree = parse_html(r#"
"#); + let img = tree.query_selector("img").unwrap().unwrap(); + let html = tree.outer_html(img); + assert!(html.contains("")); + } +} diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs new file mode 100644 index 0000000..2dd0f56 --- /dev/null +++ b/crates/obscura-dom/src/tree.rs @@ -0,0 +1,697 @@ +use html5ever::{LocalName, Namespace, QualName}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct NodeId(pub(crate) u32); + +impl NodeId { + pub fn new(val: u32) -> Self { + NodeId(val) + } + + pub fn index(self) -> usize { + self.0 as usize + } + + pub fn raw(self) -> u32 { + self.0 + } +} + +impl fmt::Display for NodeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "NodeId({})", self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attribute { + pub name: QualName, + pub value: String, +} + +#[derive(Clone, Debug)] +pub enum NodeData { + Document, + Doctype { + name: String, + public_id: String, + system_id: String, + }, + Element { + name: QualName, + attrs: Vec, + template_contents: Option, + mathml_annotation_xml_integration_point: bool, + }, + Text { + contents: String, + }, + Comment { + contents: String, + }, + ProcessingInstruction { + target: String, + data: String, + }, +} + +#[derive(Clone, Debug)] +pub struct Node { + pub id: NodeId, + pub parent: Option, + pub first_child: Option, + pub last_child: Option, + pub prev_sibling: Option, + pub next_sibling: Option, + pub data: NodeData, +} + +impl Node { + pub fn is_document(&self) -> bool { + matches!(self.data, NodeData::Document) + } + + pub fn is_element(&self) -> bool { + matches!(self.data, NodeData::Element { .. }) + } + + pub fn is_text(&self) -> bool { + matches!(self.data, NodeData::Text { .. }) + } + + pub fn as_element(&self) -> Option<&QualName> { + match &self.data { + NodeData::Element { name, .. } => Some(name), + _ => None, + } + } + + pub fn attrs(&self) -> Option<&[Attribute]> { + match &self.data { + NodeData::Element { attrs, .. } => Some(attrs), + _ => None, + } + } + + pub fn attrs_mut(&mut self) -> Option<&mut Vec> { + match &mut self.data { + NodeData::Element { attrs, .. } => Some(attrs), + _ => None, + } + } + + pub fn get_attribute(&self, name: &str) -> Option<&str> { + self.attrs()?.iter().find_map(|a| { + if a.name.local.as_ref() == name { + Some(a.value.as_str()) + } else { + None + } + }) + } + + pub fn set_attribute(&mut self, name: &str, value: String) { + if let NodeData::Element { attrs, .. } = &mut self.data { + if let Some(attr) = attrs.iter_mut().find(|a| a.name.local.as_ref() == name) { + attr.value = value; + } else { + attrs.push(Attribute { + name: QualName::new(None, Namespace::default(), LocalName::from(name)), + value, + }); + } + } + } + + pub fn text_content_of_text_node(&self) -> Option<&str> { + match &self.data { + NodeData::Text { contents } => Some(contents), + _ => None, + } + } +} + +pub struct DomTree { + inner: RefCell, +} + +pub(crate) struct DomTreeInner { + pub(crate) nodes: Vec>, + pub(crate) free_list: Vec, + pub(crate) document: NodeId, + pub(crate) id_index: HashMap, +} + +impl DomTree { + pub fn new() -> Self { + let doc_node = Node { + id: NodeId(0), + parent: None, + first_child: None, + last_child: None, + prev_sibling: None, + next_sibling: None, + data: NodeData::Document, + }; + DomTree { + inner: RefCell::new(DomTreeInner { + nodes: vec![Some(doc_node)], + free_list: Vec::new(), + document: NodeId(0), + id_index: HashMap::new(), + }), + } + } + + pub fn document(&self) -> NodeId { + self.inner.borrow().document + } + + pub(crate) fn borrow_inner(&self) -> std::cell::Ref<'_, DomTreeInner> { + self.inner.borrow() + } + + pub fn new_node(&self, data: NodeData) -> NodeId { + let mut inner = self.inner.borrow_mut(); + let id = if let Some(slot) = inner.free_list.pop() { + NodeId(slot) + } else { + let idx = inner.nodes.len() as u32; + inner.nodes.push(None); + NodeId(idx) + }; + + if let NodeData::Element { ref attrs, .. } = data { + if let Some(id_attr) = attrs.iter().find(|a| a.name.local.as_ref() == "id") { + inner.id_index.insert(id_attr.value.clone(), id); + } + } + + inner.nodes[id.index()] = Some(Node { + id, + parent: None, + first_child: None, + last_child: None, + prev_sibling: None, + next_sibling: None, + data, + }); + id + } + + pub fn get_node(&self, id: NodeId) -> Option { + self.inner.borrow().nodes.get(id.index())?.clone() + } + + pub fn with_node(&self, id: NodeId, f: F) -> Option + where + F: FnOnce(&Node) -> R, + { + let inner = self.inner.borrow(); + inner.nodes.get(id.index())?.as_ref().map(f) + } + + pub fn with_node_mut(&self, id: NodeId, f: F) -> Option + where + F: FnOnce(&mut Node) -> R, + { + let mut inner = self.inner.borrow_mut(); + inner.nodes.get_mut(id.index())?.as_mut().map(f) + } + + pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) { + self.detach(child_id); + + let mut inner = self.inner.borrow_mut(); + + let old_last = inner.nodes.get(parent_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.last_child); + + if let Some(Some(child)) = inner.nodes.get_mut(child_id.index()) { + child.parent = Some(parent_id); + child.prev_sibling = old_last; + child.next_sibling = None; + } + + if let Some(old_last_id) = old_last { + if let Some(Some(old_last_node)) = inner.nodes.get_mut(old_last_id.index()) { + old_last_node.next_sibling = Some(child_id); + } + } + + if let Some(Some(parent)) = inner.nodes.get_mut(parent_id.index()) { + if parent.first_child.is_none() { + parent.first_child = Some(child_id); + } + parent.last_child = Some(child_id); + } + } + + pub fn insert_before(&self, existing_id: NodeId, new_sibling_id: NodeId) { + let (parent_id, prev_id) = { + let inner = self.inner.borrow(); + let node = match inner.nodes.get(existing_id.index()).and_then(|n| n.as_ref()) { + Some(n) => n, + None => return, + }; + match node.parent { + Some(p) => (p, node.prev_sibling), + None => return, + } + }; + + self.detach(new_sibling_id); + + let mut inner = self.inner.borrow_mut(); + + if let Some(Some(node)) = inner.nodes.get_mut(new_sibling_id.index()) { + node.parent = Some(parent_id); + node.prev_sibling = prev_id; + node.next_sibling = Some(existing_id); + } + + if let Some(Some(node)) = inner.nodes.get_mut(existing_id.index()) { + node.prev_sibling = Some(new_sibling_id); + } + + if let Some(prev) = prev_id { + if let Some(Some(node)) = inner.nodes.get_mut(prev.index()) { + node.next_sibling = Some(new_sibling_id); + } + } else if let Some(Some(parent)) = inner.nodes.get_mut(parent_id.index()) { + parent.first_child = Some(new_sibling_id); + } + } + + pub fn detach(&self, node_id: NodeId) { + let mut inner = self.inner.borrow_mut(); + + let (parent_id, prev_id, next_id) = match inner.nodes.get(node_id.index()).and_then(|n| n.as_ref()) { + Some(node) => (node.parent, node.prev_sibling, node.next_sibling), + None => return, + }; + + if let Some(prev) = prev_id { + if let Some(Some(node)) = inner.nodes.get_mut(prev.index()) { + node.next_sibling = next_id; + } + } else if let Some(parent_id) = parent_id { + if let Some(Some(parent)) = inner.nodes.get_mut(parent_id.index()) { + parent.first_child = next_id; + } + } + + if let Some(next) = next_id { + if let Some(Some(node)) = inner.nodes.get_mut(next.index()) { + node.prev_sibling = prev_id; + } + } else if let Some(parent_id) = parent_id { + if let Some(Some(parent)) = inner.nodes.get_mut(parent_id.index()) { + parent.last_child = prev_id; + } + } + + if let Some(Some(node)) = inner.nodes.get_mut(node_id.index()) { + node.parent = None; + node.prev_sibling = None; + node.next_sibling = None; + } + } + + pub fn remove(&self, node_id: NodeId) { + self.detach(node_id); + let descendants = self.descendants(node_id); + let mut inner = self.inner.borrow_mut(); + + let mut ids_to_remove = Vec::new(); + for &desc_id in &descendants { + if let Some(Some(node)) = inner.nodes.get(desc_id.index()) { + if let Some(id_val) = node.get_attribute("id") { + ids_to_remove.push(id_val.to_string()); + } + } + } + if let Some(Some(node)) = inner.nodes.get(node_id.index()) { + if let Some(id_val) = node.get_attribute("id") { + ids_to_remove.push(id_val.to_string()); + } + } + + for id_str in ids_to_remove { + inner.id_index.remove(&id_str); + } + + for desc_id in descendants { + inner.nodes[desc_id.index()] = None; + inner.free_list.push(desc_id.0); + } + inner.nodes[node_id.index()] = None; + inner.free_list.push(node_id.0); + } + + pub fn children(&self, node_id: NodeId) -> Vec { + let inner = self.inner.borrow(); + let mut result = Vec::new(); + let mut current = inner.nodes.get(node_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.first_child); + while let Some(child_id) = current { + result.push(child_id); + current = inner.nodes.get(child_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.next_sibling); + } + result + } + + pub fn descendants(&self, node_id: NodeId) -> Vec { + let inner = self.inner.borrow(); + let mut result = Vec::new(); + let mut stack = Vec::new(); + + let mut first = inner.nodes.get(node_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.first_child); + let mut children_to_push = Vec::new(); + while let Some(child_id) = first { + children_to_push.push(child_id); + first = inner.nodes.get(child_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.next_sibling); + } + for child_id in children_to_push.into_iter().rev() { + stack.push(child_id); + } + + while let Some(current) = stack.pop() { + result.push(current); + + let mut child = inner.nodes.get(current.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.first_child); + let mut children_to_push = Vec::new(); + while let Some(child_id) = child { + children_to_push.push(child_id); + child = inner.nodes.get(child_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.next_sibling); + } + for child_id in children_to_push.into_iter().rev() { + stack.push(child_id); + } + } + + result + } + + pub fn ancestors(&self, node_id: NodeId) -> Vec { + let inner = self.inner.borrow(); + let mut result = Vec::new(); + let mut current = inner.nodes.get(node_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.parent); + while let Some(parent_id) = current { + result.push(parent_id); + current = inner.nodes.get(parent_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.parent); + } + result + } + + pub fn get_element_by_id(&self, id: &str) -> Option { + self.inner.borrow().id_index.get(id).copied() + } + + pub fn text_content(&self, node_id: NodeId) -> String { + let inner = self.inner.borrow(); + let mut result = String::new(); + collect_text_inner(&inner, node_id, &mut result); + result + } + + pub fn append_text(&self, parent_id: NodeId, text: &str) { + let last_child_is_text = { + let inner = self.inner.borrow(); + inner.nodes.get(parent_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.last_child) + .and_then(|lc| inner.nodes.get(lc.index())) + .and_then(|n| n.as_ref()) + .map(|n| n.is_text()) + .unwrap_or(false) + }; + + if last_child_is_text { + let last_child_id = { + let inner = self.inner.borrow(); + inner.nodes.get(parent_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.last_child) + .unwrap() + }; + let mut inner = self.inner.borrow_mut(); + if let Some(Some(node)) = inner.nodes.get_mut(last_child_id.index()) { + if let NodeData::Text { contents } = &mut node.data { + contents.push_str(text); + return; + } + } + } + + let text_id = self.new_node(NodeData::Text { + contents: text.to_string(), + }); + self.append_child(parent_id, text_id); + } + + pub fn find_body_or_root(&self) -> NodeId { + let doc = self.document(); + for child in self.children(doc) { + if let Some(n) = self.get_node(child) { + if n.as_element().map(|name| name.local.as_ref() == "html").unwrap_or(false) { + for html_child in self.children(child) { + if let Some(hc) = self.get_node(html_child) { + if hc.as_element().map(|name| name.local.as_ref() == "body").unwrap_or(false) { + return html_child; + } + } + } + return child; + } + } + } + doc + } + + pub fn import_children_from(&self, parent_id: NodeId, source: &DomTree, source_node: NodeId) { + let source_children = source.children(source_node); + for source_child_id in source_children { + self.import_node_from(parent_id, source, source_child_id); + } + } + + fn import_node_from(&self, parent_id: NodeId, source: &DomTree, source_node_id: NodeId) { + let node_data = { + let source_inner = source.inner.borrow(); + match source_inner.nodes.get(source_node_id.index()) { + Some(Some(node)) => node.data.clone(), + _ => return, + } + }; + + let new_id = self.new_node(node_data); + self.append_child(parent_id, new_id); + + let children = source.children(source_node_id); + for child_id in children { + self.import_node_from(new_id, source, child_id); + } + } + + pub fn len(&self) -> usize { + self.inner.borrow().nodes.iter().filter(|n| n.is_some()).count() + } + + pub fn is_empty(&self) -> bool { + self.len() <= 1 + } + + pub fn update_id_index(&self, node_id: NodeId, old_id: Option<&str>, new_id: Option<&str>) { + let mut inner = self.inner.borrow_mut(); + if let Some(old) = old_id { + inner.id_index.remove(old); + } + if let Some(new) = new_id { + inner.id_index.insert(new.to_string(), node_id); + } + } +} + +fn collect_text_inner(inner: &DomTreeInner, node_id: NodeId, buf: &mut String) { + if let Some(Some(node)) = inner.nodes.get(node_id.index()) { + match &node.data { + NodeData::Text { contents } => buf.push_str(contents), + _ => { + let mut child = node.first_child; + while let Some(child_id) = child { + collect_text_inner(inner, child_id, buf); + child = inner.nodes.get(child_id.index()) + .and_then(|n| n.as_ref()) + .and_then(|n| n.next_sibling); + } + } + } + } +} + +impl Default for DomTree { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_tree_has_document() { + let tree = DomTree::new(); + assert_eq!(tree.len(), 1); + let node = tree.get_node(tree.document()).unwrap(); + assert!(node.is_document()); + } + + #[test] + fn test_append_child() { + let tree = DomTree::new(); + let child = tree.new_node(NodeData::Text { + contents: "hello".into(), + }); + let doc = tree.document(); + tree.append_child(doc, child); + + assert_eq!(tree.len(), 2); + let doc_node = tree.get_node(doc).unwrap(); + assert_eq!(doc_node.first_child, Some(child)); + assert_eq!(doc_node.last_child, Some(child)); + + let child_node = tree.get_node(child).unwrap(); + assert_eq!(child_node.parent, Some(doc)); + } + + #[test] + fn test_multiple_children() { + let tree = DomTree::new(); + let doc = tree.document(); + let c1 = tree.new_node(NodeData::Text { contents: "a".into() }); + let c2 = tree.new_node(NodeData::Text { contents: "b".into() }); + let c3 = tree.new_node(NodeData::Text { contents: "c".into() }); + tree.append_child(doc, c1); + tree.append_child(doc, c2); + tree.append_child(doc, c3); + + assert_eq!(tree.children(doc), vec![c1, c2, c3]); + } + + #[test] + fn test_detach() { + let tree = DomTree::new(); + let doc = tree.document(); + let c1 = tree.new_node(NodeData::Text { contents: "a".into() }); + let c2 = tree.new_node(NodeData::Text { contents: "b".into() }); + tree.append_child(doc, c1); + tree.append_child(doc, c2); + + tree.detach(c1); + assert_eq!(tree.children(doc), vec![c2]); + } + + #[test] + fn test_insert_before() { + let tree = DomTree::new(); + let doc = tree.document(); + let c1 = tree.new_node(NodeData::Text { contents: "a".into() }); + let c2 = tree.new_node(NodeData::Text { contents: "b".into() }); + let c3 = tree.new_node(NodeData::Text { contents: "c".into() }); + tree.append_child(doc, c1); + tree.append_child(doc, c3); + tree.insert_before(c3, c2); + + assert_eq!(tree.children(doc), vec![c1, c2, c3]); + } + + #[test] + fn test_text_content() { + let tree = DomTree::new(); + let doc = tree.document(); + let div = tree.new_node(NodeData::Element { + name: QualName::new(None, ns!(html), local_name!("div")), + attrs: vec![], + template_contents: None, + mathml_annotation_xml_integration_point: false, + }); + tree.append_child(doc, div); + + let t1 = tree.new_node(NodeData::Text { contents: "Hello ".into() }); + let t2 = tree.new_node(NodeData::Text { contents: "World".into() }); + tree.append_child(div, t1); + tree.append_child(div, t2); + + assert_eq!(tree.text_content(div), "Hello World"); + } + + #[test] + fn test_get_element_by_id() { + let tree = DomTree::new(); + let doc = tree.document(); + let div = tree.new_node(NodeData::Element { + name: QualName::new(None, ns!(html), local_name!("div")), + attrs: vec![Attribute { + name: QualName::new(None, Namespace::default(), LocalName::from("id")), + value: "main".into(), + }], + template_contents: None, + mathml_annotation_xml_integration_point: false, + }); + tree.append_child(doc, div); + + assert_eq!(tree.get_element_by_id("main"), Some(div)); + assert_eq!(tree.get_element_by_id("nonexistent"), None); + } + + #[test] + fn test_append_text_merges() { + let tree = DomTree::new(); + let doc = tree.document(); + tree.append_text(doc, "Hello "); + tree.append_text(doc, "World"); + + assert_eq!(tree.children(doc).len(), 1); + assert_eq!(tree.text_content(doc), "Hello World"); + } + + #[test] + fn test_remove_subtree() { + let tree = DomTree::new(); + let doc = tree.document(); + let div = tree.new_node(NodeData::Element { + name: QualName::new(None, ns!(html), local_name!("div")), + attrs: vec![], + template_contents: None, + mathml_annotation_xml_integration_point: false, + }); + tree.append_child(doc, div); + let text = tree.new_node(NodeData::Text { contents: "hi".into() }); + tree.append_child(div, text); + + assert_eq!(tree.len(), 3); + tree.remove(div); + assert_eq!(tree.len(), 1); + } +} diff --git a/crates/obscura-dom/src/tree_sink.rs b/crates/obscura-dom/src/tree_sink.rs new file mode 100644 index 0000000..674732f --- /dev/null +++ b/crates/obscura-dom/src/tree_sink.rs @@ -0,0 +1,323 @@ +use std::borrow::Cow; +use std::cell::Ref; +use std::fmt; + +use html5ever::tendril::StrTendril; +use html5ever::tree_builder::{ElemName, ElementFlags, NodeOrText, QuirksMode, TreeSink}; +use html5ever::{Attribute as HtmlAttribute, LocalName, Namespace, QualName}; + +use crate::tree::{Attribute, DomTree, NodeData, NodeId}; + +pub struct ObscuraElemName<'a> { + _ref: Ref<'a, ()>, + name: *const QualName, +} + +impl<'a> fmt::Debug for ObscuraElemName<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = unsafe { &*self.name }; + write!(f, "{:?}", name) + } +} + +impl<'a> ElemName for ObscuraElemName<'a> { + fn ns(&self) -> &Namespace { + unsafe { &(*self.name).ns } + } + + fn local_name(&self) -> &LocalName { + unsafe { &(*self.name).local } + } +} + +impl TreeSink for DomTree { + type Handle = NodeId; + type Output = Self; + type ElemName<'a> = ObscuraElemName<'a>; + + fn finish(self) -> Self::Output { + self + } + + fn parse_error(&self, _msg: Cow<'static, str>) {} + + fn get_document(&self) -> NodeId { + self.document() + } + + fn elem_name<'a>(&'a self, target: &'a NodeId) -> ObscuraElemName<'a> { + let borrow = self.borrow_inner(); + let node = borrow.nodes.get(target.index()) + .and_then(|n| n.as_ref()) + .expect("elem_name called on invalid node"); + let name_ptr: *const QualName = match &node.data { + NodeData::Element { name, .. } => name as *const QualName, + _ => panic!("elem_name called on non-element"), + }; + let ref_guard = Ref::map(borrow, |_| &()); + ObscuraElemName { + _ref: ref_guard, + name: name_ptr, + } + } + + fn create_element( + &self, + name: QualName, + attrs: Vec, + flags: ElementFlags, + ) -> NodeId { + let converted_attrs: Vec = attrs + .into_iter() + .map(|a| Attribute { + name: a.name, + value: a.value.to_string(), + }) + .collect(); + + let id = self.new_node(NodeData::Element { + name: name.clone(), + attrs: converted_attrs, + template_contents: None, + mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point, + }); + + if flags.template { + let template_doc = self.new_node(NodeData::Document); + self.with_node_mut(id, |node| { + if let NodeData::Element { template_contents, .. } = &mut node.data { + *template_contents = Some(template_doc); + } + }); + } + + id + } + + fn create_comment(&self, text: StrTendril) -> NodeId { + self.new_node(NodeData::Comment { + contents: text.to_string(), + }) + } + + fn create_pi(&self, target: StrTendril, data: StrTendril) -> NodeId { + self.new_node(NodeData::ProcessingInstruction { + target: target.to_string(), + data: data.to_string(), + }) + } + + fn append(&self, parent: &NodeId, child: NodeOrText) { + match child { + NodeOrText::AppendNode(node_id) => { + self.append_child(*parent, node_id); + } + NodeOrText::AppendText(text) => { + self.append_text(*parent, &text); + } + } + } + + fn append_based_on_parent_node( + &self, + element: &NodeId, + prev_element: &NodeId, + child: NodeOrText, + ) { + let has_parent = self.with_node(*element, |n| n.parent.is_some()).unwrap_or(false); + if has_parent { + self.append_before_sibling(element, child); + } else { + self.append(prev_element, child); + } + } + + fn append_doctype_to_document( + &self, + name: StrTendril, + public_id: StrTendril, + system_id: StrTendril, + ) { + let doctype = self.new_node(NodeData::Doctype { + name: name.to_string(), + public_id: public_id.to_string(), + system_id: system_id.to_string(), + }); + let doc = self.document(); + self.append_child(doc, doctype); + } + + fn add_attrs_if_missing(&self, target: &NodeId, attrs: Vec) { + self.with_node_mut(*target, |node| { + if let NodeData::Element { attrs: existing, .. } = &mut node.data { + for attr in attrs { + let dominated = existing.iter().any(|a| a.name == attr.name); + if !dominated { + existing.push(Attribute { + name: attr.name, + value: attr.value.to_string(), + }); + } + } + } + }); + } + + fn remove_from_parent(&self, target: &NodeId) { + self.detach(*target); + } + + fn reparent_children(&self, node: &NodeId, new_parent: &NodeId) { + let children = self.children(*node); + for child_id in children { + self.append_child(*new_parent, child_id); + } + } + + fn append_before_sibling(&self, sibling: &NodeId, child: NodeOrText) { + match child { + NodeOrText::AppendNode(node_id) => { + self.insert_before(*sibling, node_id); + } + NodeOrText::AppendText(text) => { + let prev_text_id = { + let node = self.get_node(*sibling); + node.and_then(|n| n.prev_sibling).and_then(|prev_id| { + let prev = self.get_node(prev_id); + prev.and_then(|p| if p.is_text() { Some(prev_id) } else { None }) + }) + }; + + if let Some(prev_text_id) = prev_text_id { + self.with_node_mut(prev_text_id, |node| { + if let NodeData::Text { contents } = &mut node.data { + contents.push_str(&text); + } + }); + return; + } + + let text_id = self.new_node(NodeData::Text { + contents: text.to_string(), + }); + self.insert_before(*sibling, text_id); + } + } + } + + fn get_template_contents(&self, target: &NodeId) -> NodeId { + self.with_node(*target, |n| match &n.data { + NodeData::Element { template_contents, .. } => *template_contents, + _ => None, + }) + .flatten() + .expect("get_template_contents called on non-template element") + } + + fn same_node(&self, x: &NodeId, y: &NodeId) -> bool { + x == y + } + + fn set_quirks_mode(&self, _mode: QuirksMode) { + } + + fn is_mathml_annotation_xml_integration_point(&self, target: &NodeId) -> bool { + self.with_node(*target, |n| match &n.data { + NodeData::Element { mathml_annotation_xml_integration_point, .. } => { + *mathml_annotation_xml_integration_point + } + _ => false, + }) + .unwrap_or(false) + } +} + +pub fn parse_html(html: &str) -> DomTree { + use html5ever::tendril::TendrilSink; + use html5ever::{parse_document, ParseOpts}; + + let tree = DomTree::new(); + parse_document(tree, ParseOpts::default()) + .from_utf8() + .one(html.as_bytes()) +} + +pub fn parse_fragment(html: &str) -> DomTree { + use html5ever::tendril::TendrilSink; + use html5ever::{parse_fragment, ParseOpts, QualName}; + + let context_name = QualName::new(None, ns!(html), local_name!("body")); + let tree = DomTree::new(); + parse_fragment(tree, ParseOpts::default(), context_name, vec![]) + .from_utf8() + .one(html.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_html() { + let tree = parse_html("

Hello

"); + assert!(tree.len() > 3); + let text = tree.text_content(tree.document()); + assert!(text.contains("Hello")); + } + + #[test] + fn test_parse_with_attributes() { + let tree = parse_html(r#"
Text
"#); + let main = tree.get_element_by_id("main"); + assert!(main.is_some()); + let node = tree.get_node(main.unwrap()).unwrap(); + assert_eq!(node.get_attribute("class"), Some("container")); + } + + #[test] + fn test_parse_nested_structure() { + let tree = parse_html( + r#" +
+

Hello World

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ "#, + ); + + let outer = tree.get_element_by_id("outer").unwrap(); + let text = tree.text_content(outer); + assert!(text.contains("Hello")); + assert!(text.contains("World")); + assert!(text.contains("Item 1")); + assert!(text.contains("Item 2")); + } + + #[test] + fn test_parse_malformed_html() { + let tree = parse_html("

Unclosed paragraph

Another

Nested wrong
"); + assert!(tree.len() > 3); + let text = tree.text_content(tree.document()); + assert!(text.contains("Unclosed paragraph")); + assert!(text.contains("Another")); + } + + #[test] + fn test_parse_doctype() { + let tree = parse_html("Hello"); + let first_child = tree.children(tree.document())[0]; + let node = tree.get_node(first_child).unwrap(); + assert!(matches!(node.data, NodeData::Doctype { .. })); + } + + #[test] + fn test_parse_fragment() { + let tree = parse_fragment("

Hello

World

"); + let text = tree.text_content(tree.document()); + assert!(text.contains("Hello")); + assert!(text.contains("World")); + } +} diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml new file mode 100644 index 0000000..5e6eaa2 --- /dev/null +++ b/crates/obscura-js/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "obscura-js" +version.workspace = true +edition.workspace = true + +[build-dependencies] +deno_core = "0.350" + +[dependencies] +obscura-dom = { workspace = true } +obscura-net = { workspace = true } +deno_core = "0.350" +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +reqwest = { workspace = true } +html5ever = { workspace = true } +deno_error = "0.6" diff --git a/crates/obscura-js/build.rs b/crates/obscura-js/build.rs new file mode 100644 index 0000000..04e22ea --- /dev/null +++ b/crates/obscura-js/build.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=js/bootstrap.js"); + println!("cargo:rerun-if-changed=build.rs"); + + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let snapshot_path = out_dir.join("OBSCURA_SNAPSHOT.bin"); + + let bootstrap_js = include_str!("js/bootstrap.js"); + + let output = deno_core::snapshot::create_snapshot( + deno_core::snapshot::CreateSnapshotOptions { + cargo_manifest_dir: env!("CARGO_MANIFEST_DIR"), + startup_snapshot: None, + skip_op_registration: true, + extensions: vec![], + extension_transpiler: None, + with_runtime_cb: Some(Box::new(move |runtime| { + runtime + .execute_script("", bootstrap_js.to_string()) + .expect("bootstrap.js should not fail during snapshot creation"); + })), + }, + None, + ) + .expect("Failed to create V8 snapshot"); + + std::fs::write(&snapshot_path, &*output.output).expect("Failed to write snapshot"); + println!( + "cargo:rustc-env=OBSCURA_SNAPSHOT_PATH={}", + snapshot_path.display() + ); + + for file in &output.files_loaded_during_snapshot { + println!("cargo:rerun-if-changed={}", file.display()); + } +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js new file mode 100644 index 0000000..cc44f63 --- /dev/null +++ b/crates/obscura-js/js/bootstrap.js @@ -0,0 +1,2925 @@ +"use strict"; + +globalThis.__obscura_errors = []; + +globalThis.addEventListener = globalThis.addEventListener || function(){}; +globalThis.onunhandledrejection = function(e) { if (e?.preventDefault) e.preventDefault(); }; + +globalThis.onerror = function(msg, src, line, col, error) { + globalThis.__obscura_errors.push({msg: String(msg), src: String(src||""), line, error: String(error||"")}); +}; +globalThis.__windowListeners = {}; +globalThis.addEventListener = function(type, fn) { + if (!globalThis.__windowListeners[type]) globalThis.__windowListeners[type] = []; + globalThis.__windowListeners[type].push(fn); +}; +globalThis.removeEventListener = function(type, fn) { + if (globalThis.__windowListeners[type]) { + globalThis.__windowListeners[type] = globalThis.__windowListeners[type].filter(h => h !== fn); + } +}; +globalThis.dispatchEvent = function(event) { + if (!event) return true; + const handlers = globalThis.__windowListeners[event.type] || []; + for (const h of handlers) { try { h.call(globalThis, event); } catch(e) { console.error(e); } } + return !event.defaultPrevented; +}; + +const _dom = (cmd, a1, a2) => Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); + +const _nativeFns = new Set(); +const _origToString = Function.prototype.toString; +Function.prototype.toString = function() { + if (_nativeFns.has(this)) { + return `function ${this.name || ''}() { [native code] }`; + } + return _origToString.call(this); +}; +function _markNative(fn) { if (typeof fn === 'function') _nativeFns.add(fn); return fn; } +_nativeFns.add(Function.prototype.toString); + +[Error, TypeError, ReferenceError, SyntaxError, RangeError, URIError, EvalError].forEach(E => { + try { + Object.defineProperty(E.prototype, 'name', { + value: E.name, writable: true, enumerable: false, configurable: false, + }); + } catch(e) {} +}); + +const _stackCache = new WeakMap(); +const _origStackDesc = Object.getOwnPropertyDescriptor(Error.prototype, 'stack'); +if (_origStackDesc && _origStackDesc.get) { + Object.defineProperty(Error.prototype, 'stack', { + configurable: false, enumerable: false, + get: function() { + if (!_stackCache.has(this)) _stackCache.set(this, _origStackDesc.get.call(this)); + return _stackCache.get(this); + } + }); +} + +let _fpSeed = 0; +function _fpRand(salt) { + let h = (_fpSeed ^ (salt || 0)) | 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h = Math.imul(h ^ (h >>> 13), 0x45d9f3b); + return ((h ^ (h >>> 16)) >>> 0) / 0xFFFFFFFF; +} +function _fpNoise(x, y, channel) { + return (_fpRand(x * 7919 + y * 6271 + channel * 8923) - 0.5) * 4; +} + +var _fpCache = null; +function _getFp() { + if (_fpCache) return _fpCache; + const gpuPool = [ + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (AMD, AMD Radeon RX 580 Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (AMD, AMD Radeon RX 6700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 Ti Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (Intel, Intel(R) UHD Graphics 770 Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (AMD, AMD Radeon RX 5700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)', + ]; + const gpuVendorPool = [ + 'Google Inc. (NVIDIA)','Google Inc. (NVIDIA)','Google Inc. (NVIDIA)', + 'Google Inc. (Intel)','Google Inc. (Intel)', + 'Google Inc. (AMD)','Google Inc. (AMD)', + 'Google Inc. (NVIDIA)','Google Inc. (NVIDIA)', + 'Google Inc. (Intel)','Google Inc. (AMD)','Google Inc. (NVIDIA)', + ]; + const idx = Math.floor(_fpRand(42) * gpuPool.length); + const screenPool = [[1920,1080],[2560,1440],[1366,768],[1536,864],[1440,900],[1680,1050],[1280,720],[3840,2160]]; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let cfp = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg'; + for (let i = 0; i < 40; i++) cfp += chars[Math.floor(_fpRand(500 + i) * 64)]; + cfp += '=='; + _fpCache = { + gpu: gpuPool[idx], gpuVendor: gpuVendorPool[idx], + audioBaseLatency: 0.002 + _fpRand(100) * 0.008, + audioSampleRate: [44100, 48000][Math.floor(_fpRand(101) * 2)], + compThreshold: -24 + (_fpRand(102) - 0.5) * 4, + compKnee: 30 + (_fpRand(103) - 0.5) * 4, + compRatio: 12 + (_fpRand(104) - 0.5) * 4, + batteryLevel: 0.5 + _fpRand(200) * 0.5, + batteryCharging: _fpRand(201) > 0.3, + screen: screenPool[Math.floor(_fpRand(300) * screenPool.length)], + canvasFingerprint: cfp, + }; + return _fpCache; +} +function _fp(key) { return _getFp()[key]; } +globalThis._eventRegistry = globalThis._eventRegistry || {}; +globalThis._formValues = globalThis._formValues || {}; +globalThis._formChecked = globalThis._formChecked || {}; +const _eventRegistry = globalThis._eventRegistry; +const _formValues = globalThis._formValues; +const _formChecked = globalThis._formChecked; +const _domParse = (cmd, a1, a2) => { try { return JSON.parse(_dom(cmd, a1, a2)); } catch { return null; } }; +const _consoleFn = (level, args) => { + try { Deno.core.ops.op_console_msg(level, args.map(a => { + if (a === null) return "null"; + if (a === undefined) return "undefined"; + if (a instanceof Error) return a.stack || a.message || String(a); + if (typeof a === "object") { + try { + const s = JSON.stringify(a); + return s === "{}" && a.message ? a.message : s; + } catch { return String(a); } + } + return String(a); + }).join(" ")); } catch {} +}; + +globalThis.console = { + log: (...a) => _consoleFn("log", a), warn: (...a) => _consoleFn("warn", a), + error: (...a) => _consoleFn("error", a), info: (...a) => _consoleFn("log", a), + debug: () => {}, dir: () => {}, trace: () => {}, table: () => {}, group: () => {}, + groupEnd: () => {}, groupCollapsed: () => {}, time: () => {}, timeEnd: () => {}, + timeLog: () => {}, count: () => {}, countReset: () => {}, clear: () => {}, + assert: (c, ...a) => { if (!c) _consoleFn("error", ["Assertion failed:", ...a]); }, +}; + +let _tid = 0; +const _pendingTimers = new Map(); +const _clearedTimers = new Set(); + +globalThis.setTimeout = (fn, delay = 0, ...args) => { + if (typeof fn !== "function") return ++_tid; + const id = ++_tid; + _pendingTimers.set(id, { fn, args, delay }); + Promise.resolve().then(() => { + if (!_clearedTimers.has(id) && _pendingTimers.has(id)) { + _pendingTimers.delete(id); + try { fn(...args); } catch(e) { console.error("Timer error:", e); } + } + }); + return id; +}; + +globalThis.clearTimeout = (id) => { _clearedTimers.add(id); _pendingTimers.delete(id); }; +globalThis.setInterval = (fn, delay, ...args) => { + return setTimeout(fn, delay, ...args); +}; +globalThis.clearInterval = globalThis.clearTimeout; +globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +globalThis.cancelAnimationFrame = globalThis.clearTimeout; +globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); + +class MessageChannel { + constructor() { + this.port1 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; + this.port2 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; + this.port1.postMessage = (data) => { + Promise.resolve().then(() => { if (this.port2.onmessage) this.port2.onmessage({ data }); }); + }; + this.port2.postMessage = (data) => { + Promise.resolve().then(() => { if (this.port1.onmessage) this.port1.onmessage({ data }); }); + }; + } +} +globalThis.MessageChannel = MessageChannel; +globalThis.MessagePort = class MessagePort { constructor(){} postMessage(){} close(){} addEventListener(){} removeEventListener(){} }; + +class CSSStyleDeclaration { + constructor() { this._props = {}; } + setProperty(name, value) { this._props[name] = String(value); } + removeProperty(name) { const old = this._props[name]; delete this._props[name]; return old || ""; } + getPropertyValue(name) { return this._props[name] || ""; } + get cssText() { return Object.entries(this._props).map(([k,v]) => `${k}: ${v}`).join("; "); } + set cssText(v) { this._props = {}; if(v) v.split(";").forEach(p => { const [k,...rest]=p.split(":"); if(k&&rest.length) this._props[k.trim()]=rest.join(":").trim(); }); } + get length() { return Object.keys(this._props).length; } + item(i) { return Object.keys(this._props)[i] || ""; } +} + +const _styleProxy = (decl) => new Proxy(decl, { + get(t, p) { + if (typeof p === "symbol" || p in t) return t[p]; + if (typeof p === "string") return t._props[p] || ""; + return undefined; + }, + set(t, p, v) { + if (typeof p === "string") { t._props[p] = String(v); return true; } + t[p] = v; return true; + } +}); + +class Node { + static ELEMENT_NODE = 1; + static TEXT_NODE = 3; + static COMMENT_NODE = 8; + static DOCUMENT_NODE = 9; + static DOCUMENT_FRAGMENT_NODE = 11; + + constructor(nid) { this._nid = nid; } + get nodeType() { return +_dom("node_type", this._nid); } + get nodeName() { return _domParse("node_name", this._nid) || ""; } + get ownerDocument() { return globalThis.document; } + get textContent() { return _domParse("text_content", this._nid) ?? ""; } + set textContent(v) { + const children = _domParse("child_nodes", this._nid) || []; + for (const c of children) _dom("remove_child", c); + if (v != null && v !== "") { + const tn = +_dom("create_text_node", String(v)); + _dom("append_child", this._nid, tn); + } + } + get nodeValue() { + const t = this.nodeType; + if (t === 3 || t === 8) return _domParse("text_content", this._nid) ?? ""; + return null; + } + set nodeValue(v) { + const t = this.nodeType; + if (t === 3 || t === 8) _dom("set_text_content", this._nid, String(v ?? "")); + } + get parentNode() { return _wrap(+_dom("parent_node", this._nid)); } + get parentElement() { const p = this.parentNode; return p && p.nodeType === 1 ? p : null; } + get childNodes() { + const ids = _domParse("child_nodes", this._nid) || []; + const list = ids.map(_wrap).filter(Boolean); + list.item = (i) => list[i] || null; + return list; + } + get firstChild() { return _wrap(+_dom("first_child", this._nid)); } + get lastChild() { return _wrap(+_dom("last_child", this._nid)); } + get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); } + get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); } + appendChild(c) { + if (!c) return c; + _dom("append_child", this._nid, c._nid); + if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); + if (c instanceof Element && c.tagName === 'SCRIPT') { + const scriptType = c.getAttribute('type') || ''; + if (scriptType && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') { + return c; + } + const src = c.getAttribute('src'); + if (src) { + const fullUrl = src.startsWith('http') ? src : new URL(src, globalThis.location?.href || 'http://localhost/').href; + const pageOrigin = (function() { try { return new URL(globalThis.location?.href || "about:blank").origin; } catch(e) { return ""; } })(); + (async () => { + try { + const raw = await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors"); + const parsed = JSON.parse(raw); + if (parsed.body) { + try { (0, eval)(parsed.body); } catch(e) { console.error('Dynamic script error (' + fullUrl + '):', e.message); } + } + if (typeof c.onload === 'function') try { c.onload(new Event('load')); } catch(e) {} + try { c.dispatchEvent(new Event('load')); } catch(e) {} + } catch(e) { + console.error('Dynamic script fetch error:', e.message); + if (typeof c.onerror === 'function') try { c.onerror(e); } catch(ex) {} + } + })(); + } else { + const code = c.textContent; + if (code) { try { (0, eval)(code); } catch(e) { console.error('Dynamic inline script error:', e.message); } } + } + } + return c; + } + removeChild(c) { + if (!c) return c; + _dom("remove_child", c._nid); + if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [], [c._nid]); + return c; + } + replaceChild(newChild, oldChild) { + if (!oldChild || !newChild) return oldChild; + _dom("insert_before", this._nid, newChild._nid, oldChild._nid); + _dom("remove_child", oldChild._nid); + return oldChild; + } + insertBefore(n, ref) { + if (!n) return n; + if (!ref) { this.appendChild(n); return n; } + _dom("insert_before", this._nid, n._nid, ref._nid); + return n; + } + contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; } + hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; } + cloneNode(deep) { + const t = this.nodeType; + if (t === 1) { + if (deep) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = _domParse("outer_html", this._nid) || ""; + const clone = wrapper.firstChild; + return clone; + } + const el = document.createElement(this.nodeName.toLowerCase()); + const html = _domParse("outer_html", this._nid) || ""; + const attrMatch = html.match(/^<[a-zA-Z][^\s>]*([\s\S]*?)>/); + if (attrMatch && attrMatch[1]) { + const attrStr = attrMatch[1].trim(); + const re = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; + let m; + while ((m = re.exec(attrStr)) !== null) { + const name = m[1]; + const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || ""; + if (name !== this.nodeName.toLowerCase()) el.setAttribute(name, val); + } + } + return el; + } + if (t === 3) return document.createTextNode(this.textContent); + if (t === 8) return document.createComment(this.nodeValue || ""); + return null; + } + compareDocumentPosition(other) { + if (!other) return 0; + if (this._nid === other._nid) return 0; + if (this.contains(other)) return 16 | 4; // DOCUMENT_POSITION_CONTAINED_BY | FOLLOWING + if (other.contains && other.contains(this)) return 8 | 2; // DOCUMENT_POSITION_CONTAINS | PRECEDING + return 4; // DOCUMENT_POSITION_FOLLOWING + } + getRootNode() { return globalThis.document; } + normalize() {} // no-op + isEqualNode(other) { return other && this._nid === other._nid; } + isSameNode(other) { return other && this._nid === other._nid; } + addEventListener() {} removeEventListener() {} dispatchEvent() { return true; } +} + +class Element extends Node { + constructor(nid) { + super(nid); + this._style = _styleProxy(new CSSStyleDeclaration()); + } + get tagName() { return _domParse("tag_name", this._nid) || ""; } + get localName() { return (this.tagName || "").toLowerCase(); } + get id() { return this.getAttribute("id") || ""; } + set id(v) { this.setAttribute("id", v); } + get className() { return this.getAttribute("class") || ""; } + set className(v) { this.setAttribute("class", v); } + get namespaceURI() { + const tag = this.localName; + if (tag === "svg" || this._ns === "http://www.w3.org/2000/svg") return "http://www.w3.org/2000/svg"; + return "http://www.w3.org/1999/xhtml"; + } + get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } + set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } + get outerHTML() { return _domParse("outer_html", this._nid) ?? ""; } + get innerText() { return this.textContent; } + set innerText(v) { this.textContent = v; } + get children() { + const ids = _domParse("element_children", this._nid) || []; + return ids.map(_wrapEl).filter(Boolean); + } + get childElementCount() { return this.children.length; } + get firstElementChild() { return this.children[0] || null; } + get lastElementChild() { const ch = this.children; return ch[ch.length-1] || null; } + get nextElementSibling() { let s = this.nextSibling; while(s && s.nodeType !== 1) s = s.nextSibling; return s; } + get previousElementSibling() { let s = this.previousSibling; while(s && s.nodeType !== 1) s = s.previousSibling; return s; } + get classList() { + const el = this; + const obj = { + add: (...c) => { const s = new Set((el.className||"").split(/\s+/).filter(Boolean)); c.forEach(x=>s.add(x)); el.className=[...s].join(" "); }, + remove: (...c) => { const s = new Set((el.className||"").split(/\s+/).filter(Boolean)); c.forEach(x=>s.delete(x)); el.className=[...s].join(" "); }, + contains: c => (el.className||"").split(/\s+/).includes(c), + toggle: (c, force) => { const has = obj.contains(c); if(force===true||(!has&&force!==false)){obj.add(c);return true;} obj.remove(c); return false; }, + get length() { return (el.className||"").split(/\s+/).filter(Boolean).length; }, + item: i => (el.className||"").split(/\s+/).filter(Boolean)[i] || null, + forEach: (cb) => (el.className||"").split(/\s+/).filter(Boolean).forEach(cb), + toString: () => el.className || "", + }; + return obj; + } + get style() { return this._style; } + set style(v) { if (typeof v === "string") this._style.cssText = v; } + getAttribute(n) { return _domParse("get_attribute", this._nid, n); } + setAttribute(n, v) { + _dom("set_attribute", this._nid, n + "\0" + String(v)); + if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n); + } + setAttributeNS(ns, n, v) { this.setAttribute(n, v); } // Simplified NS handling + removeAttribute(n) { _dom("remove_attribute", this._nid, n); } + removeAttributeNS(ns, n) { this.removeAttribute(n); } + hasAttribute(n) { return this.getAttribute(n) !== null; } + hasAttributes() { return true; } // Simplified + getAttributeNS(ns, n) { return this.getAttribute(n); } + querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } + querySelectorAll(s) { + const ids = _domParse("query_selector_all", s) || []; + const list = ids.map(_wrapEl).filter(Boolean); + list.item = (i) => list[i] || null; + list.forEach = Array.prototype.forEach.bind(list); + return list; + } + getElementsByTagName(t) { return this.querySelectorAll(t); } + getElementsByClassName(c) { return this.querySelectorAll("." + c); } + matches(s) { + const parent = this.parentNode; + if (!parent || !parent.querySelectorAll) return false; + const matches = parent.querySelectorAll(s); + for (let i = 0; i < matches.length; i++) { + if (matches[i]._nid === this._nid) return true; + } + return false; + } + closest(s) { + let el = this; + while (el) { + if (el.nodeType === 1 && el.matches && el.matches(s)) return el; + el = el.parentNode; + } + return null; + } + insertAdjacentHTML(position, html) { + const parent = this.parentNode; + switch (position) { + case 'beforebegin': + if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], this); } + break; + case 'afterbegin': + { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const first = this.firstChild; for (let i = children.length - 1; i >= 0; i--) this.insertBefore(children[i], first); } + break; + case 'beforeend': + { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) this.appendChild(children[i]); } + break; + case 'afterend': + if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const next = this.nextSibling; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], next); } + break; + } + } + addEventListener(type, handler, opts) { + const key = this._nid; + if (!_eventRegistry[key]) _eventRegistry[key] = {}; + if (!_eventRegistry[key][type]) _eventRegistry[key][type] = []; + _eventRegistry[key][type].push(handler); + } + removeEventListener(type, handler) { + const key = this._nid; + if (_eventRegistry[key] && _eventRegistry[key][type]) { + _eventRegistry[key][type] = _eventRegistry[key][type].filter(h => h !== handler); + } + } + dispatchEvent(event) { + if (!event) return true; + event.target = this; + event.currentTarget = this; + const handlers = (_eventRegistry[this._nid] || {})[event.type] || []; + for (const h of handlers) { try { h.call(this, event); } catch(e) { console.error(e); } } + if (event.bubbles && !event.defaultPrevented && this.parentNode) { + event.currentTarget = this.parentNode; + this.parentNode.dispatchEvent(event); + } + return !event.defaultPrevented; + } + click() { + const cancelled = !this.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true})); + if (!cancelled) { + const link = this.tagName === 'A' ? this : (this.closest ? this.closest('a[href]') : null); + if (link) { + const href = link.getAttribute('href'); + if (href && !href.startsWith('#') && !href.startsWith('javascript:')) { + location.assign(href); + return; + } + } + const type = (this.getAttribute('type') || '').toLowerCase(); + if (type === 'submit' || (this.localName === 'button' && type !== 'button' && type !== 'reset')) { + const form = this.closest ? this.closest('form') : null; + if (form && typeof form.submit === 'function') { + form.submit(this); + } + } + } + } + focus() { globalThis.__obscura_focused = this; globalThis.__obscura_click_target = this; } + blur() { if (globalThis.__obscura_focused === this) globalThis.__obscura_focused = null; } + get value() { + if (_formValues[this._nid] !== undefined) return _formValues[this._nid]; + const tag = this.localName; + if (tag === 'textarea') return this.textContent; + return this.getAttribute("value") || ""; + } + set value(v) { + _formValues[this._nid] = String(v); + const tag = this.localName; + if (tag === 'textarea') { + this.textContent = String(v); + } + } + get checked() { + if (_formChecked[this._nid] !== undefined) return _formChecked[this._nid]; + return this.hasAttribute("checked"); + } + set checked(v) { _formChecked[this._nid] = !!v; } + get selected() { + if (this._selected !== undefined) return this._selected; + return this.hasAttribute("selected"); + } + set selected(v) { this._selected = !!v; } + get disabled() { return this.hasAttribute("disabled"); } + set disabled(v) { if (v) this.setAttribute("disabled", ""); else this.removeAttribute("disabled"); } + get type() { return this.getAttribute("type") || (this.localName === "input" ? "text" : ""); } + set type(v) { this.setAttribute("type", v); } + get name() { return this.getAttribute("name") || ""; } + set name(v) { this.setAttribute("name", v); } + get placeholder() { return this.getAttribute("placeholder") || ""; } + set placeholder(v) { this.setAttribute("placeholder", v); } + get href() { return this.getAttribute("href") || ""; } + set href(v) { this.setAttribute("href", v); } + get src() { return this.getAttribute("src") || ""; } + set src(v) { + this.setAttribute("src", v); + if (this.localName === 'iframe' && v && v !== 'about:blank') { + this._loadIframeSrc(v); + } + } + _loadIframeSrc(url) { + let fullUrl = url; + if (!url.includes('://')) { + try { fullUrl = new URL(url, _domParse("document_url") || "about:blank").href; } catch(e) {} + } + const el = this; + fetch(fullUrl, {mode: 'no-cors'}).then(async resp => { + if (resp.ok || resp.type === 'opaque') { + const html = await resp.text(); + el._iframeDoc = new _IframeDocument(html, fullUrl, el); + el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); + } else { + el._iframeDoc = new _IframeDocument('', fullUrl, el); + el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); + } + _registerIframe(el); + if (typeof el.onload === 'function') { + try { el.onload(); } catch(e) {} + } else { + var onloadAttr = el.getAttribute('onload'); + if (onloadAttr) try { (0, eval)(onloadAttr); } catch(e) {} + } + }).catch(() => { + el._iframeDoc = new _IframeDocument('', fullUrl, el); + el._iframeWin = new _IframeWindow(el._iframeDoc, fullUrl); + _registerIframe(el); + if (typeof el.onload === 'function') try { el.onload(); } catch(e) {} + }); + } + get contentDocument() { + if (this.localName !== 'iframe') return undefined; + if (this._iframeDoc) { + const pageOrigin = (function(){ try { return new URL(_domParse("document_url")).origin; } catch(e) { return ''; } })(); + const iframeOrigin = (function(url){ try { return new URL(url).origin; } catch(e) { return ''; } })(this.src); + if (pageOrigin === iframeOrigin || this.src === '' || this.src === 'about:blank' || !this.src.includes('://')) { + return this._iframeDoc; + } + return null; // Cross-origin: blocked + } + if (!this._iframeDoc) { + this._iframeDoc = new _IframeDocument('', 'about:blank', this); + this._iframeWin = new _IframeWindow(this._iframeDoc, 'about:blank'); + } + return this._iframeDoc; + } + get contentWindow() { + if (this.localName !== 'iframe') return undefined; + if (!this._iframeWin) { + this.contentDocument; // side effect: creates _iframeDoc + _iframeWin + } + return this._iframeWin; + } + get action() { return this.getAttribute("action") || ""; } + set action(v) { this.setAttribute("action", v); } + get method() { return this.getAttribute("method") || "get"; } + set method(v) { this.setAttribute("method", v); } + get form() { + let p = this.parentNode; + while (p && p.localName !== 'form') p = p.parentNode; + return p; + } + get options() { + if (this.localName !== 'select') return []; + return this.querySelectorAll('option'); + } + get selectedIndex() { + const opts = this.options; + for (let i = 0; i < opts.length; i++) { + if (opts[i].selected || opts[i].hasAttribute('selected')) return i; + } + return -1; + } + set selectedIndex(v) { + const opts = this.options; + for (let i = 0; i < opts.length; i++) { + opts[i]._selected = (i === v); + } + } + submit(submitter) { + const cancelled = !this.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + if (cancelled) return; + + const pairs = []; + const fields = this.querySelectorAll('input, select, textarea'); + for (let i = 0; i < fields.length; i++) { + const f = fields[i]; + const name = f.getAttribute('name'); + if (!name) continue; + if (f.getAttribute('disabled') !== null) continue; + const tag = f.localName; + const type = (f.getAttribute('type') || '').toLowerCase(); + if ((type === 'checkbox' || type === 'radio') && !f.checked) continue; + if (type === 'file' || type === 'reset') continue; + if (type === 'button') continue; + if (type === 'submit' || tag === 'button') { + if (submitter && f !== submitter) continue; + if (!submitter) continue; // default submit: don't include submit button value + } + + let val; + if (tag === 'select') { + const opt = f.querySelector('option[selected]') || f.querySelector('option'); + val = opt ? (opt.getAttribute('value') !== null ? opt.getAttribute('value') : opt.textContent) : ''; + } else if (tag === 'textarea') { + val = f.value || f.textContent || ''; + } else { + val = f.value !== undefined ? f.value : (f.getAttribute('value') || ''); + } + const enc = (s) => encodeURIComponent(s).replace(/%20/g, '+').replace(/!/g, '%21'); + pairs.push(enc(name) + '=' + enc(val)); + } + + const action = this.getAttribute('action') || ''; + const method = (this.getAttribute('method') || 'GET').toUpperCase(); + const baseUrl = globalThis.location?.href || 'about:blank'; + let targetUrl; + try { targetUrl = new URL(action, baseUrl).href; } catch(e) { targetUrl = action; } + + const encoded = pairs.join('&'); + if (method === 'POST') { + Deno.core.ops.op_navigate(targetUrl, 'POST', encoded); + } else { + const sep = targetUrl.includes('?') ? '&' : '?'; + Deno.core.ops.op_navigate(targetUrl + (encoded ? sep + encoded : ''), 'GET', ''); + } + } + reset() { + this.dispatchEvent(new Event('reset', { bubbles: true })); + } + get dataset() { + const el = this; + return new Proxy({}, { + get(_, k) { if(typeof k!=="string")return undefined; return el.getAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase()); }, + set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; }, + }); + } + get offsetWidth() { return 100; } get offsetHeight() { return 20; } + get offsetTop() { return 0; } get offsetLeft() { return 0; } + get clientWidth() { return 100; } get clientHeight() { return 20; } + get scrollWidth() { return 100; } get scrollHeight() { return 20; } + get scrollTop() { return 0; } set scrollTop(v) {} + get scrollLeft() { return 0; } set scrollLeft(v) {} + getBoundingClientRect() { + globalThis.__obscura_click_target = this; + return {x:8,y:8,width:100,height:20,top:8,right:108,bottom:28,left:8,toJSON(){return this;}}; + } + getClientRects() { return [this.getBoundingClientRect()]; } + scrollIntoView() { globalThis.__obscura_click_target = this; } + animate(keyframes, options) { + const duration = typeof options === 'number' ? options : (options?.duration || 0); + return { + finished: Promise.resolve(), currentTime: 0, playState: 'finished', + effect: { getComputedTiming() { return { duration }; } }, + cancel(){}, finish(){}, play(){}, pause(){}, reverse(){}, + addEventListener(){}, removeEventListener(){}, + onfinish: null, oncancel: null, + }; + } + getAnimations() { return []; } + get isConnected() { return true; } + after() {} before() {} remove() { if (this.parentNode) this.parentNode.removeChild(this); } + append(...nodes) { for (const n of nodes) { if (typeof n === "string") this.appendChild(document.createTextNode(n)); else this.appendChild(n); } } + prepend() {} +} + +class Document extends Node { + get documentElement() { return _wrapEl(+_dom("document_element")); } + get head() { return this.querySelector("head"); } + get body() { return this.querySelector("body"); } + get doctype() { + if (this._doctype !== undefined) return this._doctype; + const info = _domParse("document_doctype"); + if (info && info.name) { + this._doctype = new DocumentType(info.nodeId, info.name, info.publicId || "", info.systemId || ""); + } else { + this._doctype = null; + } + return this._doctype; + } + get title() { return _domParse("document_title") ?? ""; } + set title(v) {} + get URL() { return _domParse("document_url") ?? ""; } + get documentURI() { return this.URL; } + get location() { return globalThis.location; } + set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); } + get defaultView() { return globalThis; } + get nodeType() { return 9; } + get nodeName() { return "#document"; } + get ownerDocument() { return null; } // Document has no ownerDocument + get compatMode() { return "CSS1Compat"; } + get characterSet() { return "UTF-8"; } + get contentType() { return "text/html"; } + get readyState() { return "complete"; } + get hidden() { return false; } + get visibilityState() { return "visible"; } + getElementById(id) { return _wrapEl(+_dom("get_element_by_id", id)); } + querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } + querySelectorAll(s) { + const ids = _domParse("query_selector_all", s) || []; + const list = ids.map(_wrapEl).filter(Boolean); + list.item = (i) => list[i] || null; + list.forEach = Array.prototype.forEach.bind(list); + return list; + } + getElementsByTagName(t) { return this.querySelectorAll(t); } + getElementsByClassName(c) { return this.querySelectorAll("." + c); } + createElement(t) { + const el = _wrapEl(+_dom("create_element", t.toLowerCase())); + return el; + } + createElementNS(ns, t) { + const el = this.createElement(t); + if (el) el._ns = ns; + return el; + } + createTextNode(t) { return _wrap(+_dom("create_text_node", String(t))); } + createComment(t) { + const nid = +_dom("create_text_node", ""); + const n = new Node(nid); + n._isComment = true; + n.nodeType = 8; // Override nodeType + Object.defineProperty(n, "nodeType", { value: 8, writable: false }); + Object.defineProperty(n, "nodeName", { value: "#comment", writable: false }); + _cache.set(nid, n); + return n; + } + createDocumentFragment() { + const nid = +_dom("create_document_fragment"); + const frag = new DocumentFragment(nid); + _cache.set(nid, frag); + return frag; + } + createEvent(type) { return new Event(type); } + createRange() { return { setStart(){}, setEnd(){}, collapse(){}, selectNodeContents(){}, cloneContents(){ return document.createDocumentFragment(); } }; } + addEventListener(type, fn, opts) {} removeEventListener() {} dispatchEvent() { return true; } + createTreeWalker(root, whatToShow, filter) { + whatToShow = whatToShow || 0xFFFFFFFF; // NodeFilter.SHOW_ALL + const walker = { + root: root, + currentNode: root, + whatToShow: whatToShow, + filter: filter || null, + _accept(node) { + const nodeType = node.nodeType; + const show = (whatToShow >> (nodeType - 1)) & 1; + if (!show) return false; + if (this.filter) { + if (typeof this.filter === 'function') return this.filter(node) === 1; + if (this.filter.acceptNode) return this.filter.acceptNode(node) === 1; + } + return true; + }, + nextNode() { + let node = this.currentNode; + let child = node.firstChild; + while (child) { + if (this._accept(child)) { this.currentNode = child; return child; } + if (child.firstChild) { child = child.firstChild; continue; } + if (child.nextSibling) { child = child.nextSibling; continue; } + let parent = child.parentNode; + while (parent && parent !== this.root) { + if (parent.nextSibling) { child = parent.nextSibling; break; } + parent = parent.parentNode; + } + if (!parent || parent === this.root) return null; + } + return null; + }, + previousNode() { + let node = this.currentNode; + if (node === this.root) return null; + let sibling = node.previousSibling; + if (sibling) { + while (sibling.lastChild) sibling = sibling.lastChild; + if (this._accept(sibling)) { this.currentNode = sibling; return sibling; } + } + let parent = node.parentNode; + if (parent && parent !== this.root && this._accept(parent)) { + this.currentNode = parent; + return parent; + } + return null; + }, + firstChild() { + let child = this.currentNode.firstChild; + while (child) { + if (this._accept(child)) { this.currentNode = child; return child; } + child = child.nextSibling; + } + return null; + }, + lastChild() { + let child = this.currentNode.lastChild; + while (child) { + if (this._accept(child)) { this.currentNode = child; return child; } + child = child.previousSibling; + } + return null; + }, + nextSibling() { + let sibling = this.currentNode.nextSibling; + while (sibling) { + if (this._accept(sibling)) { this.currentNode = sibling; return sibling; } + sibling = sibling.nextSibling; + } + return null; + }, + previousSibling() { + let sibling = this.currentNode.previousSibling; + while (sibling) { + if (this._accept(sibling)) { this.currentNode = sibling; return sibling; } + sibling = sibling.previousSibling; + } + return null; + }, + parentNode() { + let parent = this.currentNode.parentNode; + if (parent && parent !== this.root && this._accept(parent)) { + this.currentNode = parent; + return parent; + } + return null; + }, + }; + return walker; + } + createNodeIterator(root, whatToShow, filter) { + return this.createTreeWalker(root, whatToShow, filter); + } + getSelection() { return globalThis.getSelection(); } + get activeElement() { return globalThis.__obscura_focused || this.body; } + get implementation() { + return { + createHTMLDocument(title) { return globalThis.document; }, + createDocument() { return globalThis.document; }, + hasFeature() { return true; }, + }; + } + get styleSheets() { return []; } + get forms() { return []; } + get images() { return []; } + get links() { return []; } + get scripts() { return this.querySelectorAll("script"); } + get cookie() { + return Deno.core.ops.op_get_cookies(); + } + set cookie(v) { + if (!v) return; + Deno.core.ops.op_set_cookie(v); + } + write(...args) { + var html = args.join(''); + if (!html) return; + var body = this.body; + if (!body) return; + var temp = this.createElement('div'); + temp.innerHTML = html; + var children = temp.childNodes; + for (var i = 0; i < children.length; i++) { + body.appendChild(children[i]); + } + } + writeln(...args) { + this.write(args.join('') + '\n'); + } + open() { + var body = this.body; + if (body) body.innerHTML = ''; + return this; + } + close() { + return; + } + hasFocus() { return true; } + execCommand() { return false; } +} + +class DocumentFragment extends Node { + get nodeType() { return 11; } + get nodeName() { return "#document-fragment"; } + querySelector(s) { return null; } + querySelectorAll(s) { return []; } + get children() { return []; } + get firstElementChild() { return null; } + getElementById(id) { return null; } +} + +class DocumentType extends Node { + constructor(nid, name, publicId, systemId) { + super(nid); + this._name = name; + this._publicId = publicId; + this._systemId = systemId; + } + get nodeType() { return 10; } + get nodeName() { return this._name; } + get name() { return this._name; } + get publicId() { return this._publicId; } + get systemId() { return this._systemId; } + get ownerDocument() { return globalThis.document; } +} + +const _cache = new Map(); +function _wrap(nid) { + if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; + if (_cache.has(nid)) return _cache.get(nid); + const t = +_dom("node_type", nid); + let n; + if (t === 1) n = new Element(nid); + else if (t === 9) n = new Document(nid); + else n = new Node(nid); + _cache.set(nid, n); + return n; +} +function _wrapEl(nid) { + if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; + if (_cache.has(nid)) return _cache.get(nid); + const n = new Element(nid); + _cache.set(nid, n); + return n; +} + +globalThis.document = null; +function _resolveUrl(url) { + if (!url) return url; + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('about:')) return url; + try { return new URL(url, _domParse("document_url") || "about:blank").href; } catch(e) { return url; } +} +globalThis.location = { + get href() { return _domParse("document_url") ?? "about:blank"; }, + set href(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, + get origin() { try { return new URL(this.href).origin; } catch { return ""; } }, + get protocol() { try { return new URL(this.href).protocol; } catch { return ""; } }, + get host() { try { return new URL(this.href).host; } catch { return ""; } }, + get hostname() { try { return new URL(this.href).hostname; } catch { return ""; } }, + get pathname() { try { return new URL(this.href).pathname; } catch { return "/"; } }, + get search() { try { return new URL(this.href).search; } catch { return ""; } }, + get hash() { try { return new URL(this.href).hash; } catch { return ""; } }, + get port() { try { return new URL(this.href).port; } catch { return ""; } }, + toString() { return this.href; }, + assign(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, + reload() {}, + replace(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, +}; +const _locationObj = globalThis.location; +Object.defineProperty(globalThis, 'location', { + get() { return _locationObj; }, + set(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); }, + configurable: false, + enumerable: true, +}); + +globalThis.window = globalThis; +globalThis.self = globalThis; +globalThis.top = globalThis; +globalThis.parent = globalThis; +globalThis.frames = globalThis; +globalThis.frameElement = null; +globalThis.length = 0; + +const _iframeRegistry = []; +function _registerIframe(iframeEl) { + const idx = _iframeRegistry.length; + _iframeRegistry.push(iframeEl); + globalThis.length = _iframeRegistry.length; + Object.defineProperty(globalThis, idx, { + get() { return iframeEl._iframeWin || null; }, + configurable: true, + enumerable: false, + }); +} +globalThis.navigator = { + get userAgent() { return globalThis.__obscura_ua || "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"; }, + get appVersion() { return this.userAgent.replace('Mozilla/', ''); }, + language: "en-US", languages: ["en-US","en"], platform: "Linux x86_64", + onLine: true, cookieEnabled: true, hardwareConcurrency: 8, + maxTouchPoints: 0, + vendor: "Google Inc.", product: "Gecko", productSub: "20030107", + doNotTrack: null, + deviceMemory: 8, + connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false }, + get webdriver() { return undefined; }, + pdfViewerEnabled: true, + get plugins() { + const p = [ + { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, + { name: "Chrome PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, + { name: "Chromium PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, + { name: "Microsoft Edge PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, + { name: "WebKit built-in PDF", filename: "internal-pdf-viewer", description: "Portable Document Format", length: 1 }, + ]; + p.item = (i) => p[i] || null; + p.namedItem = (name) => p.find(x => x.name === name) || null; + p.refresh = () => {}; + return p; + }, + get mimeTypes() { + const m = [ + { type: "application/pdf", description: "Portable Document Format", suffixes: "pdf", enabledPlugin: null }, + { type: "text/pdf", description: "Portable Document Format", suffixes: "pdf", enabledPlugin: null }, + ]; + m.item = (i) => m[i] || null; + m.namedItem = (name) => m.find(x => x.type === name) || null; + return m; + }, + userAgentData: { + brands: [ + {brand: "Google Chrome", version: "145"}, + {brand: "Chromium", version: "145"}, + {brand: "Not=A?Brand", version: "24"}, + ], + mobile: false, + platform: "Linux", + getHighEntropyValues(hints) { + return Promise.resolve({ + architecture: "x86", + bitness: "64", + brands: [{brand:"Google Chrome",version:"145"},{brand:"Chromium",version:"145"},{brand:"Not=A?Brand",version:"24"}], + fullVersionList: [{brand:"Google Chrome",version:"145.0.0.0"},{brand:"Chromium",version:"145.0.0.0"},{brand:"Not=A?Brand",version:"24.0.0.0"}], + mobile: false, + model: "", + platform: "Linux", + platformVersion: "6.8.0", + uaFullVersion: "145.0.0.0", + }); + }, + toJSON() { return {brands:this.brands,mobile:this.mobile,platform:this.platform}; }, + }, + serviceWorker: { ready: Promise.resolve(), register(){return Promise.resolve();}, getRegistrations(){return Promise.resolve([]);}, controller: null }, + mediaDevices: { + enumerateDevices() { + return Promise.resolve([ + {deviceId:"default",kind:"audioinput",label:"",groupId:"default"}, + {deviceId:"comms",kind:"audioinput",label:"",groupId:"comms"}, + {deviceId:"default",kind:"audiooutput",label:"",groupId:"default"}, + {deviceId:"",kind:"videoinput",label:"",groupId:""}, + ]); + }, + getUserMedia() { return Promise.reject(new DOMException("NotAllowedError")); }, + getDisplayMedia() { return Promise.reject(new DOMException("NotAllowedError")); }, + addEventListener(){}, removeEventListener(){}, + }, + clipboard: { writeText(){return Promise.resolve();}, readText(){return Promise.resolve("");} }, + permissions: { query(params){ + if (params?.name === 'notifications') return Promise.resolve({state:"prompt",onchange:null}); + return Promise.resolve({state:"granted"}); + } }, + getBattery() { return Promise.resolve({ charging: _fp('batteryCharging'), chargingTime: _fp('batteryCharging') ? 0 : Infinity, dischargingTime: _fp('batteryCharging') ? Infinity : Math.floor(3600 + _fpRand(250) * 7200), level: _fp('batteryLevel'), addEventListener(){} }); }, + getGamepads() { return []; }, + sendBeacon() { return true; }, + javaEnabled() { return false; }, +}; + +globalThis.chrome = { + app: { isInstalled: false, InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, RunningState: { CANNOT_RUN: "cannot_run", READY_TO_RUN: "ready_to_run", RUNNING: "running" } }, + runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect() { return {}; }, sendMessage() {} }, + csi() { return {}; }, + loadTimes() { return {}; }, +}; + +globalThis.Notification = class Notification { + static permission = "default"; + static requestPermission() { return Promise.resolve("default"); } + constructor() {} +}; + +globalThis.WebGLRenderingContext = class WebGLRenderingContext {}; +globalThis.WebGL2RenderingContext = class WebGL2RenderingContext {}; + +globalThis.screen = { width:1920, height:1080, availWidth:1920, availHeight:1040, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} }; +globalThis.visualViewport = { width:1920, height:1000, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; +globalThis.devicePixelRatio = 1; +globalThis.innerWidth = 1920; globalThis.innerHeight = 1000; +globalThis.outerWidth = 1920; globalThis.outerHeight = 1080; +globalThis.scrollX = 0; globalThis.scrollY = 0; +globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; + +globalThis.__fetchInterceptEnabled = false; +globalThis.__fetchInterceptCallback = null; // Set by CDP to handle paused requests + +globalThis.fetch = async (input, init = {}) => { + let url = typeof input === "string" ? input : (input instanceof Request ? input.url : input?.url || ""); + if (url && !url.includes('://')) { + try { + const base = _domParse("document_url") || "about:blank"; + url = new URL(url, base).href; + } catch(e) { /* keep as-is if URL resolution fails */ } + } + const method = init.method || (input instanceof Request ? input.method : "GET"); + const hdrs = JSON.stringify(init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init.headers || {}); + const body = init.body ? String(init.body) : ""; + const fetchMode = init.mode || (input instanceof Request ? input.mode : "cors"); + const pageOrigin = (function() { try { const u = new URL(_domParse("document_url") || "about:blank"); return u.origin; } catch(e) { return ""; } })(); + const raw = await Deno.core.ops.op_fetch_url(url, method, hdrs, body, pageOrigin, fetchMode); + const parsed = JSON.parse(raw); + if (parsed.blocked) { + const err = new TypeError('net::ERR_FAILED'); + err.name = 'AbortError'; + err.__aborted = true; + throw err; + } + if (parsed.corsBlocked) { + throw new TypeError('Failed to fetch: ' + (parsed.corsError || 'CORS error')); + } + const respType = parsed.status === 0 ? "opaque" : (fetchMode === "no-cors" ? "opaque" : "basic"); + return { + ok: parsed.status >= 200 && parsed.status < 300, status: parsed.status, statusText: "", + type: respType, redirected: false, url: parsed.url || url, + headers: new Headers(parsed.headers || {}), + async text() { return parsed.body || ""; }, + async json() { return JSON.parse(parsed.body || "null"); }, + async arrayBuffer() { return new TextEncoder().encode(parsed.body || "").buffer; }, + async blob() { return new Blob([parsed.body || ""]); }, + clone() { return this; }, + }; +}; + +if (typeof Headers === "undefined") { + globalThis.Headers = class Headers { + constructor(init={}) { this._h={}; if(init) { if(init instanceof Headers) { init.forEach((v,k)=>{this._h[k]=v;}); } else if(typeof init==="object") { for(const[k,v]of Object.entries(init)) this._h[k.toLowerCase()]=String(v); } } } + get(n) { return this._h[n.toLowerCase()]??null; } set(n,v) { this._h[n.toLowerCase()]=String(v); } + has(n) { return n.toLowerCase() in this._h; } delete(n) { delete this._h[n.toLowerCase()]; } + append(n,v) { this._h[n.toLowerCase()]=String(v); } + forEach(cb) { for(const[k,v] of Object.entries(this._h)) cb(v,k,this); } + entries() { return Object.entries(this._h)[Symbol.iterator](); } + keys() { return Object.keys(this._h)[Symbol.iterator](); } + values() { return Object.values(this._h)[Symbol.iterator](); } + [Symbol.iterator]() { return this.entries(); } + }; +} + +globalThis.XMLHttpRequest = class XMLHttpRequest { + static UNSENT = 0; + static OPENED = 1; + static HEADERS_RECEIVED = 2; + static LOADING = 3; + static DONE = 4; + UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4; + + constructor() { + this.readyState = 0; + this.status = 0; + this.statusText = ""; + this.responseText = ""; + this.responseXML = null; + this.responseURL = ""; + this.responseType = ""; + this.response = null; + this.timeout = 0; + this.withCredentials = false; + this.upload = { addEventListener(){}, removeEventListener(){} }; + this._method = "GET"; + this._url = ""; + this._headers = {}; + this._responseHeaders = {}; + this._aborted = false; + this._listeners = {}; + this.onreadystatechange = null; + this.onload = null; + this.onerror = null; + this.onabort = null; + this.onprogress = null; + this.ontimeout = null; + this.onloadstart = null; + this.onloadend = null; + } + + open(method, url, async_) { + this._method = method; + this._url = url; + this._headers = {}; + this._responseHeaders = {}; + this._aborted = false; + this.status = 0; + this.statusText = ""; + this.responseText = ""; + this.response = null; + this._setReadyState(1); + } + + setRequestHeader(name, value) { + this._headers[name] = value; + } + + getResponseHeader(name) { + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(this._responseHeaders)) { + if (k.toLowerCase() === lower) return v; + } + return null; + } + + getAllResponseHeaders() { + return Object.entries(this._responseHeaders) + .map(([k, v]) => k + ': ' + v) + .join('\r\n'); + } + + overrideMimeType(mime) { this._overrideMime = mime; } + + send(body) { + if (this.readyState !== 1) return; + if (this._aborted) return; + + const xhr = this; + this._fireEvent('loadstart'); + + let url = this._url; + if (url && !url.includes('://')) { + try { + const base = _domParse("document_url") || "about:blank"; + url = new URL(url, base).href; + } catch(e) {} + } + + fetch(url, { + method: this._method, + headers: this._headers, + body: body || undefined, + mode: 'cors', + }).then(async (resp) => { + if (xhr._aborted) return; + + xhr.status = resp.status; + xhr.statusText = resp.statusText || ''; + xhr.responseURL = resp.url || url; + + if (resp.headers) { + resp.headers.forEach((v, k) => { xhr._responseHeaders[k] = v; }); + } + + xhr._setReadyState(2); // HEADERS_RECEIVED + + const text = await resp.text(); + if (xhr._aborted) return; + + xhr.responseText = text; + xhr._setReadyState(3); // LOADING + + switch (xhr.responseType) { + case 'json': + try { xhr.response = JSON.parse(text); } catch(e) { xhr.response = null; } + break; + case 'text': + case '': + xhr.response = text; + break; + case 'arraybuffer': + xhr.response = new TextEncoder().encode(text).buffer; + break; + case 'blob': + xhr.response = new Blob([text]); + break; + case 'document': + xhr.response = text; // simplified + break; + default: + xhr.response = text; + } + + xhr._setReadyState(4); // DONE + xhr._fireEvent('load'); + xhr._fireEvent('loadend'); + }).catch((err) => { + if (xhr._aborted) return; + xhr.status = 0; + xhr.readyState = 4; + xhr._fireEvent('readystatechange'); + if (err && err.__aborted) { + xhr._aborted = true; + xhr._fireEvent('abort'); + xhr._fireEvent('loadend'); + if (xhr.onabort) xhr.onabort(err); + } else { + xhr._fireEvent('error'); + xhr._fireEvent('loadend'); + if (xhr.onerror) xhr.onerror(err); + } + }); + } + + abort() { + this._aborted = true; + if (this.readyState > 0 && this.readyState < 4) { + this._setReadyState(4); + this._fireEvent('abort'); + this._fireEvent('loadend'); + } + this.readyState = 0; + } + + addEventListener(type, handler) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(handler); + } + + removeEventListener(type, handler) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter(h => h !== handler); + } + } + + _setReadyState(state) { + this.readyState = state; + this._fireEvent('readystatechange'); + if (this.onreadystatechange) { + try { this.onreadystatechange(); } catch(e) {} + } + } + + _fireEvent(type) { + const event = { type, target: this, currentTarget: this, bubbles: false }; + const handlers = this._listeners[type] || []; + for (const h of handlers) { try { h.call(this, event); } catch(e) {} } + const prop = 'on' + type; + if (type !== 'readystatechange' && typeof this[prop] === 'function') { + try { this[prop](event); } catch(e) {} + } + } +}; +_markNative(XMLHttpRequest); +_markNative(XMLHttpRequest.prototype.open); +_markNative(XMLHttpRequest.prototype.send); +_markNative(XMLHttpRequest.prototype.abort); +_markNative(XMLHttpRequest.prototype.setRequestHeader); +_markNative(XMLHttpRequest.prototype.getResponseHeader); +_markNative(XMLHttpRequest.prototype.getAllResponseHeaders); + +if (typeof URL === 'undefined' || !URL.prototype) { + globalThis.URL = class URL { + constructor(url, base) { + let full = url; + if (base && !url.includes('://')) { + var bm = base.match(/^(https?:\/\/[^\/\?#]+)(\/[^?#]*)?/); + if (bm) { + var bOrigin = bm[1]; + var bPath = bm[2] || '/'; + if (url.startsWith('/')) { + full = bOrigin + url; + } else if (url.startsWith('?') || url.startsWith('#')) { + full = bOrigin + bPath + url; + } else { + var dir = bPath.substring(0, bPath.lastIndexOf('/') + 1); + full = bOrigin + dir + url; + } + } + } + const m = full.match(/^(https?):\/\/([^\/\?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/); + if (m) { + this.protocol = m[1] + ':'; + this.host = m[2]; this.hostname = m[2].split(':')[0]; + this.port = m[2].includes(':') ? m[2].split(':')[1] : ''; + this.pathname = m[3] || '/'; + this.search = m[4] || ''; this.hash = m[5] || ''; + } else { + this.protocol = ''; this.host = ''; this.hostname = ''; + this.port = ''; this.pathname = full; this.search = ''; this.hash = ''; + } + this.href = full; this.origin = this.protocol + '//' + this.host; + this.searchParams = new URLSearchParams(this.search); + } + toString() { return this.href; } + toJSON() { return this.href; } + static createObjectURL() { return 'blob:null/fake-' + Math.random().toString(36).slice(2); } + static revokeObjectURL() {} + }; +} + +globalThis.requestIdleCallback = globalThis.requestIdleCallback || function requestIdleCallback(cb, opts) { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining() { return Math.max(0, 50 - (Date.now() - start)); }, + }); + }, 1); +}; +globalThis.cancelIdleCallback = globalThis.cancelIdleCallback || function cancelIdleCallback(id) { clearTimeout(id); }; +_markNative(globalThis.requestIdleCallback); +_markNative(globalThis.cancelIdleCallback); + +if (typeof Request === 'undefined') { + globalThis.Request = class Request { + constructor(input, init = {}) { + if (typeof input === 'string') { this.url = input; } + else if (input instanceof Request) { this.url = input.url; init = { ...input, ...init }; } + else { this.url = String(input); } + this.method = (init.method || 'GET').toUpperCase(); + this.headers = new Headers(init.headers); + this.body = init.body || null; + this.mode = init.mode || 'cors'; + this.credentials = init.credentials || 'same-origin'; + this.redirect = init.redirect || 'follow'; + this.referrer = init.referrer || ''; + this.signal = init.signal || { aborted: false, addEventListener(){}, removeEventListener(){} }; + this.cache = init.cache || 'default'; + } + clone() { return new Request(this.url, { method: this.method, headers: this.headers, body: this.body }); } + async text() { return this.body ? String(this.body) : ''; } + async json() { return JSON.parse(await this.text()); } + async arrayBuffer() { return new TextEncoder().encode(await this.text()).buffer; } + }; +} + +if (typeof Response === 'undefined') { + globalThis.Response = class Response { + constructor(body, init = {}) { + this._body = body; this.status = init.status || 200; this.statusText = init.statusText || ''; + this.ok = this.status >= 200 && this.status < 300; + this.headers = new Headers(init.headers); + this.type = 'basic'; this.url = ''; + } + async text() { return this._body ? String(this._body) : ''; } + async json() { return JSON.parse(await this.text()); } + async arrayBuffer() { return new TextEncoder().encode(await this.text()).buffer; } + async blob() { return new Blob([await this.text()]); } + clone() { return new Response(this._body, { status: this.status, headers: this.headers }); } + static error() { return new Response(null, { status: 0 }); } + static redirect(url, status) { return new Response(null, { status: status || 302, headers: { Location: url } }); } + static json(data, init) { return new Response(JSON.stringify(data), { ...init, headers: { 'content-type': 'application/json', ...(init?.headers || {}) } }); } + }; +} + +if (!Element.prototype.replaceWith) { + Element.prototype.replaceWith = function(...nodes) { + const parent = this.parentNode; + if (!parent) return; + for (const n of nodes) { + if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this); + else parent.insertBefore(n, this); + } + parent.removeChild(this); + }; + _markNative(Element.prototype.replaceWith); +} +if (!Element.prototype.before) { + Element.prototype.before = function(...nodes) { + const parent = this.parentNode; + if (!parent) return; + for (const n of nodes) { + if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this); + else parent.insertBefore(n, this); + } + }; + _markNative(Element.prototype.before); +} +if (!Element.prototype.after) { + Element.prototype.after = function(...nodes) { + const parent = this.parentNode; + if (!parent) return; + const ref = this.nextSibling; + for (const n of nodes) { + if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), ref); + else parent.insertBefore(n, ref); + } + }; + _markNative(Element.prototype.after); +} + +if (!('isConnected' in Node.prototype)) { + Object.defineProperty(Node.prototype, 'isConnected', { + get() { + let node = this; + while (node) { + if (node.nodeType === 9) return true; // Document node + node = node.parentNode; + } + return false; + } + }); +} + +globalThis.ResizeObserver = class ResizeObserver { + constructor(callback) { this._callback = callback; this._targets = []; } + observe(el) { + this._targets.push(el); + Promise.resolve().then(() => { + this._callback([{ + target: el, contentRect: { x:0, y:0, width:100, height:20, top:0, left:0, bottom:20, right:100 }, + borderBoxSize: [{ blockSize: 20, inlineSize: 100 }], + contentBoxSize: [{ blockSize: 20, inlineSize: 100 }], + }], this); + }); + } + unobserve(el) { this._targets = this._targets.filter(t => t !== el); } + disconnect() { this._targets = []; } +}; + +if (typeof TextEncoder === 'undefined') { + globalThis.TextEncoder = class TextEncoder { + get encoding() { return 'utf-8'; } + encode(str) { + str = String(str); + const buf = []; + for (let i = 0; i < str.length; i++) { + let c = str.charCodeAt(i); + if (c < 0x80) buf.push(c); + else if (c < 0x800) { buf.push(0xC0|(c>>6), 0x80|(c&0x3F)); } + else if (c < 0xD800 || c >= 0xE000) { buf.push(0xE0|(c>>12), 0x80|((c>>6)&0x3F), 0x80|(c&0x3F)); } + else { c = 0x10000 + (((c & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF)); buf.push(0xF0|(c>>18), 0x80|((c>>12)&0x3F), 0x80|((c>>6)&0x3F), 0x80|(c&0x3F)); } + } + return new Uint8Array(buf); + } + encodeInto(str, dest) { const enc = this.encode(str); dest.set(enc.slice(0, dest.length)); return { read: str.length, written: Math.min(enc.length, dest.length) }; } + }; +} +if (typeof TextDecoder === 'undefined') { + globalThis.TextDecoder = class TextDecoder { + constructor(label) { this.encoding = label || 'utf-8'; } + decode(buf) { + if (!buf) return ''; + const bytes = new Uint8Array(buf.buffer || buf); + let str = '', i = 0; + while (i < bytes.length) { + let c = bytes[i++]; + if (c < 0x80) str += String.fromCharCode(c); + else if (c < 0xE0) str += String.fromCharCode(((c&0x1F)<<6)|(bytes[i++]&0x3F)); + else if (c < 0xF0) { const b1=bytes[i++], b2=bytes[i++]; str += String.fromCharCode(((c&0x0F)<<12)|((b1&0x3F)<<6)|(b2&0x3F)); } + else { const b1=bytes[i++], b2=bytes[i++], b3=bytes[i++]; const cp=((c&0x07)<<18)|((b1&0x3F)<<12)|((b2&0x3F)<<6)|(b3&0x3F); if(cp>0xFFFF){const s=cp-0x10000;str+=String.fromCharCode(0xD800+(s>>10),0xDC00+(s&0x3FF));}else str+=String.fromCharCode(cp); } + } + return str; + } + }; +} + +globalThis.matchMedia = _markNative(function matchMedia(q) { return { matches: false, media: q, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} }; }); +globalThis.getComputedStyle = (el) => { + if (!el) el = document.body || {}; + const style = el?.style || el?._style || new CSSStyleDeclaration(); + return new Proxy(style, { + get(target, prop) { + if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; + if (prop in target) return target[prop]; + if (typeof prop === 'string') { + const v = target.getPropertyValue ? target.getPropertyValue(prop) : ''; + if (v) return v; + const defaults = { + display: 'block', visibility: 'visible', opacity: '1', + position: 'static', overflow: 'visible', + transform: 'none', transition: 'none', animation: 'none', + float: 'none', clear: 'none', + width: 'auto', height: 'auto', + top: 'auto', left: 'auto', right: 'auto', bottom: 'auto', + margin: '0px', padding: '0px', + 'margin-top': '0px', 'margin-right': '0px', 'margin-bottom': '0px', 'margin-left': '0px', + 'padding-top': '0px', 'padding-right': '0px', 'padding-bottom': '0px', 'padding-left': '0px', + 'font-size': '16px', 'line-height': 'normal', 'font-weight': '400', + color: 'rgb(0, 0, 0)', 'background-color': 'rgba(0, 0, 0, 0)', + 'border-width': '0px', 'border-style': 'none', 'border-color': 'rgb(0, 0, 0)', + 'z-index': 'auto', 'pointer-events': 'auto', + 'box-sizing': 'content-box', cursor: 'auto', + }; + const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase(); + if (defaults[prop]) return defaults[prop]; + if (defaults[kebabProp]) return defaults[kebabProp]; + return ''; + } + if (prop === 'getPropertyValue') { + return (name) => { + const v = target.getPropertyValue ? target.getPropertyValue(name) : ''; + if (v) return v; + const defaults = {transform:'none',opacity:'1',display:'block',visibility:'visible'}; + return defaults[name] || defaults[name.replace(/-([a-z])/g,(_,c)=>c.toUpperCase())] || ''; + }; + } + if (prop === 'length') return 0; + return undefined; + } + }); +}; +globalThis.getSelection = _markNative(function getSelection() { + return { + rangeCount: 0, + anchorNode: null, anchorOffset: 0, + focusNode: null, focusOffset: 0, + isCollapsed: true, type: 'None', + removeAllRanges() { this.rangeCount = 0; }, + addRange(range) { this.rangeCount = 1; this._range = range; }, + getRangeAt(i) { return this._range || null; }, + collapse(node, offset) { this.anchorNode = node; this.anchorOffset = offset || 0; this.isCollapsed = true; }, + extend(node, offset) { this.focusNode = node; this.focusOffset = offset || 0; }, + selectAllChildren(node) {}, + deleteFromDocument() {}, + containsNode(node) { return false; }, + toString() { return ''; }, + }; +}); + +globalThis.CSSStyleSheet = class CSSStyleSheet { + constructor(options) { + this.cssRules = []; + this.ownerRule = null; + this.disabled = false; + this._rules = []; + } + insertRule(rule, index) { + const idx = index ?? this._rules.length; + this._rules.splice(idx, 0, { cssText: rule, type: 1 }); + this.cssRules = this._rules; + return idx; + } + deleteRule(index) { + this._rules.splice(index, 1); + this.cssRules = this._rules; + } + addRule(selector, style, index) { + return this.insertRule(selector + '{' + style + '}', index); + } + removeRule(index) { this.deleteRule(index); } + replace(text) { + this._rules = [{ cssText: text, type: 1 }]; + this.cssRules = this._rules; + return Promise.resolve(this); + } + replaceSync(text) { + this._rules = [{ cssText: text, type: 1 }]; + this.cssRules = this._rules; + } +}; + +Object.defineProperty(Document.prototype, 'adoptedStyleSheets', { + get() { return this._adoptedStyleSheets || []; }, + set(sheets) { this._adoptedStyleSheets = sheets; }, +}); + +globalThis.__mutationObservers = []; +globalThis.MutationObserver = class MutationObserver { + constructor(callback) { + this._callback = callback; + this._targets = []; + this._records = []; + } + observe(target, options) { + this._targets.push({ target, options: options || {} }); + globalThis.__mutationObservers.push(this); + } + disconnect() { + this._targets = []; + const idx = globalThis.__mutationObservers.indexOf(this); + if (idx >= 0) globalThis.__mutationObservers.splice(idx, 1); + } + takeRecords() { + const r = this._records.slice(); + this._records = []; + return r; + } + _notify(records) { + this._records.push(...records); + Promise.resolve().then(() => { + if (this._records.length > 0) { + const batch = this._records.splice(0); + try { this._callback(batch, this); } catch(e) { /* observer errors shouldn't propagate */ } + } + }); + } +}; +globalThis.__notifyMutation = function(type, target_nid, addedNodes, removedNodes, attributeName) { + if (!globalThis.__mutationObservers.length) return; + const target = globalThis._cache?.get(target_nid) || null; + if (!target) return; + const record = { + type: type, // 'childList', 'attributes', 'characterData' + target: target, + addedNodes: (addedNodes || []).map(nid => globalThis._cache?.get(nid) || null).filter(Boolean), + removedNodes: (removedNodes || []).map(nid => globalThis._cache?.get(nid) || null).filter(Boolean), + attributeName: attributeName || null, + oldValue: null, + previousSibling: null, + nextSibling: null, + }; + for (const obs of globalThis.__mutationObservers) { + for (const t of obs._targets) { + if (t.target._nid === target_nid || (t.options.subtree && target.contains && target.closest && true)) { + obs._notify([record]); + break; + } + } + } +}; + +globalThis.ShadowRoot = class ShadowRoot {}; +globalThis.customElements = { + _registry: new Map(), + define(name, cls, opts) { this._registry.set(name, cls); }, + get(name) { return this._registry.get(name); }, + whenDefined(name) { return Promise.resolve(this._registry.get(name)); }, + upgrade() {}, +}; +globalThis.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4, SHOW_ALL: 0xFFFFFFFF }; +globalThis.ResizeObserver = class { constructor(){} observe(){} unobserve(){} disconnect(){} }; +globalThis.IntersectionObserver = class { + constructor(callback) { this._callback = callback; } + observe(el) { + Promise.resolve().then(() => { + this._callback([{ + target: el, + isIntersecting: true, + intersectionRatio: 1, + boundingClientRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20}, + intersectionRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20}, + rootBounds: {x:0,y:0,width:1280,height:720}, + }], this); + }); + } + unobserve() {} + disconnect() {} +}; +globalThis.PerformanceObserver = class { constructor(){} observe(){} disconnect(){} }; + +globalThis.Event = class Event { + constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now(); } + get isTrusted() { return true; } + preventDefault() { this.defaultPrevented=true; } stopPropagation(){} stopImmediatePropagation(){} + initEvent(type,bubbles,cancelable) { this.type=type;this.bubbles=!!bubbles;this.cancelable=!!cancelable; } +}; +globalThis.CustomEvent = class extends Event { constructor(t,o={}) { super(t,o);this.detail=o.detail; } }; +globalThis.MouseEvent = class extends Event { constructor(t,o={}) { super(t,o);this.clientX=o.clientX||0;this.clientY=o.clientY||0; } }; +globalThis.KeyboardEvent = class extends Event { constructor(t,o={}) { super(t,o);this.key=o.key||"";this.code=o.code||""; } }; +globalThis.FocusEvent = class extends Event {}; +globalThis.InputEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data||null;this.inputType=o.inputType||""; } }; +globalThis.ErrorEvent = class extends Event { constructor(t,o={}) { super(t,o);this.message=o.message||"";this.error=o.error||null; } }; +globalThis.PointerEvent = class extends Event { constructor(t,o={}) { super(t,o); } }; +globalThis.AnimationEvent = class extends Event {}; +globalThis.TransitionEvent = class extends Event {}; +globalThis.UIEvent = class extends Event {}; +globalThis.WheelEvent = class extends Event {}; +globalThis.PopStateEvent = class extends Event {}; +globalThis.HashChangeEvent = class extends Event {}; +globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } }; +globalThis.ClipboardEvent = class extends Event {}; +globalThis.SubmitEvent = class extends Event {}; + +globalThis.AbortController = class AbortController { constructor(){this.signal={aborted:false,addEventListener(){},removeEventListener(){},onabort:null};} abort(){this.signal.aborted=true;} }; +globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){},removeEventListener(){}}; } }; +if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} }; +if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} }; +if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} }; +if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class { constructor(init=""){this._p=new Map();if(typeof init==="string"){init.replace(/^\?/,"").split("&").forEach(p=>{const[k,...v]=p.split("=");if(k)this._p.set(decodeURIComponent(k),decodeURIComponent(v.join("=")));});}} get(k){return this._p.get(k)??null;} set(k,v){this._p.set(k,String(v));} has(k){return this._p.has(k);} toString(){return[...this._p].map(([k,v])=>`${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");} forEach(cb){this._p.forEach((v,k)=>cb(v,k));} }; + +globalThis.DOMParser = class { parseFromString(s,t) { return globalThis.document; } }; +globalThis.XMLSerializer = class XMLSerializer { + serializeToString(node) { + if (!node) return ""; + if (node.nodeType === 10) { + let s = ""; + return s; + } + if (node.outerHTML !== undefined) return node.outerHTML; + if (node.nodeType === 9) { + let s = ""; + if (node.doctype) s += this.serializeToString(node.doctype); + if (node.documentElement) s += node.documentElement.outerHTML; + return s; + } + if (node.nodeType === 3) return node.textContent || ""; + if (node.nodeType === 8) return ""; + return ""; + } +}; +globalThis.performance = globalThis.performance || { + now: () => Date.now(), + mark(){}, measure(){}, + clearMarks(){}, clearMeasures(){}, clearResourceTimings(){}, + getEntries(){return [];}, getEntriesByName(){return [];}, getEntriesByType(){return [];}, + setResourceTimingBufferSize(){}, + timeOrigin: 0, + timing: { navigationStart: 0, domContentLoadedEventEnd: 0, loadEventEnd: 0 }, + navigation: { type: 0, redirectCount: 0 }, + memory: { + jsHeapSizeLimit: 2172649472, + totalJSHeapSize: 19321856, + usedJSHeapSize: 16781520, + }, +}; + +Object.defineProperty(Document.prototype, 'fonts', { + get() { + return { + ready: Promise.resolve(), + check() { return true; }, + load() { return Promise.resolve([]); }, + add() {}, + delete() { return false; }, + clear() {}, + has() { return false; }, + forEach() {}, + get size() { return 0; }, + get status() { return 'loaded'; }, + addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; }, + [Symbol.iterator]() { return [][Symbol.iterator](); }, + }; + }, + configurable: true, +}); +globalThis.crypto = globalThis.crypto || { getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } }; +globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v))); +globalThis.reportError = globalThis.reportError || ((e) => console.error(e)); + +const _mkStore = () => { const s={}; return { getItem:k=>s[k]??null, setItem:(k,v)=>{s[k]=String(v);}, removeItem:k=>{delete s[k];}, clear:()=>{for(const k in s)delete s[k];}, get length(){return Object.keys(s).length;}, key:i=>Object.keys(s)[i]??null }; }; +globalThis.localStorage = _mkStore(); +globalThis.sessionStorage = _mkStore(); + +globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i>2]+c[((a&3)<<4)|(bb>>4)]+(i+1>6)]:"=")+(i+2 { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i>4)); if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); if(d>=0)r.push(((cc&3)<<6)|d);} return String.fromCharCode(...r); }); + +globalThis.history = { length:1, state:null, pushState(){}, replaceState(){}, go(){}, back(){}, forward(){}, scrollRestoration:"auto" }; +globalThis.screenX = 0; globalThis.screenY = 0; +globalThis.screenLeft = 0; globalThis.screenTop = 0; +globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; +globalThis.scrollX = 0; globalThis.scrollY = 0; + +globalThis.CSS = { supports(){return false;}, escape(s){return s;} }; + +globalThis.HTMLElement = Element; +globalThis.HTMLDivElement = Element; +globalThis.HTMLSpanElement = Element; +globalThis.HTMLParagraphElement = Element; +globalThis.HTMLAnchorElement = Element; +globalThis.HTMLImageElement = Element; +globalThis.HTMLInputElement = Element; +globalThis.HTMLButtonElement = Element; +globalThis.HTMLFormElement = Element; +globalThis.HTMLSelectElement = Element; +globalThis.HTMLTextAreaElement = Element; +globalThis.HTMLLabelElement = Element; +globalThis.HTMLTableElement = Element; +globalThis.HTMLIFrameElement = Element; +globalThis.HTMLCanvasElement = Element; +globalThis.HTMLVideoElement = Element; +globalThis.HTMLAudioElement = Element; +globalThis.HTMLScriptElement = Element; +globalThis.HTMLStyleElement = Element; +globalThis.HTMLLinkElement = Element; +globalThis.HTMLMetaElement = Element; +globalThis.HTMLHeadElement = Element; +globalThis.HTMLBodyElement = Element; +globalThis.HTMLHtmlElement = Element; +globalThis.HTMLBRElement = Element; +globalThis.HTMLHRElement = Element; +globalThis.HTMLUListElement = Element; +globalThis.HTMLOListElement = Element; +globalThis.HTMLLIElement = Element; +globalThis.HTMLPreElement = Element; +globalThis.HTMLHeadingElement = Element; +globalThis.HTMLTemplateElement = Element; +globalThis.HTMLSlotElement = Element; +globalThis.HTMLOptionElement = Element; +globalThis.HTMLDataListElement = Element; +globalThis.HTMLFieldSetElement = Element; +globalThis.HTMLLegendElement = Element; +globalThis.HTMLProgressElement = Element; +globalThis.HTMLDetailsElement = Element; +globalThis.HTMLDialogElement = Element; +globalThis.SVGElement = Element; +globalThis.SVGSVGElement = Element; +globalThis.Text = Node; +globalThis.Comment = Node; +globalThis.DocumentFragment = DocumentFragment; +globalThis.DocumentType = DocumentType; +globalThis.Node = Node; +globalThis.Element = Element; +globalThis.Document = Document; +globalThis.EventTarget = Node; +globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNodeContents(){} deleteContents(){} cloneContents(){ return document.createDocumentFragment(); } insertNode(){} getBoundingClientRect(){return {x:0,y:0,width:0,height:0,top:0,right:0,bottom:0,left:0};} }; + +[ + navigator.getBattery, navigator.getGamepads, navigator.sendBeacon, + navigator.javaEnabled, navigator.serviceWorker?.register, + navigator.permissions?.query, navigator.credentials?.get, + globalThis.fetch, globalThis.matchMedia, globalThis.getComputedStyle, + globalThis.getSelection, globalThis.requestAnimationFrame, + globalThis.cancelAnimationFrame, globalThis.setTimeout, globalThis.clearTimeout, + globalThis.setInterval, globalThis.clearInterval, globalThis.queueMicrotask, + globalThis.structuredClone, globalThis.reportError, + globalThis.btoa, globalThis.atob, + console.log, console.warn, console.error, console.info, console.debug, + console.dir, console.assert, + Element.prototype.getAttribute, Element.prototype.setAttribute, + Element.prototype.removeAttribute, Element.prototype.hasAttribute, + Element.prototype.querySelector, Element.prototype.querySelectorAll, + Element.prototype.getElementsByTagName, Element.prototype.getElementsByClassName, + Element.prototype.matches, Element.prototype.closest, + Element.prototype.getBoundingClientRect, Element.prototype.getClientRects, + Element.prototype.addEventListener, Element.prototype.removeEventListener, + Element.prototype.dispatchEvent, Element.prototype.click, + Element.prototype.focus, Element.prototype.blur, + Element.prototype.cloneNode, Element.prototype.attachShadow, + Element.prototype.insertAdjacentHTML, Element.prototype.scrollIntoView, + Element.prototype.append, Element.prototype.remove, + Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob, + Node.prototype.appendChild, Node.prototype.removeChild, + Node.prototype.replaceChild, Node.prototype.insertBefore, + Node.prototype.contains, Node.prototype.hasChildNodes, Node.prototype.cloneNode, + Document.prototype.getElementById, Document.prototype.querySelector, + Document.prototype.querySelectorAll, Document.prototype.getElementsByTagName, + Document.prototype.createElement, Document.prototype.createElementNS, + Document.prototype.createTextNode, Document.prototype.createComment, + Document.prototype.createDocumentFragment, Document.prototype.createEvent, + Document.prototype.hasFocus, + Notification, Notification.requestPermission, + window.chrome?.csi, window.chrome?.loadTimes, + MutationObserver, ResizeObserver, IntersectionObserver, PerformanceObserver, + XMLSerializer, XMLSerializer.prototype.serializeToString, +].forEach(fn => { if (typeof fn === 'function') _markNative(fn); }); + +class _IframeDocument { + constructor(html, url, iframeEl) { + this._url = url; + this._iframeEl = iframeEl; + this.nodeType = 9; + this.nodeName = '#document'; + this.readyState = 'complete'; + this.characterSet = 'UTF-8'; + this.contentType = 'text/html'; + this.visibilityState = 'visible'; + this.hidden = false; + + this._root = document.createElement('html'); + this._head = document.createElement('head'); + this._body = document.createElement('body'); + this._root.appendChild(this._head); + this._root.appendChild(this._body); + var bodyContent = html + .replace(/^]*>/i, '') + .replace(/<\/?html[^>]*>/gi, '') + .replace(/]*>[\s\S]*?<\/head>/gi, '') + .replace(/<\/?body[^>]*>/gi, '') + .replace(/^\s+/, ''); // trim leading whitespace (before content) + if (bodyContent) { + this._body.innerHTML = bodyContent; + } + + this._title = ''; + if (this._head) { + const titleEl = this._head.querySelector('title'); + if (titleEl) this._title = titleEl.textContent; + } + } + + get documentElement() { return this._root; } + get head() { return this._head; } + get body() { return this._body; } + get title() { return this._title; } + set title(v) { this._title = v; } + get URL() { return this._url; } + get documentURI() { return this._url; } + get location() { return this._iframeEl?.contentWindow?.location; } + get defaultView() { return this._iframeEl?.contentWindow; } + get ownerDocument() { return null; } + get compatMode() { return 'CSS1Compat'; } + get activeElement() { return this._body; } + + getElementById(id) { + return this._root.querySelector('#' + id); + } + querySelector(sel) { + return this._root.querySelector(sel); + } + querySelectorAll(sel) { + return this._root.querySelectorAll(sel); + } + getElementsByTagName(tag) { + return this._root.querySelectorAll(tag); + } + getElementsByClassName(cls) { + return this._root.querySelectorAll('.' + cls); + } + createElement(tag) { return document.createElement(tag); } + createElementNS(ns, tag) { return document.createElementNS(ns, tag); } + createTextNode(text) { return document.createTextNode(text); } + createComment(text) { return document.createComment(text); } + createDocumentFragment() { return document.createDocumentFragment(); } + createEvent(type) { return new Event(type); } + hasFocus() { return false; } + + get cookie() { return ''; } + set cookie(v) {} + get implementation() { return document.implementation; } + get styleSheets() { return []; } + + addEventListener() {} + removeEventListener() {} + dispatchEvent() { return true; } + + write(html) { + if (this._body) this._body.innerHTML += html; + } + writeln(html) { this.write(html + '\n'); } + open() { if (this._body) this._body.innerHTML = ''; } + close() {} +} + +class _IframeWindow { + constructor(doc, url) { + this.document = doc; + this._url = url; + this.self = this; + this.top = globalThis; + this.parent = globalThis; + this.window = this; + this.frames = this; + this.frameElement = null; + this.length = 0; + this.name = ''; + this.closed = false; + this.navigator = globalThis.navigator; + this.screen = globalThis.screen; + this.innerWidth = 300; + this.innerHeight = 150; + this.outerWidth = 300; + this.outerHeight = 150; + this.devicePixelRatio = globalThis.devicePixelRatio; + this.localStorage = globalThis.localStorage; + this.sessionStorage = globalThis.sessionStorage; + this.performance = globalThis.performance; + this.crypto = globalThis.crypto; + this.console = globalThis.console; + this.chrome = globalThis.chrome; + + try { + const u = new URL(url); + this.location = { + href: url, origin: u.origin, protocol: u.protocol, + host: u.host, hostname: u.hostname, port: u.port, + pathname: u.pathname, search: u.search, hash: u.hash, + toString() { return url; }, assign(){}, reload(){}, replace(){}, + }; + } catch(e) { + this.location = { href: url, origin: '', protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '', toString() { return url; }, assign(){}, reload(){}, replace(){} }; + } + } + + postMessage(data, origin) { + const event = new MessageEvent('message', { + data: data, + origin: this.location.origin, + source: this, + }); + Promise.resolve().then(() => { + globalThis.dispatchEvent?.(event); + }); + } + + setTimeout(fn, ms) { return globalThis.setTimeout(fn, ms); } + clearTimeout(id) { globalThis.clearTimeout(id); } + setInterval(fn, ms) { return globalThis.setInterval(fn, ms); } + clearInterval(id) { globalThis.clearInterval(id); } + requestAnimationFrame(fn) { return globalThis.requestAnimationFrame(fn); } + + addEventListener(type, fn) { + if (!this._listeners) this._listeners = {}; + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners?.[type]) { + this._listeners[type] = this._listeners[type].filter(h => h !== fn); + } + } + dispatchEvent(event) { + const handlers = this._listeners?.[event?.type] || []; + for (const h of handlers) { try { h.call(this, event); } catch(e) {} } + return true; + } + + getComputedStyle(el) { return globalThis.getComputedStyle(el); } + matchMedia(q) { return globalThis.matchMedia(q); } + getSelection() { return globalThis.getSelection(); } + fetch(input, init) { return globalThis.fetch(input, init); } + close() { this.closed = true; } + focus() {} + blur() {} +} + +globalThis.__ariaQuerySelector = function(root, selector) { return null; }; +globalThis.__ariaQuerySelectorAll = async function*(root, selector) { /* yields nothing */ }; +class _Canvas2D { + constructor(canvas) { + this.canvas = canvas; + this._w = canvas.width || 300; + this._h = canvas.height || 150; + this._buf = new Uint8ClampedArray(this._w * this._h * 4); + for (let i = 0; i < this._w * this._h; i++) { + this._buf[i*4+0] = 255 + Math.floor(_fpNoise(i % this._w, Math.floor(i / this._w), 0)); + this._buf[i*4+1] = 255 + Math.floor(_fpNoise(i % this._w, Math.floor(i / this._w), 1)); + this._buf[i*4+2] = 255 + Math.floor(_fpNoise(i % this._w, Math.floor(i / this._w), 2)); + this._buf[i*4+3] = 255; + } + this.fillStyle = '#000000'; + this.strokeStyle = '#000000'; + this.lineWidth = 1; + this.font = '10px sans-serif'; + this.textAlign = 'start'; + this.textBaseline = 'alphabetic'; + this.globalAlpha = 1; + this.globalCompositeOperation = 'source-over'; + this._stateStack = []; + } + _parseColor(css) { + if (!css || css === 'none') return [0,0,0,0]; + if (css.startsWith('#')) { + const hex = css.slice(1); + if (hex.length === 3) return [parseInt(hex[0]+hex[0],16),parseInt(hex[1]+hex[1],16),parseInt(hex[2]+hex[2],16),255]; + if (hex.length === 6) return [parseInt(hex.slice(0,2),16),parseInt(hex.slice(2,4),16),parseInt(hex.slice(4,6),16),255]; + if (hex.length === 8) return [parseInt(hex.slice(0,2),16),parseInt(hex.slice(2,4),16),parseInt(hex.slice(4,6),16),parseInt(hex.slice(6,8),16)]; + } + const m = css.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (m) return [+m[1],+m[2],+m[3],m[4]!==undefined?Math.round(+m[4]*255):255]; + const named = {red:[255,0,0,255],green:[0,128,0,255],blue:[0,0,255,255],white:[255,255,255,255],black:[0,0,0,255],yellow:[255,255,0,255],orange:[255,165,0,255],gray:[128,128,128,255],transparent:[0,0,0,0]}; + return named[css] || [0,0,0,255]; + } + _setPixel(x, y, r, g, b, a) { + x = Math.round(x); y = Math.round(y); + if (x < 0 || x >= this._w || y < 0 || y >= this._h) return; + const idx = (y * this._w + x) * 4; + const alpha = (a / 255) * this.globalAlpha; + this._buf[idx+0] = Math.round(r * alpha + this._buf[idx+0] * (1 - alpha)); + this._buf[idx+1] = Math.round(g * alpha + this._buf[idx+1] * (1 - alpha)); + this._buf[idx+2] = Math.round(b * alpha + this._buf[idx+2] * (1 - alpha)); + this._buf[idx+3] = Math.min(255, Math.round(a * alpha + this._buf[idx+3] * (1 - alpha))); + } + fillRect(x, y, w, h) { + const [r,g,b,a] = this._parseColor(this.fillStyle); + x=Math.round(x); y=Math.round(y); w=Math.round(w); h=Math.round(h); + for (let py = Math.max(0,y); py < Math.min(this._h, y+h); py++) { + for (let px = Math.max(0,x); px < Math.min(this._w, x+w); px++) { + this._setPixel(px, py, r, g, b, a); + } + } + } + clearRect(x, y, w, h) { + x=Math.round(x); y=Math.round(y); w=Math.round(w); h=Math.round(h); + for (let py = Math.max(0,y); py < Math.min(this._h, y+h); py++) { + for (let px = Math.max(0,x); px < Math.min(this._w, x+w); px++) { + const idx = (py * this._w + px) * 4; + this._buf[idx] = this._buf[idx+1] = this._buf[idx+2] = this._buf[idx+3] = 0; + } + } + } + strokeRect(x, y, w, h) { + const [r,g,b,a] = this._parseColor(this.strokeStyle); + const lw = this.lineWidth; + for (let px = Math.round(x); px < Math.round(x+w); px++) { + for (let l = 0; l < lw; l++) { this._setPixel(px, Math.round(y)+l, r,g,b,a); this._setPixel(px, Math.round(y+h)-1-l, r,g,b,a); } + } + for (let py = Math.round(y); py < Math.round(y+h); py++) { + for (let l = 0; l < lw; l++) { this._setPixel(Math.round(x)+l, py, r,g,b,a); this._setPixel(Math.round(x+w)-1-l, py, r,g,b,a); } + } + } + fillText(text, x, y) { + const [r,g,b,a] = this._parseColor(this.fillStyle); + const fontSize = parseInt(this.font) || 10; + const scale = Math.max(1, Math.round(fontSize / 10)); + const str = String(text); + let cx = Math.round(x); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + for (let row = 0; row < 7; row++) { + for (let col = 0; col < 5; col++) { + const on = ((_fpRand(code * 100 + row * 10 + col) > 0.45) && + (row > 0 && row < 6 && col > 0 && col < 4)) || + (_fpRand(code * 200 + row * 7 + col) > 0.7); + if (on) { + for (let sy = 0; sy < scale; sy++) { + for (let sx = 0; sx < scale; sx++) { + this._setPixel(cx + col*scale + sx, Math.round(y) - 7*scale + row*scale + sy, r, g, b, a); + } + } + } + } + } + cx += 6 * scale; + } + } + strokeText(text, x, y) { this.fillText(text, x, y); } + measureText(t) { + const fontSize = parseInt(this.font) || 10; + const scale = Math.max(1, Math.round(fontSize / 10)); + return { width: String(t).length * 6 * scale, actualBoundingBoxAscent: 7*scale, actualBoundingBoxDescent: 2*scale }; + } + getImageData(x, y, w, h) { + x=Math.round(x); y=Math.round(y); w=Math.round(w); h=Math.round(h); + const data = new Uint8ClampedArray(w * h * 4); + for (let py = 0; py < h; py++) { + for (let px = 0; px < w; px++) { + const srcX = x + px, srcY = y + py; + const dstIdx = (py * w + px) * 4; + if (srcX >= 0 && srcX < this._w && srcY >= 0 && srcY < this._h) { + const srcIdx = (srcY * this._w + srcX) * 4; + data[dstIdx] = this._buf[srcIdx]; + data[dstIdx+1] = this._buf[srcIdx+1]; + data[dstIdx+2] = this._buf[srcIdx+2]; + data[dstIdx+3] = this._buf[srcIdx+3]; + } + } + } + return { data, width: w, height: h }; + } + putImageData(imageData, dx, dy) { + dx=Math.round(dx); dy=Math.round(dy); + const {data, width: w, height: h} = imageData; + for (let py = 0; py < h; py++) { + for (let px = 0; px < w; px++) { + const srcIdx = (py * w + px) * 4; + const x = dx + px, y = dy + py; + if (x >= 0 && x < this._w && y >= 0 && y < this._h) { + const dstIdx = (y * this._w + x) * 4; + this._buf[dstIdx] = data[srcIdx]; + this._buf[dstIdx+1] = data[srcIdx+1]; + this._buf[dstIdx+2] = data[srcIdx+2]; + this._buf[dstIdx+3] = data[srcIdx+3]; + } + } + } + } + createImageData(w, h) { return { data: new Uint8ClampedArray(w*h*4), width: w, height: h }; } + drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) { + if (img && img._ctx && img._ctx._buf) { + const src = img._ctx; + dx = dx ?? sx; dy = dy ?? sy; dw = dw ?? (sw ?? src._w); dh = dh ?? (sh ?? src._h); + for (let py = 0; py < dh; py++) { + for (let px = 0; px < dw; px++) { + const srcX = Math.floor((sx||0) + px * (sw||src._w) / dw); + const srcY = Math.floor((sy||0) + py * (sh||src._h) / dh); + if (srcX >= 0 && srcX < src._w && srcY >= 0 && srcY < src._h) { + const srcIdx = (srcY * src._w + srcX) * 4; + this._setPixel(dx+px, dy+py, src._buf[srcIdx], src._buf[srcIdx+1], src._buf[srcIdx+2], src._buf[srcIdx+3]); + } + } + } + } + } + beginPath() { this._path = []; } + closePath() {} + moveTo(x, y) { if (this._path) this._path.push({t:'M',x,y}); } + lineTo(x, y) { if (this._path) this._path.push({t:'L',x,y}); } + bezierCurveTo() {} quadraticCurveTo() {} + arc(x, y, r, s, e) { if (this._path) this._path.push({t:'A',x,y,r}); } + arcTo() {} + rect(x, y, w, h) { this.fillRect(x, y, w, h); } + fill() {} + stroke() {} + clip() {} + save() { this._stateStack.push({fillStyle: this.fillStyle, strokeStyle: this.strokeStyle, globalAlpha: this.globalAlpha, font: this.font, lineWidth: this.lineWidth}); } + restore() { const s = this._stateStack.pop(); if (s) Object.assign(this, s); } + translate() {} rotate() {} scale() {} + setTransform() {} resetTransform() {} transform() {} + createLinearGradient(x0,y0,x1,y1) { return { addColorStop(){}, _x0:x0,_y0:y0,_x1:x1,_y1:y1 }; } + createRadialGradient() { return { addColorStop(){} }; } + createPattern() { return {}; } + isPointInPath() { return false; } + isPointInStroke() { return false; } +} + +Element.prototype.getContext = function getContext(type) { + if (type === '2d') { + if (!this._ctx) { + this._ctx = new _Canvas2D(this); + } + return this._ctx; + } + if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') { + return { + canvas: this, + getExtension(name) { + if (name === 'WEBGL_debug_renderer_info') return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }; + return null; + }, + getParameter(pname) { + if (pname === 0x9245) return _fp('gpuVendor'); + if (pname === 0x9246) return _fp('gpu'); + if (pname === 0x1F01) return 'WebKit WebGL'; // GL_RENDERER + if (pname === 0x1F00) return 'WebKit'; // GL_VENDOR + if (pname === 0x1F02) return 'OpenGL ES 3.0 (ANGLE)'; // GL_VERSION + if (pname === 0x8B8C) return 'WebGL GLSL ES 3.00 (ANGLE)'; // GL_SHADING_LANGUAGE_VERSION + return 0; + }, + getSupportedExtensions() { return ['WEBGL_debug_renderer_info','EXT_texture_filter_anisotropic','WEBGL_compressed_texture_s3tc','WEBGL_lose_context']; }, + getShaderPrecisionFormat() { return { rangeMin: 127, rangeMax: 127, precision: 23 }; }, + createBuffer() { return {}; }, createShader() { return {}; }, createProgram() { return {}; }, + shaderSource() {}, compileShader() {}, attachShader() {}, linkProgram() {}, + getProgramParameter() { return true; }, useProgram() {}, deleteShader() {}, + bindBuffer() {}, bufferData() {}, enableVertexAttribArray() {}, vertexAttribPointer() {}, + drawArrays() {}, drawElements() {}, viewport() {}, clear() {}, clearColor() {}, + enable() {}, disable() {}, blendFunc() {}, depthFunc() {}, + getUniformLocation() { return {}; }, getAttribLocation() { return 0; }, + uniform1f() {}, uniform1i() {}, uniformMatrix4fv() {}, + createTexture() { return {}; }, bindTexture() {}, texImage2D() {}, texParameteri() {}, + activeTexture() {}, pixelStorei() {}, generateMipmap() {}, + createFramebuffer() { return {}; }, bindFramebuffer() {}, framebufferTexture2D() {}, + readPixels(x,y,w,h,f,t,d) { if(d) for(let i=0;i>> 0) % 64]; + } + return b64 + '=='; + } + return _fp('canvasFingerprint'); +}; +Element.prototype.toBlob = function(cb, type, q) { cb(new Blob([''])); }; + +_markNative(Element.prototype.getContext); +_markNative(Element.prototype.toDataURL); +_markNative(Element.prototype.toBlob); + +Element.prototype.attachShadow = function attachShadow(opts) { + const host = this; + const children = []; + const shadow = { + mode: opts?.mode || 'open', + host: host, + get innerHTML() { return children.map(c => c.outerHTML || c.textContent || '').join(''); }, + set innerHTML(v) { + children.length = 0; + if (v) { + const tmp = document.createElement('div'); + tmp.innerHTML = v; + for (let i = 0; i < tmp.childNodes.length; i++) children.push(tmp.childNodes[i]); + } + }, + get childNodes() { return children; }, + get firstChild() { return children[0] || null; }, + get lastChild() { return children[children.length - 1] || null; }, + get firstElementChild() { return children.find(c => c.nodeType === 1) || null; }, + get children() { return children.filter(c => c.nodeType === 1); }, + appendChild(c) { if (c) { children.push(c); c.parentNode = shadow; } return c; }, + insertBefore(n, ref) { + if (!n) return n; + if (!ref) { shadow.appendChild(n); return n; } + const idx = children.indexOf(ref); + if (idx >= 0) { children.splice(idx, 0, n); n.parentNode = shadow; } + else shadow.appendChild(n); + return n; + }, + removeChild(c) { const idx = children.indexOf(c); if (idx >= 0) children.splice(idx, 1); return c; }, + replaceChild(n, o) { const idx = children.indexOf(o); if (idx >= 0) { children[idx] = n; n.parentNode = shadow; } return o; }, + querySelector(s) { + for (const c of children) { + if (c.matches && c.matches(s)) return c; + if (c.querySelector) { const r = c.querySelector(s); if (r) return r; } + } + return null; + }, + querySelectorAll(s) { + const results = []; + for (const c of children) { + if (c.matches && c.matches(s)) results.push(c); + if (c.querySelectorAll) results.push(...c.querySelectorAll(s)); + } + return results; + }, + getElementById(id) { return shadow.querySelector('#' + id); }, + contains(n) { return children.includes(n); }, + getRootNode() { return shadow; }, + get ownerDocument() { return document; }, + get nodeType() { return 11; }, // DOCUMENT_FRAGMENT_NODE + get nodeName() { return '#document-fragment'; }, + addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; }, + cloneNode() { return shadow; }, + }; + this.shadowRoot = shadow; + return shadow; +}; + +_markNative(Element.prototype.attachShadow); + +globalThis.AudioContext = class AudioContext { + constructor() { this.sampleRate=_fp('audioSampleRate'); this.state='running'; this.currentTime=0; this.baseLatency=_fp('audioBaseLatency'); this.destination={maxChannelCount:2,numberOfInputs:1,numberOfOutputs:0,channelCount:2}; } + createOscillator() { return {type:'sine',frequency:{value:440,setValueAtTime(){}},connect(){},start(){},stop(){},disconnect(){},addEventListener(){}}; } + createDynamicsCompressor() { return {threshold:{value:_fp('compThreshold')},knee:{value:_fp('compKnee')},ratio:{value:_fp('compRatio')},attack:{value:0.003},release:{value:0.25},reduction:0,connect(){},disconnect(){}}; } + createAnalyser() { + return {fftSize:2048,frequencyBinCount:1024,connect(){},disconnect(){}, + getByteFrequencyData(a){for(let i=0;i { + req.result = { name, version: version||1, objectStoreNames: { contains(){return false;}, length:0 }, createObjectStore(){return {createIndex(){}}; }, transaction(){return {objectStore(){return {get(){return {onsuccess:null,onerror:null};},put(){return {onsuccess:null};},delete(){return {onsuccess:null};}};}}; }, close(){} }; + if (req.onsuccess) req.onsuccess({ target: req }); + }); + return req; + }, + deleteDatabase() { return { onsuccess: null, onerror: null }; }, +}; +globalThis.IDBKeyRange = { only(v){return v;}, lowerBound(v){return v;}, upperBound(v){return v;}, bound(l,u){return [l,u];} }; + +globalThis.caches = { + open() { return Promise.resolve({ match(){return Promise.resolve(undefined);}, put(){return Promise.resolve();}, delete(){return Promise.resolve(false);}, keys(){return Promise.resolve([]);} }); }, + match() { return Promise.resolve(undefined); }, + has() { return Promise.resolve(false); }, + delete() { return Promise.resolve(false); }, + keys() { return Promise.resolve([]); }, +}; + +_markNative(AudioContext); _markNative(OfflineAudioContext); +_markNative(SpeechSynthesisUtterance); +_markNative(MediaStream); _markNative(MediaStreamTrack); +_markNative(RTCPeerConnection); _markNative(RTCSessionDescription); _markNative(RTCIceCandidate); + +const _OrigDateTimeFormat = Intl.DateTimeFormat; +const _defaultTZ = 'America/New_York'; +Intl.DateTimeFormat = function(locales, options) { + if (!options) options = {}; + if (!options.timeZone) options.timeZone = _defaultTZ; + return new _OrigDateTimeFormat(locales, options); +}; +Intl.DateTimeFormat.prototype = _OrigDateTimeFormat.prototype; +Intl.DateTimeFormat.supportedLocalesOf = _OrigDateTimeFormat.supportedLocalesOf; +const _origResolved = _OrigDateTimeFormat.prototype.resolvedOptions; +_OrigDateTimeFormat.prototype.resolvedOptions = function() { + const r = _origResolved.call(this); + if (r.timeZone === 'UTC') r.timeZone = _defaultTZ; + return r; +}; + +if (typeof PointerEvent === 'undefined') { + globalThis.PointerEvent = class PointerEvent extends MouseEvent { + constructor(type, opts={}) { super(type, opts); this.pointerId = opts.pointerId || 0; this.width = opts.width || 1; this.height = opts.height || 1; this.pressure = opts.pressure || 0; this.pointerType = opts.pointerType || 'mouse'; } + }; +} + +if (typeof navigator.credentials === 'undefined') { + navigator.credentials = { get(){return Promise.resolve(null);}, create(){return Promise.resolve(null);}, store(){return Promise.resolve();}, preventSilentAccess(){return Promise.resolve();} }; +} + +globalThis.opener = null; + +globalThis.Worker = class Worker { + constructor(url) { + this.onmessage = null; + this.onerror = null; + this._terminated = false; + this._listeners = {}; + const worker = this; + + if (typeof url === 'string' && (url.startsWith('blob:') || url.startsWith('http'))) { + const blobContent = globalThis.__blobStore?.[url]; + if (blobContent) { + this._code = blobContent; + } else { + (async () => { + try { + const resp = await fetch(url); + worker._code = await resp.text(); + } catch(e) { if (worker.onerror) worker.onerror(e); } + })(); + } + } + } + postMessage(data) { + if (this._terminated) return; + const worker = this; + setTimeout(() => { + if (worker._terminated || !worker._code) return; + try { + const workerSelf = { + onmessage: null, + postMessage: (msg) => { + const evt = { data: msg }; + if (worker.onmessage) worker.onmessage(evt); + const handlers = worker._listeners['message'] || []; + for (const h of handlers) h(evt); + }, + addEventListener: (type, fn) => { workerSelf['on' + type] = fn; }, + close: () => { worker._terminated = true; }, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, + TextDecoder: globalThis.TextDecoder, + atob: globalThis.atob, + btoa: globalThis.btoa, + setTimeout: globalThis.setTimeout, + setInterval: globalThis.setInterval, + clearTimeout: globalThis.clearTimeout, + clearInterval: globalThis.clearInterval, + fetch: globalThis.fetch, + console: globalThis.console, + }; + const fn = new Function('self', 'postMessage', 'addEventListener', 'close', worker._code); + fn(workerSelf, workerSelf.postMessage, workerSelf.addEventListener, workerSelf.close); + if (workerSelf.onmessage) workerSelf.onmessage({ data }); + } catch(e) { + console.error('Worker error:', e.message); + if (worker.onerror) worker.onerror(e); + } + }, 0); + } + terminate() { this._terminated = true; } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(h => h !== fn); + } +}; + +globalThis.__blobStore = globalThis.__blobStore || {}; +const _origCreateObjectURL = URL.createObjectURL; +URL.createObjectURL = function(blob) { + if (blob && typeof blob.text === 'function') { + const id = 'blob:obscura/' + Math.random().toString(36).substring(2); + blob.text().then(text => { globalThis.__blobStore[id] = text; }); + return id; + } + return 'blob:obscura/fallback'; +}; +URL.revokeObjectURL = function(url) { + delete globalThis.__blobStore[url]; +}; + +globalThis.scrollTo = function(x, y) {}; +globalThis.scrollBy = function(x, y) {}; +globalThis.scroll = function(x, y) {}; +globalThis.focus = function() {}; +globalThis.blur = function() {}; +globalThis.print = function() {}; +globalThis.alert = function() {}; +globalThis.confirm = function() { return true; }; +globalThis.prompt = function() { return null; }; +globalThis.open = function() { return null; }; +globalThis.close = function() {}; +globalThis.stop = function() {}; +globalThis.postMessage = function() {}; +globalThis.requestIdleCallback = globalThis.requestIdleCallback || function(cb) { return setTimeout(cb, 0); }; +globalThis.cancelIdleCallback = globalThis.cancelIdleCallback || function(id) { clearTimeout(id); }; +if (typeof ReadableStream === 'undefined') { + globalThis.ReadableStream = class ReadableStream { + constructor(source = {}, strategy = {}) { + this._source = source; this._queue = []; this._closed = false; + this.locked = false; + if (source.start) source.start({ enqueue: (chunk) => this._queue.push(chunk), close: () => { this._closed = true; }, error: () => {} }); + } + getReader() { + this.locked = true; + const stream = this; + return { + read() { + if (stream._queue.length > 0) return Promise.resolve({ value: stream._queue.shift(), done: false }); + if (stream._closed) return Promise.resolve({ value: undefined, done: true }); + return Promise.resolve({ value: undefined, done: true }); + }, + releaseLock() { stream.locked = false; }, + cancel() { stream._closed = true; return Promise.resolve(); }, + get closed() { return stream._closed ? Promise.resolve() : new Promise(() => {}); }, + }; + } + cancel() { this._closed = true; return Promise.resolve(); } + pipeTo(dest) { return Promise.resolve(); } + pipeThrough(transform) { return transform.readable || new ReadableStream(); } + tee() { return [new ReadableStream(), new ReadableStream()]; } + [Symbol.asyncIterator]() { + const reader = this.getReader(); + return { next: () => reader.read(), return: () => { reader.releaseLock(); return Promise.resolve({done:true}); } }; + } + }; +} +if (typeof WritableStream === 'undefined') { + globalThis.WritableStream = class WritableStream { + constructor(sink = {}) { this._sink = sink; this.locked = false; } + getWriter() { + this.locked = true; + const stream = this; + return { + write(chunk) { if (stream._sink.write) stream._sink.write(chunk); return Promise.resolve(); }, + close() { if (stream._sink.close) stream._sink.close(); return Promise.resolve(); }, + abort() { return Promise.resolve(); }, + releaseLock() { stream.locked = false; }, + get ready() { return Promise.resolve(); }, + get closed() { return Promise.resolve(); }, + get desiredSize() { return 1; }, + }; + } + close() { return Promise.resolve(); } + abort() { return Promise.resolve(); } + }; +} +if (typeof TransformStream === 'undefined') { + globalThis.TransformStream = class TransformStream { + constructor(transformer = {}) { + this.readable = new ReadableStream(); + this.writable = new WritableStream(); + } + }; +} + +if (!globalThis.crypto) globalThis.crypto = {}; +if (!globalThis.crypto.subtle) { + globalThis.crypto.subtle = { + async digest(algorithm, data) { + const name = typeof algorithm === 'string' ? algorithm : algorithm?.name || 'SHA-256'; + const bytes = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer || data); + let hash = 0x811c9dc5; + for (let i = 0; i < bytes.length; i++) { hash ^= bytes[i]; hash = Math.imul(hash, 0x01000193); } + const size = name.includes('512') ? 64 : name.includes('384') ? 48 : 32; + const result = new Uint8Array(size); + for (let i = 0; i < size; i++) { hash = Math.imul(hash ^ i, 0x45d9f3b); result[i] = (hash >>> 0) & 0xff; } + return result.buffer; + }, + async encrypt() { throw new DOMException('NotSupportedError'); }, + async decrypt() { throw new DOMException('NotSupportedError'); }, + async sign() { return new ArrayBuffer(32); }, + async verify() { return true; }, + async generateKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, + async importKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, + async exportKey() { return new ArrayBuffer(32); }, + async deriveBits() { return new ArrayBuffer(32); }, + async deriveKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, + async wrapKey() { return new ArrayBuffer(32); }, + async unwrapKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, + }; +} + +if (typeof DOMRect === 'undefined') { + globalThis.DOMRect = class DOMRect { + constructor(x=0,y=0,w=0,h=0) { this.x=x;this.y=y;this.width=w;this.height=h;this.top=y;this.right=x+w;this.bottom=y+h;this.left=x; } + toJSON() { return {x:this.x,y:this.y,width:this.width,height:this.height,top:this.top,right:this.right,bottom:this.bottom,left:this.left}; } + static fromRect(r={}) { return new DOMRect(r.x,r.y,r.width,r.height); } + }; +} +if (typeof DOMPoint === 'undefined') { + globalThis.DOMPoint = class DOMPoint { + constructor(x=0,y=0,z=0,w=1) { this.x=x;this.y=y;this.z=z;this.w=w; } + static fromPoint(p={}) { return new DOMPoint(p.x,p.y,p.z,p.w); } + }; +} +if (typeof DOMMatrix === 'undefined') { + globalThis.DOMMatrix = class DOMMatrix { + constructor() { this.a=1;this.b=0;this.c=0;this.d=1;this.e=0;this.f=0;this.is2D=true;this.isIdentity=true; } + static fromMatrix() { return new DOMMatrix(); } + static fromFloat32Array() { return new DOMMatrix(); } + static fromFloat64Array() { return new DOMMatrix(); } + multiply() { return new DOMMatrix(); } + inverse() { return new DOMMatrix(); } + translate() { return new DOMMatrix(); } + scale() { return new DOMMatrix(); } + rotate() { return new DOMMatrix(); } + transformPoint(p) { return new DOMPoint(p?.x||0,p?.y||0); } + }; +} + +if (typeof Image === 'undefined') { + globalThis.Image = class Image { + constructor(w, h) { this.width = w || 0; this.height = h || 0; this.src = ''; this.onload = null; this.onerror = null; this.complete = false; this.naturalWidth = 0; this.naturalHeight = 0; } + addEventListener() {} removeEventListener() {} + setAttribute(k, v) { this[k] = v; if (k === 'src' && this.onload) setTimeout(() => { this.complete = true; this.onload(); }, 0); } + getAttribute(k) { return this[k]; } + }; +} + +if (typeof Audio === 'undefined') { + globalThis.Audio = class Audio { + constructor(src) { this.src = src || ''; this.paused = true; this.volume = 1; this.currentTime = 0; this.duration = 0; } + play() { return Promise.resolve(); } pause() { this.paused = true; } load() {} + addEventListener() {} removeEventListener() {} + }; +} + +if (typeof FileReader === 'undefined') { + globalThis.FileReader = class FileReader { + constructor() { this.result = null; this.readyState = 0; this.onload = null; this.onerror = null; } + readAsText(blob) { if (blob?.text) blob.text().then(t => { this.result = t; this.readyState = 2; if (this.onload) this.onload({target:this}); }); } + readAsDataURL(blob) { this.result = 'data:;base64,'; this.readyState = 2; if (this.onload) setTimeout(() => this.onload({target:this}), 0); } + readAsArrayBuffer(blob) { this.result = new ArrayBuffer(0); this.readyState = 2; if (this.onload) setTimeout(() => this.onload({target:this}), 0); } + abort() { this.readyState = 0; } + addEventListener(t, fn) { if (t === 'load') this.onload = fn; } + removeEventListener() {} + }; +} + +if (typeof EventSource === 'undefined') { + globalThis.EventSource = class EventSource { + constructor(url) { this.url = url; this.readyState = 0; this.onopen = null; this.onmessage = null; this.onerror = null; } + close() { this.readyState = 2; } + addEventListener() {} removeEventListener() {} + static CONNECTING = 0; static OPEN = 1; static CLOSED = 2; + }; +} + +if (typeof WebSocket === 'undefined') { + globalThis.WebSocket = class WebSocket { + constructor(url, protocols) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null; this.protocol = ''; } + send(data) {} close(code, reason) { this.readyState = 3; if (this.onclose) this.onclose({code:code||1000,reason:reason||'',wasClean:true}); } + addEventListener() {} removeEventListener() {} + static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; + }; +} + +if (typeof BroadcastChannel === 'undefined') { + globalThis.BroadcastChannel = class BroadcastChannel { + constructor(name) { this.name = name; this.onmessage = null; } + postMessage(msg) {} close() {} + addEventListener() {} removeEventListener() {} + }; +} + +if (typeof MediaQueryList === 'undefined') { + globalThis.MediaQueryList = class MediaQueryList { + constructor(q) { this.media = q || ''; this.matches = false; } + addListener() {} removeListener() {} addEventListener() {} removeEventListener() {} + }; +} + +if (typeof ImageData === 'undefined') { + globalThis.ImageData = class ImageData { + constructor(w, h) { + if (w instanceof Uint8ClampedArray) { this.data = w; this.width = h; this.height = w.length / (4 * h); } + else { this.width = w; this.height = h; this.data = new Uint8ClampedArray(w * h * 4); } + } + }; +} + +if (typeof CanvasRenderingContext2D === 'undefined') { + globalThis.CanvasRenderingContext2D = class CanvasRenderingContext2D {}; +} + +if (typeof OffscreenCanvas === 'undefined') { + globalThis.OffscreenCanvas = class OffscreenCanvas { + constructor(w, h) { this.width = w; this.height = h; } + getContext(type) { return globalThis.document?.createElement('canvas')?.getContext(type) || null; } + convertToBlob() { return Promise.resolve(new Blob([''])); } + transferToImageBitmap() { return {}; } + }; +} + +if (typeof Path2D === 'undefined') { + globalThis.Path2D = class Path2D { constructor(){} moveTo(){} lineTo(){} arc(){} rect(){} closePath(){} addPath(){} }; +} + +if (typeof ImageBitmap === 'undefined') { + globalThis.ImageBitmap = class ImageBitmap { constructor(){this.width=0;this.height=0;} close(){} }; + globalThis.createImageBitmap = function() { return Promise.resolve(new ImageBitmap()); }; +} + +if (typeof Selection === 'undefined') { + globalThis.Selection = class Selection { + constructor(){this.anchorNode=null;this.focusNode=null;this.rangeCount=0;this.isCollapsed=true;this.type='None';} + getRangeAt(){return null;} collapse(){} extend(){} selectAllChildren(){} deleteFromDocument(){} + addRange(){} removeRange(){} removeAllRanges(){} toString(){return '';} + }; +} + +if (typeof NodeFilter === 'undefined') { + globalThis.NodeFilter = { SHOW_ALL:0xFFFFFFFF, SHOW_ELEMENT:1, SHOW_TEXT:4, SHOW_COMMENT:128, + FILTER_ACCEPT:1, FILTER_REJECT:2, FILTER_SKIP:3 }; +} + +if (typeof TreeWalker === 'undefined') { + globalThis.TreeWalker = class TreeWalker { + constructor(root){this.root=root;this.currentNode=root;this.whatToShow=0xFFFFFFFF;this.filter=null;} + parentNode(){return this.currentNode?.parentNode||null;} + firstChild(){return this.currentNode?.firstChild||null;} + lastChild(){return this.currentNode?.lastChild||null;} + previousSibling(){return this.currentNode?.previousSibling||null;} + nextSibling(){return this.currentNode?.nextSibling||null;} + nextNode(){return null;} previousNode(){return null;} + }; +} + +if (typeof Range === 'undefined') { + globalThis.Range = class Range { + constructor(){this.startContainer=null;this.startOffset=0;this.endContainer=null;this.endOffset=0;this.collapsed=true;this.commonAncestorContainer=null;} + setStart(n,o){this.startContainer=n;this.startOffset=o;} setEnd(n,o){this.endContainer=n;this.endOffset=o;} + collapse(){} selectNode(){} selectNodeContents(){} cloneContents(){return document?.createDocumentFragment();} + deleteContents(){} insertNode(){} getBoundingClientRect(){return new DOMRect();} + getClientRects(){return [];} cloneRange(){return new Range();} toString(){return '';} + }; +} + +if (typeof SharedWorker === 'undefined') { + globalThis.SharedWorker = class SharedWorker { + constructor() { this.port = { postMessage(){}, onmessage:null, start(){}, close(){}, addEventListener(){}, removeEventListener(){} }; this.onerror = null; } + }; +} +if (typeof ServiceWorkerContainer === 'undefined') { + globalThis.ServiceWorkerContainer = class { register(){return Promise.resolve();} getRegistrations(){return Promise.resolve([]);} }; +} + +if (typeof URLPattern === 'undefined') { + globalThis.URLPattern = class URLPattern { + constructor(pattern){this._pattern=pattern||{};} test(){return false;} exec(){return null;} + }; +} + +if (typeof Document !== 'undefined' && !Document.prototype.importNode) { + Document.prototype.importNode = function(node, deep) { return node?.cloneNode(!!deep) || null; }; +} + +globalThis.__obscura_init = function() { + _fpSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF >>> 0); + _fpCache = null; + + globalThis.document = new Document(+_dom("document_node_id")); + + const scr = _fp('screen'); + const sw = scr[0], sh = scr[1]; + globalThis.screen = { width:sw, height:sh, availWidth:sw, availHeight:sh-40, colorDepth:24, pixelDepth:24, availTop:0, availLeft:0, orientation:{type:"landscape-primary",angle:0,addEventListener(){},removeEventListener(){},dispatchEvent(){return true;}} }; + globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; + globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1; + globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80; + globalThis.outerWidth = sw; globalThis.outerHeight = sh; + + const t0 = Date.now(); + globalThis.performance.timeOrigin = t0; + globalThis.performance.timing = { navigationStart: t0, domContentLoadedEventEnd: t0, loadEventEnd: t0 }; + + const hide = (obj, props) => { + for (const p of props) { + if (p in obj) { + try { Object.defineProperty(obj, p, { enumerable: false, configurable: true }); } catch(e) {} + } + } + }; + const toHide = Object.keys(globalThis).filter(k => + k.startsWith('_') || k.includes('obscura') || k.includes('Obscura') + ); + for (const p of toHide) { + try { Object.defineProperty(globalThis, p, { enumerable: false }); } catch(e) { + } + } + delete globalThis.__obscura_init; +}; diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs new file mode 100644 index 0000000..b538ebd --- /dev/null +++ b/crates/obscura-js/src/lib.rs @@ -0,0 +1,6 @@ +#[macro_use] +extern crate html5ever; + +pub mod module_loader; +pub mod runtime; +pub mod ops; diff --git a/crates/obscura-js/src/module_loader.rs b/crates/obscura-js/src/module_loader.rs new file mode 100644 index 0000000..d8457f2 --- /dev/null +++ b/crates/obscura-js/src/module_loader.rs @@ -0,0 +1,93 @@ +use std::pin::Pin; + +use deno_core::error::ModuleLoaderError; +use deno_core::ModuleLoadResponse; +use deno_core::ModuleLoader; +use deno_core::ModuleSource; +use deno_core::ModuleSourceCode; +use deno_core::ModuleSpecifier; +use deno_core::RequestedModuleType; + +pub struct ObscuraModuleLoader { + pub base_url: String, +} + +impl ObscuraModuleLoader { + pub fn new(base_url: &str) -> Self { + ObscuraModuleLoader { + base_url: base_url.to_string(), + } + } +} + +fn io_err(msg: String) -> ModuleLoaderError { + std::io::Error::new(std::io::ErrorKind::Other, msg).into() +} + +impl ModuleLoader for ObscuraModuleLoader { + fn resolve( + &self, + specifier: &str, + referrer: &str, + _kind: deno_core::ResolutionKind, + ) -> Result { + let base = if referrer.is_empty() + || referrer.starts_with('<') + || referrer == "." + || referrer == "about:blank" + { + &self.base_url + } else { + referrer + }; + + deno_core::resolve_import(specifier, base).map_err(|e| e.into()) + } + + fn load( + &self, + module_specifier: &ModuleSpecifier, + _maybe_referrer: Option<&ModuleSpecifier>, + _is_dyn_import: bool, + _requested_module_type: RequestedModuleType, + ) -> ModuleLoadResponse { + let url = module_specifier.to_string(); + + ModuleLoadResponse::Async(Pin::from(Box::new(async move { + let client = reqwest::Client::builder() + .build() + .map_err(|e| io_err(format!("HTTP client error: {}", e)))?; + + tracing::debug!("Loading ES module: {}", url); + + let resp = client + .get(&url) + .header("Accept", "application/javascript, text/javascript, */*") + .send() + .await + .map_err(|e| io_err(format!("Failed to fetch module {}: {}", url, e)))?; + + if !resp.status().is_success() { + return Err(io_err(format!( + "Module {} returned HTTP {}", + url, + resp.status() + ))); + } + + let code = resp.text().await.map_err(|e| { + io_err(format!("Failed to read module body {}: {}", url, e)) + })?; + + let specifier = ModuleSpecifier::parse(&url) + .map_err(|e| io_err(format!("Invalid module URL {}: {}", url, e)))?; + + Ok(ModuleSource::new( + deno_core::ModuleType::JavaScript, + ModuleSourceCode::String(code.into()), + &specifier, + None, + )) + }))) + } +} diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs new file mode 100644 index 0000000..4edabc1 --- /dev/null +++ b/crates/obscura-js/src/ops.rs @@ -0,0 +1,654 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, OnceLock}; + +use deno_core::op2; +use deno_core::OpState; +use deno_core::Extension; +use obscura_dom::{DomTree, NodeData, NodeId}; +use obscura_net::{CookieJar, ObscuraHttpClient}; +use tokio::sync::Mutex; + +pub type InterceptCallback = Arc Option<(u16, String, String)> + Send + Sync>>>>; + +#[derive(Debug)] +pub enum InterceptResolution { + Continue { + url: Option, + method: Option, + headers: Option>, + body: Option, + }, + Fulfill { + status: u16, + headers: HashMap, + body: String, + }, + Fail { reason: String }, +} + +pub struct InterceptedRequest { + pub request_id: String, + pub url: String, + pub method: String, + pub headers: HashMap, + pub resource_type: String, + pub resolver: tokio::sync::oneshot::Sender, +} + +pub struct ObscuraState { + pub dom: Option, + pub url: String, + pub title: String, + pub blocked_urls: Vec, + pub cookie_jar: Option>, + pub http_client: Option>, + pub pending_navigation: Option<(String, String, String)>, + pub intercept_tx: Option>, + pub intercept_counter: u64, + pub intercept_enabled: bool, +} + +impl ObscuraState { + pub fn new() -> Self { + ObscuraState { + dom: None, + url: "about:blank".to_string(), + title: String::new(), + blocked_urls: Vec::new(), + cookie_jar: None, + http_client: None, + pending_navigation: None, + intercept_tx: None, + intercept_counter: 0, + intercept_enabled: false, + } + } +} + +pub type SharedState = Rc>; + +#[op2] +#[string] +fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[string] arg2: String) -> String { + let gs = state.borrow::().clone(); + let gs = gs.borrow(); + let dom = match &gs.dom { + Some(d) => d, + None => return "null".to_string(), + }; + + match cmd.as_str() { + "document_node_id" => dom.document().index().to_string(), + "document_title" => serde_json::to_string(&gs.title).unwrap_or("\"\"".into()), + "document_url" => serde_json::to_string(&gs.url).unwrap_or("\"\"".into()), + "document_element" => { + for cid in dom.children(dom.document()) { + if let Some(n) = dom.get_node(cid) { + if n.as_element().map(|name| name.local.as_ref() == "html").unwrap_or(false) { + return cid.index().to_string(); + } + } + } + "-1".into() + } + "document_doctype" => { + for cid in dom.children(dom.document()) { + if let Some(n) = dom.get_node(cid) { + if let obscura_dom::NodeData::Doctype { name, public_id, system_id } = &n.data { + return serde_json::json!({ + "name": name, + "publicId": public_id, + "systemId": system_id, + "nodeId": cid.index(), + }).to_string(); + } + } + } + "null".into() + } + "get_element_by_id" => { + dom.get_element_by_id(&arg1).map(|id| id.index().to_string()).unwrap_or("-1".into()) + } + "query_selector" => { + dom.query_selector(&arg1).ok().flatten().map(|id| id.index().to_string()).unwrap_or("-1".into()) + } + "query_selector_all" => { + let ids: Vec = dom.query_selector_all(&arg1).ok() + .map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default(); + serde_json::to_string(&ids).unwrap_or("[]".into()) + } + "node_type" => { + let nid = arg1.parse::().unwrap_or(0); + dom.get_node(NodeId::new(nid)).map(|n| match &n.data { + NodeData::Document => "9", NodeData::Element { .. } => "1", NodeData::Text { .. } => "3", + NodeData::Comment { .. } => "8", NodeData::Doctype { .. } => "10", NodeData::ProcessingInstruction { .. } => "7", + }).unwrap_or("0").into() + } + "node_name" => { + let nid = arg1.parse::().unwrap_or(0); + let name: String = dom.get_node(NodeId::new(nid)).map(|n| match &n.data { + NodeData::Document => "#document".to_string(), NodeData::Element { name, .. } => name.local.as_ref().to_ascii_uppercase(), + NodeData::Text { .. } => "#text".to_string(), NodeData::Comment { .. } => "#comment".to_string(), + NodeData::Doctype { name, .. } => name.clone(), NodeData::ProcessingInstruction { target, .. } => target.clone(), + }).unwrap_or_default(); + serde_json::to_string(&name).unwrap_or("\"\"".into()) + } + "text_content" => { + let nid = arg1.parse::().unwrap_or(0); + serde_json::to_string(&dom.text_content(NodeId::new(nid))).unwrap_or("\"\"".into()) + } + "parent_node" | "first_child" | "last_child" | "next_sibling" | "prev_sibling" => { + let nid = arg1.parse::().unwrap_or(0); + dom.get_node(NodeId::new(nid)).and_then(|n| match cmd.as_str() { + "parent_node" => n.parent, "first_child" => n.first_child, + "last_child" => n.last_child, "next_sibling" => n.next_sibling, + "prev_sibling" => n.prev_sibling, _ => None, + }).map(|id| id.index().to_string()).unwrap_or("-1".into()) + } + "child_nodes" => { + let nid = arg1.parse::().unwrap_or(0); + let ids: Vec = dom.children(NodeId::new(nid)).iter().map(|id| id.index() as i32).collect(); + serde_json::to_string(&ids).unwrap_or("[]".into()) + } + "tag_name" => { + let nid = arg1.parse::().unwrap_or(0); + let name = dom.get_node(NodeId::new(nid)).and_then(|n| n.as_element().map(|name| name.local.as_ref().to_ascii_uppercase())).unwrap_or_default(); + serde_json::to_string(&name).unwrap_or("\"\"".into()) + } + "get_attribute" => { + let nid = arg1.parse::().unwrap_or(0); + let val = dom.get_node(NodeId::new(nid)).and_then(|n| n.get_attribute(&arg2).map(|s| s.to_string())); + serde_json::to_string(&val).unwrap_or("null".into()) + } + "set_attribute" => { + let nid = arg1.parse::().unwrap_or(0); + let node_id = NodeId::new(nid); + if let Some((name, value)) = arg2.split_once('\0') { + if name == "id" { + let old_id = dom.get_node(node_id).and_then(|n| n.get_attribute("id").map(|s| s.to_string())); + dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string())); + dom.update_id_index(node_id, old_id.as_deref(), Some(value)); + } else { + dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string())); + } + } + "true".into() + } + "inner_html" => { + let nid = arg1.parse::().unwrap_or(0); + serde_json::to_string(&dom.inner_html(NodeId::new(nid))).unwrap_or("\"\"".into()) + } + "outer_html" => { + let nid = arg1.parse::().unwrap_or(0); + serde_json::to_string(&dom.outer_html(NodeId::new(nid))).unwrap_or("\"\"".into()) + } + "append_child" => { + let parent = arg1.parse::().unwrap_or(0); + let child = arg2.parse::().unwrap_or(0); + dom.append_child(NodeId::new(parent), NodeId::new(child)); + "true".into() + } + "remove_child" => { + let child = arg1.parse::().unwrap_or(0); + dom.detach(NodeId::new(child)); + "true".into() + } + "insert_before" => { + let new_node = arg1.parse::().unwrap_or(0); + let ref_node = arg2.parse::().unwrap_or(0); + dom.insert_before(NodeId::new(ref_node), NodeId::new(new_node)); + "true".into() + } + "remove_attribute" => { + let nid = arg1.parse::().unwrap_or(0); + dom.with_node_mut(NodeId::new(nid), |n| { + if let NodeData::Element { attrs, .. } = &mut n.data { + attrs.retain(|a| a.name.local.as_ref() != arg2.as_str()); + } + }); + "true".into() + } + "set_inner_html" => { + let nid = arg1.parse::().unwrap_or(0); + let target = NodeId::new(nid); + let children = dom.children(target); + for child in children { + dom.detach(child); + } + if !arg2.is_empty() { + let fragment = obscura_dom::parse_fragment(&arg2); + let import_root = fragment.find_body_or_root(); + dom.import_children_from(target, &fragment, import_root); + } + "true".into() + } + "set_text_content" => { + let nid = arg1.parse::().unwrap_or(0); + dom.with_node_mut(NodeId::new(nid), |n| { + if let NodeData::Text { contents } = &mut n.data { + *contents = arg2.clone(); + } + }); + "true".into() + } + "create_document_fragment" => { + dom.new_node(NodeData::Document).index().to_string() + } + "create_element" => { + dom.new_node(NodeData::Element { + name: html5ever::QualName::new(None, html5ever::ns!(html), html5ever::LocalName::from(arg1.as_str())), + attrs: vec![], template_contents: None, mathml_annotation_xml_integration_point: false, + }).index().to_string() + } + "create_text_node" => { + dom.new_node(NodeData::Text { contents: arg1.clone() }).index().to_string() + } + "element_children" => { + let nid = arg1.parse::().unwrap_or(0); + let ids: Vec = dom.children(NodeId::new(nid)).iter() + .filter(|&&id| dom.get_node(id).map(|n| n.is_element()).unwrap_or(false)) + .map(|id| id.index() as i32).collect(); + serde_json::to_string(&ids).unwrap_or("[]".into()) + } + "has_child_nodes" => { + let nid = arg1.parse::().unwrap_or(0); + dom.get_node(NodeId::new(nid)).map(|n| n.first_child.is_some()).unwrap_or(false).to_string() + } + "contains" => { + let nid = arg1.parse::().unwrap_or(0); + let other = arg2.parse::().unwrap_or(0); + dom.descendants(NodeId::new(nid)).contains(&NodeId::new(other)).to_string() + } + _ => "null".into(), + } +} + +#[op2(fast)] +fn op_console_msg(state: &OpState, #[string] level: &str, #[string] msg: &str) { + let _ = state; + match level { + "warn" => tracing::warn!(target: "obscura::console", "{}", msg), + "error" => tracing::error!(target: "obscura::console", "{}", msg), + _ => tracing::info!(target: "obscura::console", "{}", msg), + } +} + +static SHARED_HTTP_CLIENT: OnceLock = OnceLock::new(); + +fn get_shared_client() -> &'static reqwest::Client { + SHARED_HTTP_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .build() + .expect("failed to build shared reqwest::Client") + }) +} + +#[op2(async)] +#[string] +async fn op_fetch_url( + state: Rc>, + #[string] url: String, + #[string] method: String, + #[string] headers_json: String, + #[string] body: String, + #[string] origin: String, + #[string] mode: String, +) -> Result { + tracing::debug!("op_fetch_url called: {} {} (intercept check pending)", method, url); + + if let Ok(parsed_url) = url::Url::parse(&url) { + if let Err(e) = validate_fetch_url(&parsed_url) { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": url, + "headers": {}, + "blocked": true, + "error": e, + }).to_string()); + } + } + + let (cookie_jar, in_flight, intercept_tx) = { + let state_borrow = state.borrow(); + let gs = state_borrow.borrow::().clone(); + let mut gs = gs.borrow_mut(); + for pattern in &gs.blocked_urls { + if pattern == "*" || url.contains(pattern) || glob_match(pattern, &url) { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": url, + "headers": {}, + "blocked": true, + }).to_string()); + } + } + let jar = gs.cookie_jar.clone(); + let in_flight = gs.http_client.as_ref().map(|c| c.in_flight.clone()); + tracing::debug!("op_fetch_url: intercept_enabled={}, has_tx={}", gs.intercept_enabled, gs.intercept_tx.is_some()); + let itx = if gs.intercept_enabled { + gs.intercept_counter += 1; + gs.intercept_tx.clone().map(|tx| (tx, format!("intercept-{}", gs.intercept_counter))) + } else { + None + }; + (jar, in_flight, itx) + }; + + if let Some((tx, request_id)) = intercept_tx { + let custom_headers: HashMap = serde_json::from_str(&headers_json).unwrap_or_default(); + let (resolve_tx, resolve_rx) = tokio::sync::oneshot::channel(); + let intercepted = InterceptedRequest { + request_id: request_id.clone(), + url: url.clone(), + method: method.clone(), + headers: custom_headers.clone(), + resource_type: "Fetch".to_string(), + resolver: resolve_tx, + }; + if tx.send(intercepted).is_ok() { + match resolve_rx.await { + Ok(InterceptResolution::Fulfill { status, headers: h, body: b }) => { + let resp_headers: HashMap = h; + return Ok(serde_json::json!({ + "status": status, + "body": b, + "url": url, + "headers": resp_headers, + }).to_string()); + } + Ok(InterceptResolution::Fail { reason }) => { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": url, + "headers": {}, + "blocked": true, + "error": reason, + }).to_string()); + } + Ok(InterceptResolution::Continue { url: new_url, method: new_method, headers: new_headers, body: new_body }) => { + tracing::debug!("Interception: continue request {}", url); + } + Err(_) => { + } + } + } + } + + let client = get_shared_client(); + + let request_origin = url::Url::parse(&url) + .ok() + .map(|u| { + let host = u.host_str().unwrap_or(""); + match u.port() { + Some(p) => format!("{}://{}:{}", u.scheme(), host, p), + None => format!("{}://{}", u.scheme(), host), + } + }) + .unwrap_or_default(); + let page_origin = if origin.is_empty() { request_origin.clone() } else { origin.clone() }; + let is_cross_origin = !page_origin.is_empty() && request_origin != page_origin; + + let req_method: reqwest::Method = method.parse().unwrap_or(reqwest::Method::GET); + + let custom_headers: std::collections::HashMap = + serde_json::from_str(&headers_json).unwrap_or_default(); + + let needs_preflight = is_cross_origin + && mode == "cors" + && (req_method != reqwest::Method::GET + && req_method != reqwest::Method::HEAD + && req_method != reqwest::Method::POST + || custom_headers.keys().any(|k| { + let kl = k.to_lowercase(); + kl != "accept" && kl != "accept-language" && kl != "content-language" + && kl != "content-type" + })); + + if needs_preflight { + let preflight = client + .request(reqwest::Method::OPTIONS, &url) + .header("Origin", &page_origin) + .header("Access-Control-Request-Method", method.as_str()) + .header( + "Access-Control-Request-Headers", + custom_headers.keys().cloned().collect::>().join(", "), + ) + .send() + .await + .map_err(|e| deno_error::JsErrorBox::generic(format!("CORS preflight failed: {}", e)))?; + + let allowed_origin = preflight + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if allowed_origin != "*" && allowed_origin != page_origin { + return Err(deno_error::JsErrorBox::generic(format!( + "CORS preflight: Origin '{}' not allowed by Access-Control-Allow-Origin '{}'", + page_origin, allowed_origin + ))); + } + } + + let mut req = client.request(req_method, &url); + + if is_cross_origin { + req = req.header("Origin", &page_origin); + } + + if !is_cross_origin { + if let Some(ref jar) = cookie_jar { + if let Ok(parsed_url) = url::Url::parse(&url) { + let cookie_header = jar.get_cookie_header(&parsed_url); + if !cookie_header.is_empty() { + req = req.header("Cookie", &cookie_header); + } + } + } + } + + for (k, v) in &custom_headers { + req = req.header(k.as_str(), v.as_str()); + } + + if !body.is_empty() { + req = req.body(body); + } + + if let Some(ref counter) = in_flight { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + let response = req + .send() + .await + .map_err(|e| { + if let Some(ref counter) = in_flight { + counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } + deno_error::JsErrorBox::generic(e.to_string()) + })?; + + if let Some(ref counter) = in_flight { + counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } + + let status = response.status().as_u16(); + + if let Some(ref jar) = cookie_jar { + if let Ok(parsed_url) = url::Url::parse(&url) { + for val in response.headers().get_all(reqwest::header::SET_COOKIE) { + if let Ok(s) = val.to_str() { + jar.set_cookie(s, &parsed_url); + } + } + } + } + + let resp_headers: std::collections::HashMap = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + if is_cross_origin && mode == "cors" { + let allowed = resp_headers + .get("access-control-allow-origin") + .map(|s| s.as_str()) + .unwrap_or(""); + + if allowed != "*" && allowed != page_origin { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": url, + "headers": {}, + "corsBlocked": true, + "corsError": format!("CORS error: Origin '{}' not in Access-Control-Allow-Origin '{}'", page_origin, allowed), + }) + .to_string()); + } + } + + let resp_body = response + .text() + .await + .map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?; + + tracing::debug!("op_fetch_url completed: {} {} ({} bytes)", method, url, resp_body.len()); + + Ok(serde_json::json!({ + "status": status, + "body": resp_body, + "url": url, + "headers": resp_headers, + }) + .to_string()) +} + +fn glob_match(pattern: &str, url: &str) -> bool { + if pattern == "*" { + return true; + } + if pattern.starts_with('*') && pattern.ends_with('*') { + return url.contains(&pattern[1..pattern.len() - 1]); + } + if pattern.starts_with('*') { + return url.ends_with(&pattern[1..]); + } + if pattern.ends_with('*') { + return url.starts_with(&pattern[..pattern.len() - 1]); + } + url == pattern +} + +fn validate_fetch_url(url: &url::Url) -> Result<(), String> { + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(format!( + "Forbidden URL scheme '{}' - only http and https are allowed", + scheme + )); + } + + if let Some(host) = url.host() { + match host { + url::Host::Ipv4(ip) => { + if ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + { + return Err(format!( + "Access to private/internal IP address {} is not allowed", + ip + )); + } + } + url::Host::Ipv6(ip) => { + if ip.is_loopback() || ip.is_unicast_link_local() { + return Err(format!( + "Access to private/internal IPv6 address {} is not allowed", + ip + )); + } + } + url::Host::Domain(domain) => { + let lower_domain = domain.to_lowercase(); + if lower_domain == "localhost" + || lower_domain.ends_with(".localhost") + || lower_domain == "127.0.0.1" + || lower_domain == "::1" + { + return Err(format!( + "Access to localhost domain '{}' is not allowed", + domain + )); + } + } + } + } + + Ok(()) +} + +#[op2] +#[string] +fn op_get_cookies(state: &OpState) -> String { + let gs = state.borrow::().clone(); + let gs = gs.borrow(); + let jar = match &gs.cookie_jar { + Some(j) => j, + None => return String::new(), + }; + let url = match url::Url::parse(&gs.url) { + Ok(u) => u, + Err(_) => return String::new(), + }; + jar.get_js_visible_cookies(&url) +} + +#[op2(fast)] +fn op_set_cookie(state: &OpState, #[string] cookie_str: &str) { + let gs = state.borrow::().clone(); + let gs = gs.borrow(); + let jar = match &gs.cookie_jar { + Some(j) => j, + None => return, + }; + let url = match url::Url::parse(&gs.url) { + Ok(u) => u, + Err(_) => return, + }; + jar.set_cookie_from_js(cookie_str, &url); +} + +#[op2(fast)] +fn op_navigate(state: &OpState, #[string] url: &str, #[string] method: &str, #[string] body: &str) { + let gs = state.borrow::().clone(); + let mut gs = gs.borrow_mut(); + gs.pending_navigation = Some((url.to_string(), method.to_string(), body.to_string())); +} + +pub fn build_extension() -> Extension { + Extension { + name: "obscura_dom", + ops: std::borrow::Cow::Owned(vec![ + op_dom(), + op_console_msg(), + op_fetch_url(), + op_get_cookies(), + op_set_cookie(), + op_navigate(), + ]), + ..Default::default() + } +} diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs new file mode 100644 index 0000000..4ffb33d --- /dev/null +++ b/crates/obscura-js/src/runtime.rs @@ -0,0 +1,1322 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use deno_core::{JsRuntime, RuntimeOptions}; +use obscura_dom::DomTree; + +use crate::module_loader::ObscuraModuleLoader; +use crate::ops::{build_extension, ObscuraState}; + +static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); + +const BOOTSTRAP_JS: &str = include_str!("../js/bootstrap.js"); + +#[derive(Debug, Clone)] +pub struct RemoteObjectInfo { + pub js_type: String, + pub subtype: Option, + pub class_name: String, + pub description: String, + pub object_id: Option, + pub value: Option, +} + +pub struct ObscuraJsRuntime { + runtime: JsRuntime, + state: Rc>, + object_store: HashMap, + object_counter: u64, +} + +impl ObscuraJsRuntime { + pub fn new() -> Self { + Self::with_base_url("about:blank") + } + + pub fn with_base_url(base_url: &str) -> Self { + let state = Rc::new(RefCell::new(ObscuraState::new())); + let state_clone = state.clone(); + + let module_loader = Rc::new(ObscuraModuleLoader::new(base_url)); + + let mut runtime = JsRuntime::new(RuntimeOptions { + extensions: vec![build_extension()], + module_loader: Some(module_loader), + startup_snapshot: Some(SNAPSHOT), + ..Default::default() + }); + + runtime.op_state().borrow_mut().put(state_clone); + + runtime + .execute_script( + "", + "globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; globalThis.__obscura_init();".to_string(), + ) + .expect("init should not fail"); + + ObscuraJsRuntime { + runtime, + state, + object_store: HashMap::new(), + object_counter: 0, + } + } + + pub fn set_cookie_jar(&self, jar: std::sync::Arc) { + self.state.borrow_mut().cookie_jar = Some(jar); + } + + pub fn set_http_client(&self, client: std::sync::Arc) { + self.state.borrow_mut().http_client = Some(client); + } + + pub fn set_dom(&self, dom: DomTree) { + self.state.borrow_mut().dom = Some(dom); + } + + pub fn set_url(&self, url: &str) { + self.state.borrow_mut().url = url.to_string(); + } + + pub fn set_title(&self, title: &str) { + self.state.borrow_mut().title = title.to_string(); + } + + pub fn set_blocked_urls(&self, patterns: Vec) { + self.state.borrow_mut().blocked_urls = patterns; + } + + pub fn take_pending_navigation(&self) -> Option<(String, String, String)> { + self.state.borrow_mut().pending_navigation.take() + } + + pub fn set_intercept_tx(&self, tx: tokio::sync::mpsc::UnboundedSender) { + let mut state = self.state.borrow_mut(); + state.intercept_tx = Some(tx); + state.intercept_enabled = true; + } + + pub fn set_user_agent(&mut self, ua: &str) { + let escaped = ua.replace('\\', "\\\\").replace('\'', "\\'"); + let _ = self.runtime.execute_script( + "", + format!("globalThis.__obscura_ua = '{}';", escaped), + ); + } + pub fn evaluate(&mut self, expression: &str) -> Result { + let wrapped = Self::wrap_expression(expression); + let result = self + .runtime + .execute_script("", wrapped) + .map_err(|e| format!("JS error: {}", e))?; + self.v8_to_json(result) + } + + pub fn evaluate_for_cdp( + &mut self, + expression: &str, + return_by_value: bool, + ) -> Result { + if return_by_value { + let val = self.evaluate(expression)?; + return Ok(Self::info_from_json(&val)); + } + + self.object_counter += 1; + let oid = self.make_oid(self.object_counter); + + let meta_code = format!( + "(function() {{\n\ + var __result;\n\ + try {{ __result = ({expr}); }} catch(e) {{ __result = undefined; }}\n\ + globalThis.__obscura_objects['{oid}'] = __result;\n\ + return {meta_fn};\n\ + }})()", + expr = expression, + oid = oid, + meta_fn = Self::meta_extract_js("__result"), + ); + + let result = self + .runtime + .execute_script("", meta_code) + .map_err(|e| format!("JS error: {}", e))?; + + let meta_str = self.v8_to_json(result)?; + let meta_json = if let serde_json::Value::String(s) = &meta_str { + serde_json::from_str(s).unwrap_or(meta_str) + } else { + meta_str + }; + self.object_store.insert( + oid.clone(), + format!("globalThis.__obscura_objects['{}']", oid), + ); + + Ok(Self::info_from_meta(&meta_json, Some(oid))) + } + + pub async fn call_function_on_for_cdp( + &mut self, + function_declaration: &str, + object_id: Option<&str>, + arguments: &[serde_json::Value], + return_by_value: bool, + await_promise: bool, + ) -> Result { + let this_expr = self.resolve_this(object_id); + let (setup, args_list) = self.build_args(arguments); + + self.object_counter += 1; + let oid = self.make_oid(self.object_counter); + + if await_promise { + let code = format!( + "(async function() {{\n\ + {setup}\n\ + var __fn = ({fn_decl});\n\ + var __this = ({this_expr});\n\ + var __result = await __fn.call(__this, {args});\n\ + globalThis.__obscura_objects['{oid}'] = __result;\n\ + globalThis.__obscura_await_meta = {meta_fn};\n\ + }})()", + setup = setup, + fn_decl = function_declaration, + this_expr = this_expr, + args = args_list, + oid = oid, + meta_fn = Self::meta_extract_js("__result"), + ); + + self.runtime + .execute_script("", code) + .map_err(|e| format!("JS error: {}", e))?; + + self.resolve_promises().await; + + if return_by_value { + let read = self.runtime.execute_script( + "", + format!("globalThis.__obscura_objects['{}']", oid), + ).map_err(|e| format!("JS error: {}", e))?; + let json_val = self.v8_to_json(read)?; + return Ok(Self::info_from_json(&json_val)); + } + + let meta_result = self.runtime.execute_script( + "", + "globalThis.__obscura_await_meta".to_string(), + ).map_err(|e| format!("JS error: {}", e))?; + let meta_str = self.v8_to_json(meta_result)?; + let meta_json = if let serde_json::Value::String(s) = &meta_str { + serde_json::from_str(s).unwrap_or(meta_str.clone()) + } else { + meta_str + }; + self.object_store.insert( + oid.clone(), + format!("globalThis.__obscura_objects['{}']", oid), + ); + return Ok(Self::info_from_meta(&meta_json, Some(oid))); + } + + if return_by_value { + let code = format!( + "(function() {{\n\ + {setup}\n\ + var __fn = ({fn_decl});\n\ + var __this = ({this_expr});\n\ + return __fn.call(__this, {args});\n\ + }})()", + setup = setup, + fn_decl = function_declaration, + this_expr = this_expr, + args = args_list, + ); + let result = self.runtime + .execute_script("", code) + .map_err(|e| format!("JS error: {}", e))?; + let json_val = self.v8_to_json(result)?; + return Ok(Self::info_from_json(&json_val)); + } + + let code = format!( + "(function() {{\n\ + {setup}\n\ + var __fn = ({fn_decl});\n\ + var __this = ({this_expr});\n\ + var __result = __fn.call(__this, {args});\n\ + globalThis.__obscura_objects['{oid}'] = __result;\n\ + return {meta_fn};\n\ + }})()", + setup = setup, + fn_decl = function_declaration, + this_expr = this_expr, + args = args_list, + oid = oid, + meta_fn = Self::meta_extract_js("__result"), + ); + let result = self.runtime + .execute_script("", code) + .map_err(|e| format!("JS error: {}", e))?; + let meta_str = self.v8_to_json(result)?; + let meta_json = if let serde_json::Value::String(s) = &meta_str { + serde_json::from_str(s).unwrap_or(meta_str.clone()) + } else { + meta_str + }; + self.object_store.insert( + oid.clone(), + format!("globalThis.__obscura_objects['{}']", oid), + ); + Ok(Self::info_from_meta(&meta_json, Some(oid))) + } + pub async fn call_function_on( + &mut self, + function_declaration: &str, + object_id: Option<&str>, + arguments: &[serde_json::Value], + return_by_value: bool, + ) -> Result { + self.call_function_on_for_cdp(function_declaration, object_id, arguments, return_by_value, false).await + } + pub fn store_object(&mut self, js_expression: &str) -> Result { + self.object_counter += 1; + let oid = self.make_oid(self.object_counter); + let code = format!( + "globalThis.__obscura_objects['{}'] = ({});", + oid, js_expression, + ); + self.runtime + .execute_script("", code) + .map_err(|e| format!("Store error: {}", e))?; + self.object_store.insert( + oid.clone(), + format!("globalThis.__obscura_objects['{}']", oid), + ); + Ok(oid) + } + + pub fn store_object_with_meta( + &mut self, + js_expression: &str, + ) -> Result { + self.object_counter += 1; + let oid = self.make_oid(self.object_counter); + let code = format!( + "(function() {{\n\ + var __result = ({expr});\n\ + globalThis.__obscura_objects['{oid}'] = __result;\n\ + return {meta_fn};\n\ + }})()", + expr = js_expression, + oid = oid, + meta_fn = Self::meta_extract_js("__result"), + ); + let result = self + .runtime + .execute_script("", code) + .map_err(|e| format!("Store error: {}", e))?; + let meta_str = self.v8_to_json(result)?; + let meta_json = if let serde_json::Value::String(s) = &meta_str { + serde_json::from_str(s).unwrap_or(meta_str.clone()) + } else { + meta_str + }; + self.object_store.insert( + oid.clone(), + format!("globalThis.__obscura_objects['{}']", oid), + ); + Ok(Self::info_from_meta(&meta_json, Some(oid))) + } + + pub fn release_object(&mut self, object_id: &str) { + if self.object_store.remove(object_id).is_some() { + let code = format!( + "delete globalThis.__obscura_objects['{}'];", + object_id, + ); + let _ = self.runtime.execute_script("", code); + } + } + + pub fn release_object_group(&mut self) { + let _ = self.runtime.execute_script( + "", + "globalThis.__obscura_objects = {};".to_string(), + ); + self.object_store.clear(); + } + pub async fn load_module(&mut self, url: &str) -> Result<(), String> { + let specifier = deno_core::ModuleSpecifier::parse(url) + .map_err(|e| format!("Invalid module URL {}: {}", url, e))?; + + let module_id = self + .runtime + .load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from_static("")) + .await + .map_err(|e| format!("Module load error: {}", e))?; + + let result = self.runtime.mod_evaluate(module_id); + + let timeout = tokio::time::timeout( + tokio::time::Duration::from_secs(10), + self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), + ).await; + + match timeout { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), + Err(_) => { + tracing::warn!("Module evaluation timed out after 10s: {}", url); + return Ok(()); + } + } + + match result.await { + Ok(()) => Ok(()), + Err(e) => { + tracing::warn!("Module eval error: {}", e); + Ok(()) + } + } + } + + pub async fn load_inline_module(&mut self, code: &str, base_url: &str) -> Result<(), String> { + let specifier = deno_core::ModuleSpecifier::parse( + &format!("{}#inline-module-{}", base_url, self.object_counter), + ) + .unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap()); + + self.object_counter += 1; + + let module_id = self + .runtime + .load_side_es_module_from_code( + &specifier, + deno_core::ModuleCodeString::from(code.to_string()), + ) + .await + .map_err(|e| format!("Inline module load error: {}", e))?; + + let result = self.runtime.mod_evaluate(module_id); + + let timeout = tokio::time::timeout( + tokio::time::Duration::from_secs(10), + self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), + ).await; + + match timeout { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), + Err(_) => { + tracing::warn!("Inline module timed out after 10s"); + return Ok(()); + } + } + + match result.await { + Ok(()) => Ok(()), + Err(e) => { + tracing::warn!("Inline module eval error: {}", e); + Ok(()) + } + } + } + + pub fn execute_script(&mut self, _name: &str, source: &str) -> Result<(), String> { + self.runtime + .execute_script("