diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5592118 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..446429b --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,57 @@ +name: Verify and package +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - name: Install pinned Rust and build tools + run: | + rustup toolchain install 1.99.1 --profile minimal --component clippy,rustfmt --target wasm32-unknown-unknown + curl -fsSL https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl.tar.gz -o /tmp/bindgen.tar.gz + tar -xzf /tmp/bindgen.tar.gz -C /tmp + echo /tmp/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl >> "$GITHUB_PATH" + curl -fsSL https://github.com/WebAssembly/binaryen/releases/download/version_133/binaryen-version_133-x86_64-linux.tar.gz -o /tmp/binaryen.tar.gz + tar -xzf /tmp/binaryen.tar.gz -C /tmp + echo /tmp/binaryen-version_133/bin >> "$GITHUB_PATH" + - name: Rust validation + run: | + cargo fmt --all -- --check + cargo xtask test + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo clippy --target wasm32-unknown-unknown -p limitless-runtime -p limitless-ui --locked -- -D warnings + - name: Build validated browser packages and source archive + run: cargo xtask package + - name: Install browser harness + run: | + python3 -m pip install -r tests/browser/requirements.txt + python3 -m playwright install --with-deps chromium firefox + - name: Chromium extension integration + run: | + python3 tests/browser/smoke.py + python3 tests/browser/inline.py + python3 tests/browser/inline_contracts.py + python3 tests/browser/native_fidelity.py + python3 tests/browser/lifecycle.py + python3 tests/browser/infinite_scroll.py + python3 tests/browser/identity_recovery.py + python3 tests/browser/review_regressions.py + python3 tests/browser/media_player.py + - name: Firefox extension integration + run: | + curl -fsSL https://github.com/mozilla/geckodriver/releases/download/v0.37.1/geckodriver-v0.37.1-linux64.tar.gz -o /tmp/geckodriver.tar.gz + tar -xzf /tmp/geckodriver.tar.gz -C /tmp + export GECKODRIVER=/tmp/geckodriver + python3 tests/browser/firefox_smoke.py + python3 tests/browser/firefox_inline.py + - uses: actions/upload-artifact@v4 + with: + name: limitlessx-development-packages + path: dist/releases/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52cf420 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target/ +/dist/ +.superpowers/ +__pycache__/ +/tests/browser/artifacts/ + +/node_modules/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6f5880a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2024 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.21", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "attribute-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167" +dependencies = [ + "attribute-derive-macro", + "manyhow", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 3.0.9", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "config" +version = "0.15.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +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 = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[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 = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasm-bindgen", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "html-escape" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hydration_context" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285b05858fe7d58c5eff059f7b46c228fd86996d996c59eb35fc0340d174f640" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idb" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828" +dependencies = [ + "indexmap", + "js-sys", + "num-traits", + "thiserror 2.0.21", + "tokio", + "wasm-bindgen", + "web-sys", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leptos" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713ee6895e3d1126ea9781280d01414f5643f3f201a949b71efffac07d735f00" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.21", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.21", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d84989bd3d604e66adbfe13a32bec2ea47bdcb6c9fcd643f784facbb32f6ef10" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn 3.0.9", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0902af1e6947ffc7e906b7003a165f76f178dc711a47b74241025090b0c32a72" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn 3.0.9", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202b2d1261c22d886f307934760a6bb566444f68f39b24f6f10defdc23633049" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "limitless-core" +version = "0.1.0" +dependencies = [ + "serde", + "sha2", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "limitless-fxtwitter" +version = "0.1.0" +dependencies = [ + "limitless-core", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "limitless-runtime" +version = "0.1.0" +dependencies = [ + "futures", + "js-sys", + "limitless-core", + "limitless-fxtwitter", + "limitless-storage", + "serde", + "serde-wasm-bindgen", + "serde_json", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "limitless-storage" +version = "0.1.0" +dependencies = [ + "js-sys", + "limitless-core", + "rexie", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] +name = "limitless-ui" +version = "0.1.0" +dependencies = [ + "js-sys", + "leptos", + "limitless-core", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "manyhow" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "manyhow-macros" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48356e493823dc3c1d7e9fc7b3ff204ecec48afbdd718fc4134091b88eb63f3d" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf1c5de6a7cc231c71e03c77aa4c7019097859ea7029156b54d2894a8430704" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reactive_graph" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcec36a2d1b9a53a4129b3270b19e3df6f6b88c2e7c97ed9404dc9307f137d7" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.21", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a29e469b556f25486678e9f5f65fa21a0351d98affde9a7eea9fee286bf8ebd6" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d11f1b82859d94577892b8037c35f8a4f066cbb3864583e22e68def0af49a88" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rexie" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887466cfa8a12c08ee4b174998135cea8ff0fd84858627cd793e56535a045bc9" +dependencies = [ + "idb", + "thiserror 1.0.69", + "wasm-bindgen", +] + +[[package]] +name = "rstml" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6bd1bfba3c4c0b57aae906204d9e5744f3f65800ce6c75bd4cc162f2c26f3aa" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 3.0.9", + "thiserror 2.0.21", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.21", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce79d7b6ab93f7994e5ff4999eb962f2aea370d7c3b91986eb75da738a8e65f9" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 3.0.9", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a4f81a0d855f279c3448243138a55cbfdd9851da9341b9c1e5971b90089e61" +dependencies = [ + "server_fn_macro", + "syn 3.0.9", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tachys" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47e2220bb9d2a3be976a22a0fb7685a0b84499bb11ea5cb3bbc82907675ac68" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[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 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn 2.0.119", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", + "zip", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0496417 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] +members = ["crates/core", "crates/fxtwitter", "crates/storage", "crates/runtime", "crates/ui", "crates/xtask"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +rust-version = "1.90" + +[workspace.dependencies] +limitless-core = { path = "crates/core" } +limitless-fxtwitter = { path = "crates/fxtwitter" } +limitless-storage = { path = "crates/storage" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +url = "2" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde-wasm-bindgen = "0.6" +js-sys = "0.3" +web-sys = "0.3" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..37858de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LimitlessX contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a534e9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +
+ + + LimitlessX + + +# LimitlessX + +### No VPN. No proxy setup. No DPI hacks. + +Read region-restricted public X content directly on X. + +[![Checks](https://github.com/OnurByte/LimitlessX/actions/workflows/check.yml/badge.svg)](https://github.com/OnurByte/LimitlessX/actions/workflows/check.yml) +[![MIT license](https://img.shields.io/badge/license-MIT-black)](LICENSE) +[![Open source](https://img.shields.io/badge/open-source-black?logo=github)](https://github.com/OnurByte/LimitlessX) + +[![Chromium](https://img.shields.io/badge/Chromium-development_build-4285F4?logo=googlechrome&logoColor=white)](#install) +[![Firefox](https://img.shields.io/badge/Firefox-development_build-FF7139?logo=firefoxbrowser&logoColor=white)](#install) +[![Helium](https://img.shields.io/badge/Helium-locally_verified-black)](docs/compatibility.md) + +
+ +LimitlessX detects supported country-withholding notices and displays available public profiles, posts and replies inside X. It uses FxTwitter as an alternative public source, with no X login or separate LimitlessX account. + +**Open source, MIT licensed.** Rust owns fetching, validation, sessions and storage. TypeScript connects those components to the browser. All executable code ships in the extension. There is no LimitlessX backend, analytics or remote executable code. + +## What it does + +- Automatically replaces a supported regional-withholding notice with available public content. +- Shows five posts first, then loads more as you scroll. Existing videos keep playing during feed expansion. +- Displays profile details, media, quoted posts and available conversation replies in X's visual style. +- Resolves exact public account identities when a username lookup fails, without confusing similarly named accounts. +- Keeps unique local counts of successfully viewed restricted accounts and posts. Disable or clear them in the popup. +- Uses one popup for manual URLs, current-page actions and settings. No extension reader page or iframe. + +## Install + +**Development builds. Not published in browser stores yet.** These badges describe build targets and test evidence, not store availability. + +Build the project below, then load the generated package: + +| Browser | Local installation | Verification | +|---|---|---| +| Chromium | Open the Extensions page, enable Developer mode, choose **Load unpacked**, select `dist/chromium` | Packaged extension integration tests | +| Helium | Use `chrome://extensions`, enable Developer mode and load `dist/chromium` | Actual X session tested locally | +| Firefox | Open `about:debugging`, choose **This Firefox**, **Load Temporary Add-on**, select `dist/firefox/manifest.json` | Packaged extension integration tests | + +Firefox temporary add-ons disappear when the browser closes. Permanent Firefox distribution requires signing. Other Chromium/Firefox variants are intended targets; see [individual browser evidence](docs/compatibility.md) before assuming compatibility. + +On a supported blocked X profile or post, reading starts automatically. The popup also accepts an `@handle` or an X profile/post URL. + +## Privacy and limits + +The public provider receives requested identifiers and network information, including your IP address. Media loads from X media hosts. Requests omit credentials; the extension does not read X passwords, authentication cookies or private messages. This is not an anonymity tool. + +The Record stores unique salted identifier hashes in local IndexedDB. It does not store post bodies or plaintext browsing history. Failed requests and ordinary manual lookups do not increase restricted-content totals. + +Availability depends on the provider and public source. Private content is unavailable. Timelines, replies and metadata can be incomplete; this is a focused reader, not a complete authenticated X client. Identity recovery fixes a known username-lookup failure, not every possible provider outage. Read the [privacy details](docs/privacy.md) and [release audit](docs/verification/release-audit.md). + +## Build + +Requirements: Node.js 22+, npm, rustup, Python for browser tests, `wasm-bindgen` 0.2.129 and Binaryen `version_133`. Rust 1.99.1 and the WASM target are pinned in `rust-toolchain.toml`. + +```sh +npm ci --ignore-scripts +cargo install wasm-bindgen-cli --version 0.2.129 --locked +# Install Binaryen version_133 and place wasm-opt on PATH. +cargo xtask build chromium +cargo xtask build firefox +cargo xtask test +cargo xtask package +``` + +If needed, set `WASM_BINDGEN` and `WASM_OPT` to their executable paths. `cargo xtask` typechecks and compiles the handwritten TypeScript adapters; application logic remains in Rust. Packages and a reviewable source archive are written to `dist/releases`. + +## Verify and contribute + +```sh +npm run typecheck +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo clippy --target wasm32-unknown-unknown -p limitless-runtime -p limitless-ui --locked -- -D warnings +python3 -m pip install -r tests/browser/requirements.txt +python3 -m playwright install chromium firefox +``` + +The [CI workflow](.github/workflows/check.yml) runs native checks, builds both packages and exercises the installed extensions. Browser fixtures cover malformed responses, identity recovery, navigation, pagination, counters and retry behavior. Firefox's inline test also uses a live public provider response; that dependency is disclosed in its output. + +Issues and pull requests are welcome. Keep permissions narrow, executable code local, provider text escaped and public/private boundaries explicit. Include a regression test for behavioral fixes. See [architecture](docs/architecture.md), [design](docs/brand.md) and [browser tests](tests/browser). + +Built with Rust, WebAssembly, Leptos and TypeScript. Public data via [FxTwitter / FxEmbed](https://github.com/FxEmbed/FxEmbed). diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..fd86458 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "limitless-core" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true +url.workspace = true +sha2 = "0.10" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..e0d78e8 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,298 @@ +//! Provider-independent domain and security boundaries. No browser APIs here. +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Target { + Profile { handle: String }, + Post { id: String }, +} +impl Target { + pub fn parse(input: &str) -> Result { + let input = input.trim(); + if input.len() > 2048 || input.chars().any(char::is_control) { + return Err(Error::InvalidTarget); + } + if !input.contains("://") && !input.contains('/') { + return valid_handle(input.trim_start_matches('@')) + .map(|handle| Self::Profile { handle }) + .ok_or(Error::InvalidTarget); + } + let url = Url::parse(input).map_err(|_| Error::InvalidTarget)?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || !matches!( + url.host_str(), + Some( + "x.com" + | "www.x.com" + | "twitter.com" + | "www.twitter.com" + | "mobile.twitter.com" + ) + ) + { + return Err(Error::InvalidTarget); + } + let parts: Vec<_> = url + .path() + .trim_end_matches('/') + .split('/') + .skip(1) + .collect(); + match parts.as_slice() { + [handle] | [handle, "with_replies" | "reposts" | "media"] => valid_handle(handle) + .map(|handle| Self::Profile { handle }) + .ok_or(Error::InvalidTarget), + [handle, "status", id] + if (*handle == "i" || valid_handle(handle).is_some()) && valid_id(id) => + { + Ok(Self::Post { id: (*id).into() }) + } + _ => Err(Error::InvalidTarget), + } + } + pub fn canonical_url(&self) -> String { + match self { + Self::Profile { handle } => format!("https://x.com/{handle}"), + Self::Post { id } => format!("https://x.com/i/status/{id}"), + } + } + pub fn label(&self) -> String { + match self { + Self::Profile { handle } => format!("@{handle}"), + Self::Post { id } => format!("Post {id}"), + } + } + pub fn is_valid(&self) -> bool { + match self { + Self::Profile { handle } => valid_handle(handle).as_ref() == Some(handle), + Self::Post { id } => valid_id(id), + } + } +} +pub fn valid_handle(handle: &str) -> Option { + let h = handle.to_ascii_lowercase(); + if h.is_empty() + || h.len() > 15 + || !h.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') + || matches!( + h.as_str(), + "home" + | "explore" + | "search" + | "notifications" + | "messages" + | "settings" + | "i" + | "intent" + | "compose" + | "login" + | "logout" + | "signup" + | "tos" + | "privacy" + | "share" + ) + { + None + } else { + Some(h) + } +} +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 20 + && id.bytes().all(|c| c.is_ascii_digit()) + && id.parse::().is_ok_and(|n| n > 0) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Notice { + pub text: String, + pub policy_url: String, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Restriction { + Regional, +} +impl Restriction { + pub fn detect(target: &Target, notice: &Notice) -> Option { + if notice.text.len() > 1600 { + return None; + } + let u = Url::parse(¬ice.policy_url).ok()?; + if u.scheme() != "https" + || !matches!(u.host_str(), Some("help.x.com" | "help.twitter.com")) + || !u.username().is_empty() + || u.port().is_some() + || !u + .path() + .ends_with("/rules-and-policies/post-withheld-by-country") + { + return None; + } + let t = notice + .text + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + let t = t + .strip_prefix("account withheld") + .unwrap_or(&t) + .trim_start(); + let phrase = match target { + Target::Profile { handle } => { + let prefix = format!("@{handle}'s account has been withheld in "); + let alternate = format!("@{handle} has been withheld in "); + t.starts_with(&prefix) + || t.starts_with(&alternate) + || (t.starts_with(&format!("@{handle}")) + && t.contains("hesab") + && t.contains("erişime engellen") + && (t.contains("ülke") || t.contains("türkiye"))) + } + Target::Post { .. } => { + t.starts_with("this post has been withheld in ") + || t.starts_with("this tweet has been withheld in ") + || ((t.starts_with("bu gönderi") || t.starts_with("bu tweet")) + && t.contains("erişime engellen")) + } + }; + phrase.then_some(Self::Regional) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(rename_all = "snake_case")] +pub enum Error { + #[error("Enter an X profile or post URL, or an account handle.")] + InvalidTarget, + #[error("This account is protected. Private content is unavailable.")] + Protected, + #[error("The provider reports that this account is suspended.")] + Suspended, + #[error("The provider reports that this post was deleted.")] + Deleted, + #[error("The provider could not find this content.")] + NotFound, + #[error("This content is unavailable from the provider.")] + Unavailable, + #[error("FxTwitter is receiving too many requests. Try again later.")] + RateLimited, + #[error("FxTwitter returned an unexpected response.")] + InvalidResponse, + #[error("The request timed out. Try again.")] + Timeout, + #[error("Could not reach FxTwitter. Check your connection and try again.")] + Network, + #[error("This reader session expired. Open the content again.")] + Expired, + #[error("This action is not allowed from this page.")] + Unauthorized, + #[error("Local storage is unavailable. Try reopening the extension.")] + Storage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Profile { + pub id: String, + pub handle: String, + pub name: String, + pub biography: Option, + pub avatar: Option, + #[serde(default)] + pub banner: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub website: Option, + #[serde(default)] + pub joined: Option, + #[serde(default)] + pub followers: Option, + #[serde(default)] + pub following: Option, + #[serde(default)] + pub statuses: Option, + #[serde(default)] + pub verified: Option, + #[serde(default)] + pub verification_type: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Media { + pub kind: String, + pub url: String, + pub preview: Option, + pub alt: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Post { + pub id: String, + pub text: String, + pub author: Profile, + pub published: Option, + pub media: Vec, + #[serde(default)] + pub metrics: Metrics, + #[serde(default)] + pub quote: Option>, + #[serde(default)] + pub replying_to: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Metrics { + pub replies: Option, + pub reposts: Option, + pub likes: Option, + pub views: Option, + pub bookmarks: Option, + pub quotes: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conversation { + pub post: Post, + pub thread: Vec, + pub replies: Vec, + pub next_cursor: Option, + pub unavailable_replies: usize, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Timeline { + pub posts: Vec, + pub next_cursor: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Record { + pub profiles: u32, + pub posts: u32, + pub enabled: bool, + pub since: f64, +} + +pub fn safe_media_url(raw: &str) -> Option { + let u = Url::parse(raw).ok()?; + (u.scheme() == "https" + && u.username().is_empty() + && u.password().is_none() + && u.port().is_none() + && matches!(u.host_str(), Some("pbs.twimg.com" | "video.twimg.com"))) + .then(|| u.to_string()) +} +/// Domain-separated salted identifiers. Salt never leaves the installation. +pub fn record_key(salt: &[u8], kind: &str, id: &str) -> String { + let mut hash = Sha256::new(); + hash.update(salt); + hash.update([0]); + hash.update(kind); + hash.update([0]); + hash.update(id); + format!("{:x}", hash.finalize()) +} diff --git a/crates/core/tests/domain.rs b/crates/core/tests/domain.rs new file mode 100644 index 0000000..12b734f --- /dev/null +++ b/crates/core/tests/domain.rs @@ -0,0 +1,138 @@ +use limitless_core::{Notice, Restriction, Target, record_key, safe_media_url}; + +#[test] +fn parses_supported_urls_without_leaking_queries() { + assert_eq!( + Target::parse("https://x.com/Some_User/status/1234567890123456789?s=20").unwrap(), + Target::Post { + id: "1234567890123456789".into() + } + ); + assert_eq!( + Target::parse("@Some_User").unwrap(), + Target::Profile { + handle: "some_user".into() + } + ); + assert_eq!( + Target::parse("https://twitter.com/Some_User/").unwrap(), + Target::Profile { + handle: "some_user".into() + } + ); +} +#[test] +fn rejects_confused_hosts_routes_credentials_and_unicode() { + for s in [ + "https://x.com.evil.test/foo", + "https://evil.test/x.com/foo", + "https://u:p@x.com/foo", + "http://x.com/foo", + "https://x.com:444/foo", + "https://x.com/home", + "https://x.com/i", + "https://x.com/foo/followers", + "https://x.com/foo/status/notanid", + "a\nb", + "@ı", + "", + "https://x.com/foo%2fbar", + ] { + assert!(Target::parse(s).is_err(), "accepted {s}"); + } +} +#[test] +fn requires_explicit_matching_notice_and_policy_link() { + let target = Target::parse("@example").unwrap(); + let notice = Notice { + text: "@example's account has been withheld in Türkiye in response to a legal demand." + .into(), + policy_url: "https://help.x.com/en/rules-and-policies/post-withheld-by-country".into(), + }; + assert_eq!( + Restriction::detect(&target, ¬ice), + Some(Restriction::Regional) + ); + assert_eq!( + Restriction::detect(&Target::parse("@other").unwrap(), ¬ice), + None + ); + assert_eq!( + Restriction::detect( + &target, + &Notice { + text: "Something went wrong. Try again.".into(), + ..notice.clone() + } + ), + None + ); + assert_eq!( + Restriction::detect( + &target, + &Notice { + policy_url: "https://evil.test/post-withheld-by-country".into(), + ..notice + } + ), + None + ); +} +#[test] +fn never_classifies_private_suspended_or_missing_as_regional() { + let t = Target::parse("@test").unwrap(); + for text in [ + "@test Account suspended", + "@test These posts are protected", + "@test This account doesn’t exist", + "@test unavailable in your country", + "@test wrote: account has been withheld in Türkiye", + ] { + assert_eq!( + Restriction::detect( + &t, + &Notice { + text: text.into(), + policy_url: "https://help.x.com/en/rules-and-policies/post-withheld-by-country" + .into() + } + ), + None + ); + } +} +#[test] +fn media_is_https_on_explicit_provider_hosts() { + assert!(safe_media_url("https://pbs.twimg.com/media/abc.jpg").is_some()); + for s in [ + "javascript:alert(1)", + "data:text/html,x", + "https://evil.test/a", + "https://pbs.twimg.com.evil.test/a", + "http://pbs.twimg.com/a", + "https://a@pbs.twimg.com/a", + ] { + assert!(safe_media_url(s).is_none()); + } +} +#[test] +fn record_is_per_install_per_kind_and_does_not_store_ids() { + let a = record_key(&[1; 32], "profile", "422861505"); + assert_eq!(a.len(), 64); + assert_ne!(a, record_key(&[2; 32], "profile", "422861505")); + assert_ne!(a, record_key(&[1; 32], "post", "422861505")); + assert!(!a.contains("422861505")); +} + +#[test] +fn detects_the_actual_helium_x_account_withheld_dom_text() { + let notice=Notice{text:"Account Withheld@ekrem_imamoglu has been withheld in TR in response to a legal demand. https://Learn more.".into(),policy_url:"https://help.x.com/en/rules-and-policies/post-withheld-by-country".into()}; + assert_eq!( + Restriction::detect(&Target::parse("@ekrem_imamoglu").unwrap(), ¬ice), + Some(Restriction::Regional) + ); + assert_eq!( + Restriction::detect(&Target::parse("@other").unwrap(), ¬ice), + None + ); +} diff --git a/crates/fxtwitter/Cargo.toml b/crates/fxtwitter/Cargo.toml new file mode 100644 index 0000000..c0e8620 --- /dev/null +++ b/crates/fxtwitter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "limitless-fxtwitter" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +limitless-core.workspace = true +serde.workspace = true +serde_json.workspace = true +url.workspace = true diff --git a/crates/fxtwitter/src/lib.rs b/crates/fxtwitter/src/lib.rs new file mode 100644 index 0000000..9b640da --- /dev/null +++ b/crates/fxtwitter/src/lib.rs @@ -0,0 +1,412 @@ +//! FxTwitter v2 wire types. Unknown fields are ignored; required identity fields are not. +use limitless_core::{ + Error, Media, Post, Profile, Target, Timeline, safe_media_url, valid_handle, valid_id, +}; +use serde::Deserialize; + +pub fn endpoint(target: &Target, timeline: bool, cursor: Option<&str>) -> Result { + if !target.is_valid() { + return Err(Error::InvalidTarget); + } + let path = match target { + Target::Profile { handle } => format!( + "profile/{handle}{}", + if timeline { "/statuses" } else { "" } + ), + Target::Post { id } => format!("status/{id}"), + }; + let mut url = url::Url::parse(&format!("https://api.fxtwitter.com/2/{path}")).unwrap(); + if let Some(cursor) = cursor { + if cursor.len() > 4096 { + return Err(Error::InvalidResponse); + } + url.query_pairs_mut().append_pair("cursor", cursor); + } + Ok(url.into()) +} +#[derive(Deserialize)] +struct Envelope { + code: u16, + reason: Option, +} +fn error_reason(reason: &str) -> Error { + match reason { + "suspended" => Error::Suspended, + "deleted" => Error::Deleted, + "private" | "protected" => Error::Protected, + "not_found" => Error::NotFound, + _ => Error::Unavailable, + } +} +fn check(http: u16, body: &str) -> Result<(), Error> { + if http == 429 { + return Err(Error::RateLimited); + } + if http >= 500 { + return Err(Error::Unavailable); + } + if body.len() > 4 * 1024 * 1024 { + return Err(Error::InvalidResponse); + } + let e: Envelope = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + if let Some(reason) = e.reason { + return Err(error_reason(&reason)); + } + for code in [http, e.code] { + match code { + 200 => {} + 404 => return Err(Error::NotFound), + 429 => return Err(Error::RateLimited), + _ => return Err(Error::Unavailable), + } + } + Ok(()) +} +#[derive(Deserialize)] +struct RawProfile { + #[serde(rename = "type")] + kind: String, + id: String, + screen_name: String, + name: String, + protected: bool, + description: Option, + avatar_url: Option, + banner_url: Option, + location: Option, + joined: Option, + followers: Option, + following: Option, + statuses: Option, + website: Option, + verification: Option, +} +#[derive(Deserialize)] +struct RawWebsite { + url: String, +} +#[derive(Deserialize)] +struct RawVerification { + verified: bool, + #[serde(rename = "type")] + kind: Option, +} +impl RawProfile { + fn normalize(self) -> Result { + if self.protected { + return Err(Error::Protected); + } + if self.kind != "profile" + || !valid_id(&self.id) + || self.name.len() > 4096 + || self.description.as_ref().is_some_and(|x| x.len() > 32768) + { + return Err(Error::InvalidResponse); + } + valid_handle(&self.screen_name).ok_or(Error::InvalidResponse)?; + let handle = self.screen_name; + Ok(Profile { + id: self.id, + handle, + name: self.name, + biography: self.description, + avatar: self.avatar_url.and_then(|u| safe_media_url(&u)), + banner: self.banner_url.and_then(|u| safe_media_url(&u)), + location: self.location.filter(|v| !v.is_empty()), + joined: self.joined, + followers: self.followers, + following: self.following, + statuses: self.statuses, + website: self + .website + .and_then(|w| url::Url::parse(&w.url).ok()) + .filter(|u| { + u.scheme() == "https" && u.username().is_empty() && u.password().is_none() + }) + .map(Into::into), + verified: self.verification.as_ref().map(|v| v.verified), + verification_type: self.verification.and_then(|v| v.kind), + }) + } +} +#[derive(Deserialize)] +struct ProfileResponse { + user: RawProfile, +} +pub fn parse_profile(http: u16, body: &str, expected_handle: &str) -> Result { + check(http, body)?; + let r: ProfileResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + let p = r.user.normalize()?; + if !p.handle.eq_ignore_ascii_case(expected_handle) { + return Err(Error::InvalidResponse); + } + Ok(p) +} +#[derive(Deserialize)] +#[serde(tag = "type")] +enum RawPost { + #[serde(rename = "status")] + Status(Box), + #[serde(rename = "tombstone")] + Tombstone { reason: String }, +} +#[derive(Deserialize)] +struct RawStatus { + provider: String, + id: String, + text: String, + author: Box, + created_at: Option, + media: Option, + #[serde(flatten)] + metrics: limitless_core::Metrics, + quote: Option>, + replying_to: Option, +} +#[derive(Deserialize)] +struct RawReply { + screen_name: Option, +} +#[derive(Deserialize, Default)] +struct RawMedia { + photos: Option>, + videos: Option>, +} +#[derive(Deserialize)] +struct RawAttachment { + #[serde(rename = "type")] + kind: String, + url: String, + thumbnail_url: Option, + #[serde(rename = "altText")] + alt: Option, +} +impl RawPost { + fn normalize(self) -> Result { + self.normalize_depth(0) + } + fn normalize_depth(self, depth: u8) -> Result { + if depth > 6 { + return Err(Error::InvalidResponse); + } + match self { + Self::Tombstone { reason } => Err(error_reason(&reason)), + Self::Status(raw) => { + let RawStatus { + provider, + id, + text, + author, + created_at, + media, + metrics, + quote, + replying_to, + } = *raw; + if provider != "twitter" + || !valid_id(&id) + || text.len() > 256 * 1024 + || created_at.as_ref().is_some_and(|x| x.len() > 128) + { + return Err(Error::InvalidResponse); + } + let raw = media.unwrap_or_default(); + let media = raw + .photos + .unwrap_or_default() + .into_iter() + .chain(raw.videos.unwrap_or_default()) + .take(16) + .filter_map(|m| { + let kind = match m.kind.as_str() { + "photo" => "photo", + "video" | "gif" => "video", + _ => return None, + }; + Some(Media { + kind: kind.into(), + url: safe_media_url(&m.url)?, + preview: m.thumbnail_url.and_then(|u| safe_media_url(&u)), + alt: m.alt, + }) + }) + .collect(); + Ok(Post { + id, + text, + author: author.normalize()?, + published: created_at, + media, + metrics, + quote: quote + .and_then(|q| q.normalize_depth(depth + 1).ok()) + .map(Box::new), + replying_to: replying_to + .and_then(|r| r.screen_name) + .filter(|h| valid_handle(h).is_some()), + }) + } + } + } +} +#[derive(Deserialize)] +struct PostResponse { + status: RawPost, +} +pub fn parse_post(http: u16, body: &str, expected_id: &str) -> Result { + check(http, body)?; + let r: PostResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + let p = r.status.normalize()?; + if p.id != expected_id { + return Err(Error::InvalidResponse); + } + Ok(p) +} +#[derive(Deserialize)] +struct Cursor { + bottom: Option, +} +#[derive(Deserialize)] +struct TimelineResponse { + results: Vec, + cursor: Option, +} +pub fn parse_timeline(http: u16, body: &str) -> Result { + check(http, body)?; + let r: TimelineResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + if r.results.len() > 200 { + return Err(Error::InvalidResponse); + } + let posts = r + .results + .into_iter() + .map(RawPost::normalize) + .collect::>()?; + let next_cursor = r.cursor.and_then(|c| c.bottom).filter(|s| !s.is_empty()); + if next_cursor.as_ref().is_some_and(|s| s.len() > 4096) { + return Err(Error::InvalidResponse); + } + Ok(Timeline { posts, next_cursor }) +} + +#[derive(Deserialize)] +struct ConversationResponse { + status: RawPost, + thread: Option>, + replies: Vec, + cursor: Option, +} +pub fn parse_conversation( + http: u16, + body: &str, + expected: &str, +) -> Result { + check(http, body)?; + let r: ConversationResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + let post = r.status.normalize()?; + if post.id != expected { + return Err(Error::InvalidResponse); + } + if r.replies.len() > 200 || r.thread.as_ref().is_some_and(|t| t.len() > 200) { + return Err(Error::InvalidResponse); + } + let mut unavailable = 0; + let mut normalize = |values: Vec| -> Result, Error> { + values + .into_iter() + .filter_map(|raw| match raw { + RawPost::Tombstone { .. } => { + unavailable += 1; + None + } + post => Some(post.normalize()), + }) + .collect() + }; + let thread = normalize(r.thread.unwrap_or_default())? + .into_iter() + .filter(|p| p.id != expected) + .collect(); + let replies = normalize(r.replies)?; + let next_cursor = r.cursor.and_then(|c| c.bottom).filter(|c| !c.is_empty()); + if next_cursor.as_ref().is_some_and(|c| c.len() > 4096) { + return Err(Error::InvalidResponse); + } + Ok(limitless_core::Conversation { + post, + thread, + replies, + next_cursor, + unavailable_replies: unavailable, + }) +} +pub fn conversation_endpoint(id: &str, cursor: Option<&str>) -> Result { + if !valid_id(id) { + return Err(Error::InvalidTarget); + } + let mut url = + url::Url::parse(&format!("https://api.fxtwitter.com/2/conversation/{id}")).unwrap(); + if let Some(cursor) = cursor { + if cursor.len() > 4096 { + return Err(Error::InvalidResponse); + } + url.query_pairs_mut().append_pair("cursor", cursor); + } + Ok(url.into()) +} + +/// Use public typeahead only to resolve identity. Its placeholder zero counters are not profile data. +pub fn identity_endpoint(handle: &str) -> Result { + let handle = valid_handle(handle).ok_or(Error::InvalidTarget)?; + let mut url = url::Url::parse("https://api.fxtwitter.com/2/typeahead").unwrap(); + url.query_pairs_mut() + .append_pair("q", &handle) + .append_pair("result_type", "users"); + Ok(url.into()) +} +pub fn parse_identity(http: u16, body: &str, expected: &str) -> Result { + check(http, body)?; + #[derive(Deserialize)] + struct Candidate { + id: String, + screen_name: String, + protected: bool, + } + #[derive(Deserialize)] + struct Response { + users: Vec, + } + let response: Response = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?; + if response.users.len() > 100 { + return Err(Error::InvalidResponse); + } + let user = response + .users + .into_iter() + .find(|u| u.screen_name.eq_ignore_ascii_case(expected)) + .ok_or(Error::NotFound)?; + if user.protected { + return Err(Error::Protected); + } + if !valid_id(&user.id) { + return Err(Error::InvalidResponse); + } + Ok(user.id) +} +pub fn id_endpoint(id: &str, timeline: bool, cursor: Option<&str>) -> Result { + if !valid_id(id) || cursor.is_some_and(|c| c.len() > 4096) { + return Err(Error::InvalidTarget); + } + let mut url = url::Url::parse(&format!( + "https://api.fxtwitter.com/2/profile/id:{id}{}", + if timeline { "/statuses" } else { "" } + )) + .unwrap(); + if timeline { + url.query_pairs_mut().append_pair("count", "5"); + } + if let Some(cursor) = cursor { + url.query_pairs_mut().append_pair("cursor", cursor); + } + Ok(url.into()) +} diff --git a/crates/fxtwitter/tests/provider.rs b/crates/fxtwitter/tests/provider.rs new file mode 100644 index 0000000..d910e54 --- /dev/null +++ b/crates/fxtwitter/tests/provider.rs @@ -0,0 +1,155 @@ +use limitless_core::Error; +use limitless_fxtwitter::{endpoint, parse_post, parse_profile, parse_timeline}; +const USER: &str = r#"{"code":200,"user":{"type":"profile","id":"12","screen_name":"jack","name":"jack","description":"bio","avatar_url":"https://pbs.twimg.com/a.jpg","protected":false}}"#; +const POST: &str = r#"{"code":200,"status":{"type":"status","provider":"twitter","id":"20","text":"just setting up my twttr","author":{"type":"profile","id":"12","screen_name":"jack","name":"jack","protected":false},"created_at":"Tue Mar 21 20:50:14 +0000 2006","media":{"photos":[{"type":"photo","url":"https://pbs.twimg.com/a.jpg","altText":"a","width":1,"height":1}]}}}"#; +#[test] +fn normalizes_only_real_fields_and_keeps_text_as_text() { + let p = parse_profile(200, USER, "jack").unwrap(); + assert_eq!(p.id, "12"); + assert_eq!(p.biography.as_deref(), Some("bio")); + let p = parse_post(200, POST, "20").unwrap(); + assert_eq!(p.text, "just setting up my twttr"); + assert_eq!(p.author.avatar, None); + assert_eq!(p.media[0].alt.as_deref(), Some("a")); +} +#[test] +fn cannot_substitute_wrong_content_or_provider() { + assert!(parse_profile(200, USER, "other").is_err()); + assert!(parse_post(200, POST, "21").is_err()); + assert!(parse_post(200, &POST.replace("twitter", "mastodon"), "20").is_err()); + assert!( + parse_post( + 200, + &POST.replace("https://pbs.twimg.com/a.jpg", "javascript:alert(1)"), + "20" + ) + .unwrap() + .media + .is_empty() + ); +} +#[test] +fn separates_unavailability_states_and_never_infers_withholding() { + for (body, error) in [ + (r#"{"code":404,"reason":"suspended"}"#, Error::Suspended), + ( + r#"{"code":200,"status":{"type":"tombstone","provider":"twitter","reason":"deleted","message":"gone"}}"#, + Error::Deleted, + ), + ( + r#"{"code":200,"status":{"type":"tombstone","provider":"twitter","reason":"private","message":"private"}}"#, + Error::Protected, + ), + ( + r#"{"code":404,"message":"User not found"}"#, + Error::NotFound, + ), + (r#"{"code":429}"#, Error::RateLimited), + ] { + assert_eq!(parse_post(200, body, "20").unwrap_err(), error); + } + assert_eq!(parse_post(503, POST, "20").unwrap_err(), Error::Unavailable); + assert_eq!( + parse_post(200, "not json", "20").unwrap_err(), + Error::InvalidResponse + ); + assert_eq!( + parse_profile(200, &USER.replace("false", "true"), "jack").unwrap_err(), + Error::Protected + ); +} +#[test] +fn preserves_pagination_and_does_not_invent_empty_arrays() { + let status: serde_json::Value = serde_json::from_str(POST).unwrap(); + let timeline = + serde_json::json!({"code":200,"results":[status["status"]],"cursor":{"bottom":"next+/="}}) + .to_string(); + let t = parse_timeline(200, &timeline).unwrap(); + assert_eq!(t.posts.len(), 1); + assert_eq!(t.next_cursor.as_deref(), Some("next+/=")); + assert!(parse_timeline(200, r#"{"code":200}"#).is_err()); + assert!( + parse_timeline(200, r#"{"code":200,"results":[],"cursor":{"bottom":null}}"#) + .unwrap() + .posts + .is_empty() + ); + assert!( + endpoint( + &limitless_core::Target::parse("@jack").unwrap(), + true, + Some("next+/=") + ) + .unwrap() + .ends_with("?cursor=next%2B%2F%3D") + ); +} + +#[test] +fn normalizes_real_profile_metadata_and_post_metrics_without_inventing_nulls() { + let mut user: serde_json::Value = serde_json::from_str(USER).unwrap(); + user["user"]["banner_url"] = serde_json::json!("https://pbs.twimg.com/profile_banners/12/1"); + user["user"]["followers"] = serde_json::json!(9876543); + user["user"]["following"] = serde_json::json!(820); + user["user"]["location"] = serde_json::json!("İstanbul"); + user["user"]["joined"] = serde_json::json!("Sun Nov 27 19:45:16 +0000 2011"); + user["user"]["verification"] = serde_json::json!({"verified":true,"type":"individual"}); + let p = parse_profile(200, &user.to_string(), "jack").unwrap(); + assert_eq!(p.followers, Some(9876543)); + assert!(p.banner.is_some()); + assert_eq!(p.verified, Some(true)); + let mut status: serde_json::Value = serde_json::from_str(POST).unwrap(); + status["status"]["likes"] = serde_json::json!(0); + status["status"]["views"] = serde_json::Value::Null; + status["status"]["replies"] = serde_json::json!(34); + let p = parse_post(200, &status.to_string(), "20").unwrap(); + assert_eq!(p.metrics.likes, Some(0)); + assert_eq!(p.metrics.views, None); + assert_eq!(p.metrics.replies, Some(34)); +} +#[test] +fn conversation_checks_focal_identity_and_retains_reply_cursor() { + let status: serde_json::Value = serde_json::from_str(POST).unwrap(); + let mut reply = status["status"].clone(); + reply["id"] = serde_json::json!("21"); + reply["text"] = serde_json::json!("a real reply"); + let body=serde_json::json!({"code":200,"status":status["status"],"thread":null,"replies":[reply,{"type":"tombstone","reason":"deleted","provider":"twitter","message":"gone"}],"cursor":{"bottom":"reply+/="}}).to_string(); + let c = limitless_fxtwitter::parse_conversation(200, &body, "20").unwrap(); + assert_eq!(c.post.id, "20"); + assert_eq!(c.replies[0].id, "21"); + assert_eq!(c.next_cursor.as_deref(), Some("reply+/=")); + assert_eq!(c.unavailable_replies, 1); + assert!(limitless_fxtwitter::parse_conversation(200, &body, "22").is_err()); + assert_eq!( + limitless_fxtwitter::conversation_endpoint("20", Some("reply+/=")).unwrap(), + "https://api.fxtwitter.com/2/conversation/20?cursor=reply%2B%2F%3D" + ); +} + +#[test] +fn accepts_the_actual_live_v2_conversation_shape() { + let c = limitless_fxtwitter::parse_conversation( + 200, + include_str!("../../../tests/fixtures/public-conversation.json"), + "2115761952937128381", + ) + .unwrap(); + assert_eq!(c.replies.len(), 2); + assert!(c.next_cursor.is_some()); + assert!(c.post.metrics.likes.is_some()); +} + +#[test] +fn identity_resolution_is_exact_and_never_uses_suggestion_counters() { + let body = r#"{"code":200,"users":[{"id":"99","screen_name":"jackfan","protected":false},{"id":"12","screen_name":"Jack","protected":false,"followers":0}]}"#; + assert_eq!( + limitless_fxtwitter::parse_identity(200, body, "jack").unwrap(), + "12" + ); + assert!(limitless_fxtwitter::parse_identity(200, body, "other").is_err()); + assert!(limitless_fxtwitter::id_endpoint("12/evil", true, None).is_err()); + assert_eq!( + limitless_fxtwitter::id_endpoint("12", true, Some("+/=")).unwrap(), + "https://api.fxtwitter.com/2/profile/id:12/statuses?count=5&cursor=%2B%2F%3D" + ); +} diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml new file mode 100644 index 0000000..5688420 --- /dev/null +++ b/crates/runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "limitless-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +limitless-core.workspace = true +limitless-fxtwitter.workspace = true +limitless-storage.workspace = true +serde.workspace = true +serde_json.workspace = true +url.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +futures = "0.3" +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +serde-wasm-bindgen.workspace = true +js-sys.workspace = true +web-sys = { workspace = true, features = ["Request", "RequestInit", "RequestCredentials", "RequestRedirect", "RequestCache", "Response", "Headers", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader"] } diff --git a/crates/runtime/src/icons.rs b/crates/runtime/src/icons.rs new file mode 100644 index 0000000..8b21c28 --- /dev/null +++ b/crates/runtime/src/icons.rs @@ -0,0 +1,14 @@ +//! Native X action paths captured through the user-authorized Helium inspection. +pub const REPLY: &str = "M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"; +pub const REPOST: &str = "M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"; +pub const LIKE: &str = "M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"; +pub const BOOKMARK: &str = "M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z"; +pub const SHARE: &str = "M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"; +pub const VIEWS: &str = + "M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"; + +pub const VERIFIED: &str = "M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"; + +pub const LOCATION: &str = "M12 7c-1.93 0-3.5 1.57-3.5 3.5S10.07 14 12 14s3.5-1.57 3.5-3.5S13.93 7 12 7zm0 5c-.827 0-1.5-.673-1.5-1.5S11.173 9 12 9s1.5.673 1.5 1.5S12.827 12 12 12zm0-10c-4.687 0-8.5 3.813-8.5 8.5 0 5.967 7.621 11.116 7.945 11.332l.555.37.555-.37c.324-.216 7.945-5.365 7.945-11.332C20.5 5.813 16.687 2 12 2zm0 17.77c-1.665-1.241-6.5-5.196-6.5-9.27C5.5 6.916 8.416 4 12 4s6.5 2.916 6.5 6.5c0 4.073-4.835 8.028-6.5 9.27z"; +pub const WEBSITE: &str = "M18.36 5.64c-1.95-1.96-5.11-1.96-7.07 0L9.88 7.05 8.46 5.64l1.42-1.42c2.73-2.73 7.16-2.73 9.9 0 2.73 2.74 2.73 7.17 0 9.9l-1.42 1.42-1.41-1.42 1.41-1.41c1.96-1.96 1.96-5.12 0-7.07zm-2.12 3.53l-7.07 7.07-1.41-1.41 7.07-7.07 1.41 1.41zm-12.02.71l1.42-1.42 1.41 1.42-1.41 1.41c-1.96 1.96-1.96 5.12 0 7.07 1.95 1.96 5.11 1.96 7.07 0l1.41-1.41 1.42 1.41-1.42 1.42c-2.73 2.73-7.16 2.73-9.9 0-2.73-2.74-2.73-7.17 0-9.9z"; +pub const JOINED: &str = "M7 4V3h2v1h6V3h2v1h1.5C19.89 4 21 5.12 21 6.5v12c0 1.38-1.11 2.5-2.5 2.5h-13C4.12 21 3 19.88 3 18.5v-12C3 5.12 4.12 4 5.5 4H7zm0 2H5.5c-.27 0-.5.22-.5.5v12c0 .28.23.5.5.5h13c.28 0 .5-.22.5-.5v-12c0-.28-.22-.5-.5-.5H17v1h-2V6H9v1H7V6zm0 6h2v-2H7v2zm0 4h2v-2H7v2zm4-4h2v-2h-2v2zm0 4h2v-2h-2v2zm4-4h2v-2h-2v2z"; diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs new file mode 100644 index 0000000..1cac8b4 --- /dev/null +++ b/crates/runtime/src/lib.rs @@ -0,0 +1,97 @@ +mod icons; +pub mod view; +use limitless_core::Target; +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sender { + pub id: String, + pub url: String, + #[serde(default)] + pub page_url: Option, + pub tab_id: Option, + pub frame_id: u32, + pub extension_origin: String, +} +impl Sender { + pub fn current_url(&self) -> &str { + self.page_url.as_deref().unwrap_or(&self.url) + } + fn same_document_origin(&self) -> bool { + let (Ok(document), Ok(current)) = ( + url::Url::parse(&self.url), + url::Url::parse(self.current_url()), + ) else { + return false; + }; + document.origin() == current.origin() + } +} +pub fn authorized(sender: &Sender, op: &str) -> bool { + if sender.id.is_empty() || sender.frame_id != 0 { + return false; + } + if let Some(path) = sender.url.strip_prefix(&sender.extension_origin) { + return matches!(path.split('?').next(), Some("popup.html")) + && matches!( + op, + "ping" + | "record" + | "settings" + | "clear" + | "open_manual" + | "current" + | "open_current" + ); + } + let Ok(u) = url::Url::parse(&sender.url) else { + return false; + }; + sender.same_document_origin() + && u.scheme() == "https" + && matches!( + u.host_str(), + Some("x.com" | "www.x.com" | "twitter.com" | "www.twitter.com") + ) + && sender.tab_id.is_some() + && matches!( + op, + "inspect" + | "open_detected" + | "auto_detected" + | "pending" + | "read" + | "timeline" + | "open_post" + | "replies" + ) +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub target: Target, + pub source: Target, + pub restricted: bool, + #[serde(default)] + pub deliberate: bool, + #[serde(default)] + pub pending: bool, + #[serde(default)] + pub created: f64, + pub expires: f64, + pub tab_id: i32, + #[serde(default)] + pub allowed_posts: Vec, + #[serde(default)] + pub content: Option, +} +impl Session { + pub fn bound_to(&self, sender: &Sender) -> bool { + sender.tab_id == Some(self.tab_id) + && Target::parse(sender.current_url()).is_ok_and(|target| target == self.source) + } + pub fn valid(&self, target: &Target, now: f64) -> bool { + self.target == *target && target.is_valid() && now.is_finite() && now < self.expires + } +} + +#[cfg(target_arch = "wasm32")] +mod wasm; diff --git a/crates/runtime/src/view.rs b/crates/runtime/src/view.rs new file mode 100644 index 0000000..5da615d --- /dev/null +++ b/crates/runtime/src/view.rs @@ -0,0 +1,886 @@ +//! A typed DOM description rendered by the narrow content-script DOM adapter. +//! All product copy, view decisions and actions are constructed in Rust. +use limitless_core::{Error, Post, Profile, Target, Timeline}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Node { + Text(String), + Element { + tag: String, + attrs: Vec<(String, String)>, + children: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + action: Option, + }, +} +fn text(s: impl Into) -> Node { + Node::Text(s.into()) +} +fn el(tag: &str, class: &str, children: Vec) -> Node { + Node::Element { + tag: tag.into(), + attrs: if class.is_empty() { + vec![] + } else { + vec![("class".into(), class.into())] + }, + children, + action: None, + } +} +fn attr(mut node: Node, key: &str, value: impl Into) -> Node { + if let Node::Element { attrs, .. } = &mut node { + attrs.push((key.into(), value.into())); + } + node +} +fn button(label: &str, action: Value) -> Node { + let mut node = el("button", "secondary", vec![text(label)]); + if let Node::Element { action: a, .. } = &mut node { + *a = Some(action); + } + attr(node, "type", "button") +} +fn link(label: &str, url: String) -> Node { + attr( + attr( + attr(el("a", "source-link", vec![text(label)]), "href", url), + "target", + "_blank", + ), + "rel", + "noopener noreferrer", + ) +} +fn frame(children: Vec) -> Node { + el("main", "reader x-reader", children) +} +pub fn loading(_target: &Target) -> Node { + frame(vec![attr( + el("p", "x-loading", vec![text("Loading public content…")]), + "role", + "status", + )]) +} +pub fn error(error: &Error, retry: Option) -> Node { + let mut children = vec![el("p", "", vec![text(error.to_string())])]; + if let Some(retry) = retry { + children.push(button("Try again", retry)); + } + frame(vec![attr( + el("section", "x-error", children), + "role", + "alert", + )]) +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProfileFilter { + #[default] + Posts, + Replies, + Reposts, + Media, +} +impl ProfileFilter { + pub fn from_url(url: &str) -> Self { + match url::Url::parse(url) + .ok() + .as_ref() + .map(|u| { + u.path() + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or("") + .to_string() + }) + .as_deref() + { + Some("with_replies") => Self::Replies, + Some("reposts") => Self::Reposts, + Some("media") => Self::Media, + _ => Self::Posts, + } + } + fn accepts(self, post: &Post, owner: Option<&Profile>) -> bool { + match self { + Self::Posts => true, + Self::Replies => post.replying_to.is_some(), + Self::Reposts => { + owner.is_some_and(|p| !p.handle.eq_ignore_ascii_case(&post.author.handle)) + } + Self::Media => !post.media.is_empty(), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Content { + #[serde(default)] + pub visible_posts: usize, + #[serde(default)] + pub profile_filter: ProfileFilter, + pub profile: Option, + pub post: Option, + pub timeline: Option, + pub profile_error: Option, + pub timeline_error: Option, + pub record_error: Option, + #[serde(default)] + pub conversation: Option, + #[serde(default)] + pub replies_error: Option, +} +impl Content { + pub fn visible_limit(&self) -> usize { + self.visible_posts.max(5) + } + pub fn reveal_buffered(&mut self) -> bool { + let len = self.timeline.as_ref().map_or(0, |t| { + t.posts + .iter() + .filter(|p| self.profile_filter.accepts(p, self.profile.as_ref())) + .count() + }); + if self.visible_limit() >= len { + return false; + } + self.visible_posts = (self.visible_limit() + 5).min(len); + true + } +} +fn svg(path: &str, class: &str) -> Node { + attr( + attr( + attr( + el("svg", class, vec![attr(el("path", "", vec![]), "d", path)]), + "viewBox", + "0 0 24 24", + ), + "fill", + "currentColor", + ), + "aria-hidden", + "true", + ) +} +fn compact(n: u64) -> String { + let (value, suffix) = if n >= 1_000_000_000 { + (n as f64 / 1e9, "B") + } else if n >= 1_000_000 { + (n as f64 / 1e6, "M") + } else if n >= 1000 { + (n as f64 / 1e3, "K") + } else { + return n.to_string(); + }; + format!( + "{}{suffix}", + format!("{value:.1}") + .trim_end_matches('0') + .trim_end_matches('.') + ) +} +fn linked_text(value: &str) -> Vec { + value + .split_inclusive(char::is_whitespace) + .flat_map(|chunk| { + let token = chunk.trim_end_matches(char::is_whitespace); + let rest = &chunk[token.len()..]; + let url = if token.starts_with("https://") { + url::Url::parse(token) + .ok() + .filter(|u| u.username().is_empty() && u.password().is_none()) + .map(Into::into) + } else if token.starts_with('@') && limitless_core::valid_handle(&token[1..]).is_some() + { + Some(format!("https://x.com/{}", &token[1..])) + } else if token.starts_with('#') && token.len() > 1 { + let mut u = url::Url::parse("https://x.com/hashtag/").unwrap(); + u.path_segments_mut().unwrap().push(&token[1..]); + Some(u.into()) + } else { + None + }; + let first = if let Some(url) = url { + attr(link(token, url), "class", "x-entity") + } else { + text(token) + }; + vec![first, text(rest)] + }) + .collect() +} +fn badge(p: &Profile) -> Option { + p.verified.filter(|v| *v).map(|_| { + let path = crate::icons::VERIFIED; + attr( + attr( + svg( + path, + if p.verification_type.as_deref() == Some("organization") { + "x-verified organization" + } else { + "x-verified" + }, + ), + "viewBox", + "0 0 22 22", + ), + "aria-label", + "Verified", + ) + }) +} +fn author(p: &Profile, detailed: bool) -> Node { + let mut name = vec![attr( + link(&p.name, format!("https://x.com/{}", p.handle)), + "class", + "x-author-name", + )]; + if let Some(badge) = badge(p) { + name.push(badge); + } + el( + "div", + if detailed { + "x-author detailed-author" + } else { + "x-author" + }, + vec![ + el("span", "x-name-line", name), + attr( + link( + &format!("@{}", p.handle), + format!("https://x.com/{}", p.handle), + ), + "class", + "x-handle", + ), + ], + ) +} +fn avatar(p: &Profile, class: &str) -> Node { + if let Some(src) = &p.avatar { + let src = if class == "x-profile-avatar" { + src.replace("_normal.", "_200x200.") + } else { + src.clone() + }; + attr( + attr( + attr(attr(el("img", class, vec![]), "src", &src), "alt", ""), + "crossorigin", + "anonymous", + ), + "referrerpolicy", + "no-referrer", + ) + } else { + attr( + el("div", &format!("{class} avatar-placeholder"), vec![]), + "aria-label", + "Profile image unavailable", + ) + } +} +fn profile(p: &Profile) -> Node { + let mut hero = vec![]; + if let Some(banner) = &p.banner { + hero.push(attr( + attr( + attr(el("img", "x-banner", vec![]), "src", banner), + "alt", + "", + ), + "referrerpolicy", + "no-referrer", + )); + } + hero.push(avatar(p, "x-profile-avatar")); + let mut name = vec![text(&p.name)]; + if let Some(b) = badge(p) { + name.push(b); + } + let mut info = vec![ + el( + "div", + "x-profile-actions", + vec![attr( + link( + "Follow", + format!("https://x.com/intent/follow?screen_name={}", p.handle), + ), + "class", + "x-follow", + )], + ), + el( + "div", + "x-profile-names", + vec![ + el("h2", "", name), + el("p", "x-handle", vec![text(format!("@{}", p.handle))]), + ], + ), + ]; + if let Some(bio) = &p.biography { + info.push(el("div", "biography", linked_text(bio))); + } + let mut details = vec![]; + if let Some(location) = &p.location { + details.push(el( + "span", + "x-profile-location", + vec![ + svg(crate::icons::LOCATION, "x-profile-icon"), + text(location), + ], + )); + } + if let Some(website) = &p.website { + let label = website + .trim_start_matches("https://") + .trim_end_matches('/') + .to_string(); + details.push(attr( + link(&label, website.clone()).with_children(vec![ + svg(crate::icons::WEBSITE, "x-profile-icon"), + text(label), + ]), + "class", + "x-profile-website", + )); + } + if let Some(joined) = &p.joined { + let fields: Vec<_> = joined.split_whitespace().collect(); + let label = if fields.len() >= 6 { + let month = match fields[1] { + "Jan" => "January", + "Feb" => "February", + "Mar" => "March", + "Apr" => "April", + "May" => "May", + "Jun" => "June", + "Jul" => "July", + "Aug" => "August", + "Sep" => "September", + "Oct" => "October", + "Nov" => "November", + "Dec" => "December", + other => other, + }; + format!("Joined {month} {}", fields[5]) + } else { + format!("Joined {joined}") + }; + details.push(el( + "span", + "x-profile-joined", + vec![svg(crate::icons::JOINED, "x-profile-icon"), text(label)], + )); + } + if !details.is_empty() { + info.push(el("div", "x-profile-details", details)); + } + let mut counts = vec![]; + for (number, label, path) in [ + (p.following, "Following", "following"), + (p.followers, "Followers", "verified_followers"), + ] { + if let Some(n) = number { + counts.push( + attr( + link("", format!("https://x.com/{}/{path}", p.handle)), + "class", + "x-profile-count", + ) + .with_children(vec![ + el("strong", "", vec![text(compact(n))]), + text(format!(" {label}")), + ]), + ); + } + } + if !counts.is_empty() { + info.push(el("div", "x-profile-counts", counts)); + } + el( + "section", + "x-profile", + vec![ + el("div", "x-profile-hero", hero), + el("div", "x-profile-info", info), + ], + ) +} +impl Node { + fn with_attr(self, key: &str, value: &str) -> Self { + attr(self, key, value) + } + fn with_children(mut self, next: Vec) -> Self { + if let Node::Element { children, .. } = &mut self { + *children = next; + } + self + } +} +fn short_date(value: &str) -> String { + let fields: Vec<_> = value.split_whitespace().collect(); + if fields.len() == 6 { + format!("{} {}, {}", fields[1], fields[2], fields[5]) + } else { + value.into() + } +} +fn date_label(value: &str, detailed: bool) -> String { + #[cfg(target_arch = "wasm32")] + { + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(value)); + if date.get_time().is_finite() { + let months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let month = months[date.get_month() as usize]; + let day = date.get_date(); + let year = date.get_full_year(); + if detailed { + let h = date.get_hours(); + return format!( + "{}:{:02} {} · {month} {day}, {year}", + if h.is_multiple_of(12) { 12 } else { h % 12 }, + date.get_minutes(), + if h >= 12 { "PM" } else { "AM" } + ); + } + let elapsed = (js_sys::Date::now() - date.get_time()) / 1000.; + if (0.0..60.).contains(&elapsed) { + return format!("{}s", elapsed.max(1.) as u64); + } + if (60.0..3600.).contains(&elapsed) { + return format!("{}m", (elapsed / 60.) as u64); + } + if (3600.0..86400.).contains(&elapsed) { + return format!("{}h", (elapsed / 3600.) as u64); + } + let current_year = js_sys::Date::new_0().get_full_year(); + return if year == current_year { + format!("{month} {day}") + } else { + format!("{month} {day}, {year}") + }; + } + } + let _ = detailed; + short_date(value) +} +fn media_control(label: &str, command: &str, path: &str) -> Node { + attr( + attr( + attr( + el( + "button", + "lx-media-button", + vec![svg(path, "lx-media-icon")], + ), + "type", + "button", + ), + "aria-label", + label, + ), + "data-media-command", + command, + ) +} +fn media(p: &Post) -> Node { + el( + "div", + &format!("media-grid media-count-{}", p.media.len().min(4)), + p.media + .iter() + .map(|m| { + if m.kind == "photo" { + attr( + attr( + attr( + attr( + attr(el("img", "post-media", vec![]), "src", &m.url), + "alt", + m.alt.as_deref().unwrap_or("Image from this post"), + ), + "loading", + "lazy", + ), + "crossorigin", + "anonymous", + ), + "referrerpolicy", + "no-referrer", + ) + } else { + let mut n = attr( + attr( + attr( + attr(el("video", "post-media", vec![]), "src", &m.url), + "controls", + "", + ), + "preload", + "none", + ), + "crossorigin", + "anonymous", + ); + n = attr(n, "playsinline", ""); + if let Some(preview) = &m.preview { + n = attr(n, "poster", preview); + } + let controls=el("div","lx-media-controls",vec![ + media_control("Play video","play","M8 5v14l11-7z"), + attr(attr(attr(attr(attr(el("input","lx-seek",vec![]),"type","range"),"min","0"),"max","1000"),"value","0"),"aria-label","Seek video").with_attr("data-media-role","seek"), + attr(el("span","lx-media-time",vec![text("0:00 / 0:00")]),"data-media-role","time"), + media_control("Mute video","mute","M3 9v6h4l5 4V5L7 9H3zm12-2v10c2-1 3-3 3-5s-1-4-3-5z"), + attr(attr(attr(attr(attr(attr(el("input","lx-volume",vec![]),"type","range"),"min","0"),"max","1"),"step","0.05"),"value","1"),"aria-label","Volume").with_attr("data-media-role","volume"), + attr(attr(el("select","lx-speed",["0.5","1","1.5","2"].into_iter().map(|rate|attr(el("option","",vec![text(format!("{rate}×"))]),"value",rate)).collect()),"aria-label","Playback speed"),"data-media-role","speed"), + media_control("Full screen","fullscreen","M3 3h7v2H5v5H3V3zm11 0h7v7h-2V5h-5V3zM3 14h2v5h5v2H3v-7zm16 0h2v7h-7v-2h5v-5z"), + ]); + el("div","lx-player",vec![n,controls, + attr(attr(el("p","lx-media-status",vec![]),"data-media-role","status"),"role","status"), + attr(attr(el("button","lx-media-retry",vec![text("Retry video")]),"type","button"),"data-media-command","retry"), + attr(link("Open video on X",format!("https://x.com/{}/status/{}",p.author.handle,p.id)),"class","lx-media-source"), + ]) + } + }) + .collect(), + ) +} +fn metrics(p: &Post, detailed: bool) -> Node { + let mut items = vec![]; + let data = if detailed { + vec![ + ("Reply", crate::icons::REPLY, p.metrics.replies), + ("Repost", crate::icons::REPOST, p.metrics.reposts), + ("Like", crate::icons::LIKE, p.metrics.likes), + ("Bookmark", crate::icons::BOOKMARK, p.metrics.bookmarks), + ("Share", crate::icons::SHARE, None), + ] + } else { + vec![ + ("Reply", crate::icons::REPLY, p.metrics.replies), + ("Repost", crate::icons::REPOST, p.metrics.reposts), + ("Like", crate::icons::LIKE, p.metrics.likes), + ("Views", crate::icons::VIEWS, p.metrics.views), + ("Bookmark", crate::icons::BOOKMARK, None), + ("Share", crate::icons::SHARE, None), + ] + }; + for (label, path, count) in data { + let mut children = vec![svg(path, "x-metric-icon")]; + if let Some(number) = count.filter(|n| *n > 0) { + children.push(el("span", "x-metric-value", vec![text(compact(number))])); + } + let href = match label { + "Reply" => format!("https://x.com/intent/post?in_reply_to={}", p.id), + "Repost" => format!("https://x.com/intent/retweet?tweet_id={}", p.id), + "Like" => format!("https://x.com/intent/like?tweet_id={}", p.id), + _ => format!("https://x.com/{}/status/{}", p.author.handle, p.id), + }; + items.push( + attr( + attr( + link("", href), + "class", + format!("x-metric metric-{}", label.to_ascii_lowercase()), + ), + "aria-label", + format!( + "{label}{} · Open on X", + count.map(|n| format!(" {n}")).unwrap_or_default() + ), + ) + .with_children(children), + ); + } + attr( + el( + "div", + if detailed { + "x-metrics detailed-metrics" + } else { + "x-metrics" + }, + items, + ), + "role", + "group", + ) +} +fn quote(p: &Post, depth: u8) -> Node { + let mut children = vec![ + el( + "div", + "x-quote-author", + vec![ + avatar(&p.author, "x-quote-avatar"), + author(&p.author, false), + ], + ), + el("div", "x-quote-text", linked_text(&p.text)), + media(p), + ]; + if depth < 3 + && let Some(q) = &p.quote + { + children.push(quote(q, depth + 1)); + } + attr( + el("section", "x-quote", children), + "aria-label", + "Quoted post", + ) +} +fn post(p: &Post, token: &str, detailed: bool) -> Node { + let mut headline = vec![author(&p.author, detailed)]; + if !detailed && let Some(date) = &p.published { + headline.push(el( + "time", + "x-timeline-time", + vec![text(format!("· {}", date_label(date, false)))], + )); + } + headline.push(attr( + link( + "…", + format!("https://x.com/{}/status/{}", p.author.handle, p.id), + ), + "class", + "x-post-more", + )); + let header = el("div", "post-heading", headline); + let mut body = vec![]; + let mut body_text = el("p", "post-text", linked_text(&p.text)); + if !detailed { + if let Node::Element { action, .. } = &mut body_text { + *action = Some(json!({"op":"open_post","session":token,"id":p.id})); + } + body_text = attr(attr(body_text, "role", "link"), "tabindex", "0"); + } + body.push(body_text); + body.push(media(p)); + if let Some(q) = &p.quote { + body.push(quote(q, 0)); + } + if detailed { + let mut meta = vec![]; + if let Some(date) = &p.published { + meta.push(el("time", "", vec![text(date_label(date, true))])); + } + if let Some(views) = p.metrics.views { + meta.push(text(" · ")); + meta.push(el("strong", "", vec![text(compact(views))])); + meta.push(text(" Views")); + } + if !meta.is_empty() { + body.push(el("div", "x-post-meta", meta)); + } + body.push(metrics(p, true)); + let mut children = vec![el( + "div", + "x-detail-author", + vec![avatar(&p.author, "post-avatar"), header], + )]; + children.extend(body); + attr( + el("article", "post-card detailed-post", children), + "data-post-id", + &p.id, + ) + } else { + body.insert(0, header); + body.push(metrics(p, false)); + attr( + el( + "article", + "post-card", + vec![ + avatar(&p.author, "post-avatar"), + el("div", "post-content", body), + ], + ), + "data-post-id", + &p.id, + ) + } +} +pub fn reader(content: &Content, target: &Target, token: &str, _restricted: bool) -> Node { + let mut children = vec![]; + if let Some(p) = &content.profile { + children.push(profile(p)); + children.push(attr( + el( + "div", + "x-profile-tabs", + [ + ("Posts", "", ProfileFilter::Posts), + ("Replies", "/with_replies", ProfileFilter::Replies), + ("Reposts", "/reposts", ProfileFilter::Reposts), + ("Media", "/media", ProfileFilter::Media), + ] + .into_iter() + .map(|(label, path, filter)| { + attr( + attr( + link(label, format!("{}{path}", target.canonical_url())), + "target", + "_self", + ), + "class", + if content.profile_filter == filter { + "x-tab active" + } else { + "x-tab" + }, + ) + }) + .collect(), + ), + "role", + "navigation", + )); + } + for (label, error) in [ + ("Profile lookup", &content.profile_error), + ("Posts", &content.timeline_error), + ("The Record could not update", &content.record_error), + ] { + if let Some(error) = error { + children.push(el("p", "notice", vec![text(format!("{label}: {error}"))])); + } + } + if let Some(p) = &content.post { + children.push(post(p, token, true)); + } + if content.timeline.is_none() && content.timeline_error.is_some() { + children.push(button( + "Try again", + json!({"op":"read","session":token,"target":target}), + )); + } + if let Some(t) = &content.timeline { + if !t + .posts + .iter() + .any(|p| content.profile_filter.accepts(p, content.profile.as_ref())) + { + children.push(el( + "p", + "empty-state", + vec![text( + "No matching public posts were returned by the provider.", + )], + )); + } + let filtered: Vec<_> = t + .posts + .iter() + .filter(|p| content.profile_filter.accepts(p, content.profile.as_ref())) + .collect(); + for p in filtered.iter().take(content.visible_limit()) { + children.push(post(p, token, false)); + } + if filtered.len() > content.visible_limit() || t.next_cursor.is_some() { + let cursor = if filtered.len() > content.visible_limit() { + None + } else { + t.next_cursor.as_deref() + }; + let mut sentinel = attr( + button( + if content.timeline_error.is_some() { + "Try again" + } else { + "Load more posts" + }, + json!({"op":"timeline","session":token,"target":target,"cursor":cursor}), + ), + "class", + "x-scroll-sentinel", + ); + if content.timeline_error.is_none() { + sentinel = attr(sentinel, "data-autoload", "true"); + } + children.push(sentinel); + } + } + if let Some(c) = &content.conversation { + for p in &c.thread { + children.push(post(p, token, false)); + } + children.push(el("div", "x-replies-heading", vec![text("Most relevant")])); + for p in &c.replies { + children.push(post(p, token, false)); + } + if c.unavailable_replies > 0 { + children.push(el( + "p", + "notice", + vec![text("Some replies are unavailable from the source.")], + )); + } + if let Some(cursor) = &c.next_cursor { + children.push(attr( + button( + if content.replies_error.is_some() { + "Try again" + } else { + "Show more replies" + }, + json!({"op":"replies","session":token,"target":target,"cursor":cursor}), + ), + "class", + "x-more-replies", + )); + } + } + if let Some(error) = &content.replies_error { + children.push(el( + "p", + "notice", + vec![text(format!("Replies unavailable: {error}"))], + )); + } + children.push(el( + "p", + "attribution", + vec![ + text("LimitlessX · via "), + link("FxTwitter", "https://github.com/FxEmbed/FxEmbed".into()), + ], + )); + let page = if let Some(p) = &content.profile { + Some(( + format!("{} (@{}) / X", p.name, p.handle), + p.biography.clone().unwrap_or_default(), + )) + } else { + content + .post + .as_ref() + .map(|p| (format!("{} on X", p.author.name), p.text.clone())) + }; + let node = frame(children); + if let Some((title, description)) = page { + attr( + attr(node, "data-page-title", title), + "data-page-description", + description, + ) + } else { + node + } +} diff --git a/crates/runtime/src/wasm.rs b/crates/runtime/src/wasm.rs new file mode 100644 index 0000000..96c8121 --- /dev/null +++ b/crates/runtime/src/wasm.rs @@ -0,0 +1,758 @@ +use crate::{Sender, Session, authorized}; +use futures::FutureExt; +use limitless_core::{Error, Notice, Post, Profile, Restriction, Target}; +use limitless_fxtwitter as provider; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::cell::{Cell, RefCell}; +use wasm_bindgen::{JsCast, prelude::*}; +use wasm_bindgen_futures::JsFuture; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace=limitlessBridge)] + fn random_bytes(size: u32) -> js_sys::Uint8Array; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn session_get(key: &str) -> Result; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn session_set(key: &str, value: JsValue) -> Result<(), JsValue>; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn session_remove(keys: JsValue) -> Result<(), JsValue>; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn session_all() -> Result; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn new_tab() -> Result; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn navigate_tab(tab_id: i32, url: &str) -> Result<(), JsValue>; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn show_in_tab(tab_id: i32, context: JsValue) -> Result; + #[wasm_bindgen(js_namespace=limitlessBridge,catch)] + async fn active_context() -> Result; + #[wasm_bindgen(js_namespace=limitlessBridge)] + fn schedule_abort(controller: &web_sys::AbortController, ms: u32) -> i32; + #[wasm_bindgen(js_namespace=limitlessBridge)] + fn cancel_timer(id: i32); + #[wasm_bindgen(js_namespace=globalThis,js_name=fetch)] + fn global_fetch(request: &web_sys::Request) -> js_sys::Promise; +} +fn now() -> f64 { + js_sys::Date::now() +} +fn js(value: &T) -> JsValue { + value + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + .unwrap_or(JsValue::NULL) +} +fn decode(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value).map_err(|_| Error::InvalidResponse) +} +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Ping, + Record, + Clear, + Settings { + enabled: bool, + }, + Inspect { + notices: Vec, + }, + OpenDetected { + notices: Vec, + }, + AutoDetected { + notices: Vec, + }, + Pending, + Current, + OpenCurrent, + OpenManual { + input: String, + }, + Read { + session: String, + target: Target, + }, + Timeline { + session: String, + target: Target, + cursor: Option, + }, + OpenPost { + session: String, + id: String, + }, + Replies { + session: String, + target: Target, + cursor: String, + }, +} +#[derive(Deserialize)] +struct ActiveContext { + url: String, + notices: Vec, + tab_id: i32, +} +fn restricted(target: &Target, notices: &[Notice]) -> bool { + notices.len() <= 8 + && notices + .iter() + .any(|n| Restriction::detect(target, n).is_some()) +} +fn session_key(token: &str) -> Result { + if token.len() == 48 && token.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(format!("reader:{token}")) + } else { + Err(Error::Expired) + } +} +async fn load_session( + token: &str, + target: Option<&Target>, + sender: &Sender, +) -> Result { + let value = session_get(&session_key(token)?) + .await + .map_err(|_| Error::Storage)?; + let s: Session = decode(value).map_err(|_| Error::Expired)?; + if !s.valid(target.unwrap_or(&s.target), now()) || !s.bound_to(sender) { + return Err(Error::Expired); + } + Ok(s) +} +async fn create_reader( + tab_id: i32, + target: Target, + source: Target, + is_restricted: bool, + expires: Option, + pending: bool, + deliberate: bool, +) -> Result { + // Only live reader contexts are retained in browser memory. No disk-backed session ledger. + let all: std::collections::HashMap = + decode(session_all().await.map_err(|_| Error::Storage)?).unwrap_or_default(); + let expired: Vec<_> = all + .iter() + .filter(|(_, s)| s.expires <= now()) + .map(|(k, _)| k.clone()) + .collect(); + if !expired.is_empty() { + session_remove(js(&expired)) + .await + .map_err(|_| Error::Storage)?; + } + if all.len().saturating_sub(expired.len()) >= 100 { + return Err(Error::Unavailable); + } + let token: String = random_bytes(24) + .to_vec() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let s = Session { + target: target.clone(), + source, + pending, + deliberate, + restricted: is_restricted, + created: next_created(), + expires: expires.unwrap_or(now() + 10. * 60. * 1000.), + tab_id, + allowed_posts: vec![], + content: None, + }; + session_set(&session_key(&token)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + Ok( + json!({"session":token,"target":target,"view":crate::view::loading(&target),"load":{"op":"read","session":token,"target":target}}), + ) +} +async fn deliver(tab_id: i32, context: Value) -> Result { + let result: Value = decode( + show_in_tab(tab_id, js(&context)) + .await + .map_err(|_| Error::Unavailable)?, + )?; + if result["ok"] != true { + return Err(Error::Unavailable); + } + Ok(json!({"opened":true})) +} + +thread_local! {static LAST_CREATED:Cell=const{Cell::new(0.)};} +fn next_created() -> f64 { + LAST_CREATED.with(|last| { + let value = now().max(last.get() + 0.001); + last.set(value); + value + }) +} +thread_local! {static COOLDOWN:Cell=const{Cell::new(0.)};} +struct Timer(i32); +impl Drop for Timer { + fn drop(&mut self) { + cancel_timer(self.0) + } +} +async fn fetch( + target: &Target, + timeline: bool, + cursor: Option<&str>, +) -> Result<(u16, String), Error> { + fetch_url(provider::endpoint(target, timeline, cursor)?).await +} +async fn fetch_url(endpoint: String) -> Result<(u16, String), Error> { + if COOLDOWN.with(|v| v.get() > now()) { + return Err(Error::RateLimited); + } + let controller = web_sys::AbortController::new().map_err(|_| Error::Network)?; + let init = web_sys::RequestInit::new(); + init.set_method("GET"); + init.set_credentials(web_sys::RequestCredentials::Omit); + init.set_redirect(web_sys::RequestRedirect::Error); + init.set_cache(web_sys::RequestCache::NoStore); + init.set_referrer(""); + init.set_signal(Some(&controller.signal())); + let request = + web_sys::Request::new_with_str_and_init(&endpoint, &init).map_err(|_| Error::Network)?; + request + .headers() + .set("Accept", "application/json") + .map_err(|_| Error::Network)?; + let _timer = Timer(schedule_abort(&controller, 15000)); + let map_net = |_: JsValue| { + if controller.signal().aborted() { + Error::Timeout + } else { + Error::Network + } + }; + let response: web_sys::Response = JsFuture::from(global_fetch(&request)) + .await + .map_err(map_net)? + .dyn_into() + .map_err(|_| Error::InvalidResponse)?; + let status = response.status(); + if status == 429 { + let retry = response.headers().get("Retry-After").ok().flatten(); + let seconds = retry + .and_then(|s| { + s.parse::().ok().or_else(|| { + let date = js_sys::Date::parse(&s); + date.is_finite().then_some((date - now()) / 1000.) + }) + }) + .unwrap_or(60.) + .clamp(1., 3600.); + COOLDOWN.with(|v| v.set(now() + seconds * 1000.)); + return Err(Error::RateLimited); + } + if status >= 500 { + return Err(Error::Unavailable); + } + if response + .headers() + .get("Content-Length") + .ok() + .flatten() + .and_then(|s| s.parse::().ok()) + .is_some_and(|n| n > 4 * 1024 * 1024) + { + controller.abort(); + return Err(Error::InvalidResponse); + } + let reader = response + .body() + .ok_or(Error::InvalidResponse)? + .get_reader() + .unchecked_into::(); + let mut bytes = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.map_err(map_net)?; + if js_sys::Reflect::get(&chunk, &"done".into()) + .ok() + .and_then(|v| v.as_bool()) + == Some(true) + { + break; + } + let chunk = + js_sys::Reflect::get(&chunk, &"value".into()).map_err(|_| Error::InvalidResponse)?; + let chunk = js_sys::Uint8Array::new(&chunk); + if bytes.len() + chunk.length() as usize > 4 * 1024 * 1024 { + controller.abort(); + return Err(Error::InvalidResponse); + } + bytes.extend(chunk.to_vec()); + } + Ok(( + status, + String::from_utf8(bytes).map_err(|_| Error::InvalidResponse)?, + )) +} +async fn get_post(target: &Target) -> Result { + let Target::Post { id } = target else { + return Err(Error::InvalidTarget); + }; + let (h, b) = fetch(target, false, None).await?; + provider::parse_post(h, &b, id) +} +async fn get_conversation( + target: &Target, + cursor: Option<&str>, +) -> Result { + let Target::Post { id } = target else { + return Err(Error::InvalidTarget); + }; + let (h, b) = fetch_url(provider::conversation_endpoint(id, cursor)?).await?; + provider::parse_conversation(h, &b, id) +} +type IdentityFuture = + futures::future::Shared>>; +thread_local! {static IDENTITIES:RefCell>=RefCell::new(std::collections::HashMap::new());} +async fn resolve_identity(handle: &str) -> Result { + let future = IDENTITIES.with(|cache| { + let mut cache = cache.borrow_mut(); + cache.retain(|_, (expires, _)| *expires > now()); + if let Some((_, future)) = cache.get(handle) { + return future.clone(); + } + if cache.len() >= 64 { + cache.clear(); + } + let handle = handle.to_string(); + let query = handle.clone(); + let future = async move { + let (http, body) = fetch_url(provider::identity_endpoint(&query)?).await?; + provider::parse_identity(http, &body, &query) + } + .boxed_local() + .shared(); + cache.insert(handle, (now() + 300_000., future.clone())); + future + }); + let result = future.clone().await; + if result.is_err() { + IDENTITIES.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache + .get(handle) + .is_some_and(|(_, current)| current.ptr_eq(&future)) + { + cache.remove(handle); + } + }); + } + result +} +async fn get_profile(target: &Target) -> Result { + let Target::Profile { handle } = target else { + return Err(Error::InvalidTarget); + }; + let (h, b) = fetch(target, false, None).await?; + match provider::parse_profile(h, &b, handle) { + Err(Error::NotFound) => { + let id = resolve_identity(handle).await?; + let (h, b) = fetch_url(provider::id_endpoint(&id, false, None)?).await?; + let profile = provider::parse_profile(h, &b, handle)?; + if profile.id != id { + return Err(Error::InvalidResponse); + } + Ok(profile) + } + result => result, + } +} +async fn timeline( + token: &str, + session: &mut Session, + cursor: Option<&str>, +) -> Result { + if !matches!(session.target, Target::Profile { .. }) { + return Err(Error::InvalidTarget); + } + let (h, b) = fetch(&session.target, true, cursor).await?; + let t = match provider::parse_timeline(h, &b) { + Err(Error::NotFound) => { + let Target::Profile { handle } = &session.target else { + return Err(Error::InvalidTarget); + }; + let id = resolve_identity(handle).await?; + let (h, b) = fetch_url(provider::id_endpoint(&id, true, cursor)?).await?; + provider::parse_timeline(h, &b)? + } + result => result?, + }; + for post in &t.posts { + if !session.allowed_posts.contains(&post.id) && session.allowed_posts.len() < 2000 { + session.allowed_posts.push(post.id.clone()); + } + } + session_set(&session_key(token)?, js(session)) + .await + .map_err(|_| Error::Storage)?; + Ok(t) +} +async fn handle(request: Request, sender: Sender) -> Result { + let automatic = matches!(&request, Request::AutoDetected { .. }); + match request { + Request::Ping => Ok(json!({"runtime":"rust-wasm"})), + Request::Record => Ok(json!(limitless_storage::record(None, None, false).await?)), + Request::Settings { enabled } => Ok(json!( + limitless_storage::record(None, Some(enabled), false).await? + )), + Request::Clear => Ok(json!(limitless_storage::record(None, None, true).await?)), + Request::Inspect { notices } => { + let target = Target::parse(sender.current_url())?; + Ok(json!({"eligible":restricted(&target,¬ices),"target":target})) + } + Request::OpenDetected { notices } | Request::AutoDetected { notices } => { + let target = Target::parse(sender.current_url())?; + if !restricted(&target, ¬ices) { + return Err(Error::Unauthorized); + } + create_reader( + sender.tab_id.ok_or(Error::Unauthorized)?, + target.clone(), + target, + true, + None, + false, + !automatic, + ) + .await + } + Request::OpenManual { input } => { + let target = Target::parse(&input)?; + let tab_id = new_tab().await.map_err(|_| Error::Unavailable)?; + create_reader( + tab_id, + target.clone(), + target.clone(), + false, + None, + true, + true, + ) + .await?; + navigate_tab(tab_id, &target.canonical_url()) + .await + .map_err(|_| Error::Unavailable)?; + Ok(json!({"opened":true})) + } + Request::Pending => { + let all: std::collections::HashMap = + decode(session_all().await.map_err(|_| Error::Storage)?)?; + let mut sessions: Vec<_> = all.into_iter().collect(); + sessions.sort_by(|a, b| b.1.created.total_cmp(&a.1.created)); + for (key, mut session) in sessions { + if session.valid(&session.target, now()) + && session.bound_to(&sender) + && Target::parse(sender.current_url()) + .is_ok_and(|target| target == session.target) + { + session.pending = false; + if let Some(content) = session.content.as_mut() { + let filter = crate::view::ProfileFilter::from_url(sender.current_url()); + if content.profile_filter != filter { + content.profile_filter = filter; + content.visible_posts = 5; + } + } + session_set(&key, js(&session)) + .await + .map_err(|_| Error::Storage)?; + let token = key.strip_prefix("reader:").ok_or(Error::Expired)?; + if let Some(content) = &session.content { + return Ok( + json!({"view":crate::view::reader(content,&session.target,token,session.restricted)}), + ); + } + return Ok( + json!({"session":token,"target":session.target,"view":crate::view::loading(&session.target),"load":{"op":"read","session":token,"target":session.target}}), + ); + } + } + Ok(Value::Null) + } + Request::Current | Request::OpenCurrent => { + let open = matches!(request, Request::OpenCurrent); + let c: ActiveContext = decode(active_context().await.map_err(|_| Error::Unavailable)?)?; + match Target::parse(&c.url) { + Ok(target) => { + let is_restricted = restricted(&target, &c.notices); + if open { + let context = create_reader( + c.tab_id, + target.clone(), + target, + is_restricted, + None, + false, + true, + ) + .await?; + deliver(c.tab_id, context).await + } else { + Ok(json!({"target":target,"restricted":is_restricted})) + } + } + Err(_) => { + if open { + Err(Error::InvalidTarget) + } else { + Ok(Value::Null) + } + } + } + } + Request::Read { session, target } => { + let mut s = load_session(&session, Some(&target), &sender).await?; + match &target { + Target::Post { .. } => { + let (post, conversation, replies_error) = match get_conversation(&target, None) + .await + { + Ok(conversation) => (conversation.post.clone(), Some(conversation), None), + Err( + error @ (Error::Protected + | Error::Deleted + | Error::Suspended + | Error::RateLimited + | Error::Network + | Error::Timeout), + ) => return Err(error), + Err(error) => (get_post(&target).await?, None, Some(error.to_string())), + }; + if let Some(conversation) = &conversation { + for post in conversation + .thread + .iter() + .chain(conversation.replies.iter()) + { + if !s.allowed_posts.contains(&post.id) { + s.allowed_posts.push(post.id.clone()); + } + } + } + let record_error = if s.restricted { + limitless_storage::record(Some(("post", &post.id)), None, false) + .await + .err() + .map(|e| e.to_string()) + } else { + None + }; + s.content = Some(crate::view::Content { + post: Some(post), + conversation, + replies_error, + record_error, + ..Default::default() + }); + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ) + } + Target::Profile { handle } => { + let (profile, feed) = + futures::join!(get_profile(&target), timeline(&session, &mut s, None)); + if let Err(error @ (Error::Protected | Error::Suspended | Error::Deleted)) = + &profile + { + return Err(error.clone()); + } + if let Err(error) = &profile + && feed.is_err() + { + return Err(error.clone()); + } + let mut profile_error = profile.as_ref().err().map(ToString::to_string); + let profile = profile.ok().or_else(|| { + feed.as_ref() + .ok()? + .posts + .iter() + .find(|p| p.author.handle.eq_ignore_ascii_case(handle)) + .map(|p| p.author.clone()) + }); + if profile.is_some() { + profile_error = None; + } + let record_error = if s.restricted { + if let Some(p) = profile.as_ref() { + limitless_storage::record(Some(("profile", &p.id)), None, false) + .await + .err() + .map(|e| e.to_string()) + } else { + None + } + } else { + None + }; + let feed_error = feed.as_ref().err().map(ToString::to_string); + s.content = Some(crate::view::Content { + profile_filter: crate::view::ProfileFilter::from_url(sender.current_url()), + profile, + timeline: feed.ok(), + profile_error, + timeline_error: feed_error, + record_error, + ..Default::default() + }); + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ) + } + } + } + Request::Timeline { + session, + target, + cursor, + } => { + let mut s = load_session(&session, Some(&target), &sender).await?; + let content = s.content.as_mut().ok_or(Error::Expired)?; + if !content.reveal_buffered() { + let expected = content + .timeline + .as_ref() + .ok_or(Error::InvalidTarget)? + .next_cursor + .as_ref(); + if expected.is_none() || expected != cursor.as_ref() { + return Err(Error::InvalidTarget); + } + let visible = content.visible_limit(); + let next = match timeline(&session, &mut s, cursor.as_deref()).await { + Ok(next) => next, + Err(error) => { + let content = s.content.as_mut().ok_or(Error::Expired)?; + content.timeline_error = Some(error.to_string()); + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + return Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ); + } + }; + let content = s.content.as_mut().ok_or(Error::Expired)?; + let feed = content.timeline.as_mut().ok_or(Error::InvalidTarget)?; + for post in next.posts { + if !feed.posts.iter().any(|p| p.id == post.id) { + feed.posts.push(post); + } + } + feed.next_cursor = next + .next_cursor + .filter(|next| Some(next) != cursor.as_ref()); + content.visible_posts = (visible + 5).min(feed.posts.len()); + content.timeline_error = None; + } + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ) + } + Request::Replies { + session, + target, + cursor, + } => { + let mut s = load_session(&session, Some(&target), &sender).await?; + let next = match get_conversation(&target, Some(&cursor)).await { + Ok(next) => next, + Err(error) => { + let content = s.content.as_mut().ok_or(Error::Expired)?; + content.replies_error = Some(error.to_string()); + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + return Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ); + } + }; + if let Some(content) = s.content.as_mut() { + content.replies_error = None; + } + for post in &next.replies { + if !s.allowed_posts.contains(&post.id) && s.allowed_posts.len() < 2000 { + s.allowed_posts.push(post.id.clone()); + } + } + let conversation = s + .content + .as_mut() + .and_then(|c| c.conversation.as_mut()) + .ok_or(Error::Expired)?; + for post in next.replies { + if !conversation.replies.iter().any(|p| p.id == post.id) { + conversation.replies.push(post); + } + } + conversation.unavailable_replies += next.unavailable_replies; + conversation.next_cursor = next.next_cursor.filter(|c| c != &cursor); + session_set(&session_key(&session)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + Ok( + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), + ) + } + Request::OpenPost { session, id } => { + let s = load_session(&session, None, &sender).await?; + if !limitless_core::valid_id(&id) || !s.allowed_posts.contains(&id) { + return Err(Error::Unauthorized); + } + create_reader( + s.tab_id, + Target::Post { id }, + s.source, + s.restricted, + Some(s.expires), + false, + true, + ) + .await + } + } +} +#[wasm_bindgen] +pub async fn dispatch(message: JsValue, sender: JsValue) -> JsValue { + let raw_message: Value = decode(message.clone()).unwrap_or(Value::Null); + let result = async { + let sender: Sender = decode(sender).map_err(|_| Error::Unauthorized)?; + let raw: Value = decode(message.clone())?; + let op = raw + .get("op") + .and_then(Value::as_str) + .ok_or(Error::Unauthorized)?; + if !authorized(&sender, op) { + return Err(Error::Unauthorized); + } + let request: Request = decode(message)?; + handle(request, sender).await + } + .await; + js(&match result { + Ok(data) => json!({"ok":true,"data":data}), + Err(error) => { + json!({"ok":false,"message":error.to_string(),"view":crate::view::error(&error, matches!(raw_message["op"].as_str(),Some("read"|"timeline")).then_some(raw_message)),"error":error}) + } + }) +} diff --git a/crates/runtime/tests/security.rs b/crates/runtime/tests/security.rs new file mode 100644 index 0000000..32666c3 --- /dev/null +++ b/crates/runtime/tests/security.rs @@ -0,0 +1,108 @@ +use limitless_core::Target; +use limitless_runtime::{Sender, Session, authorized}; +fn sender(url: &str) -> Sender { + Sender { + id: "abc".into(), + url: url.into(), + page_url: None, + tab_id: Some(4), + frame_id: 0, + extension_origin: "chrome-extension://abc/".into(), + } +} +#[test] +fn page_cannot_call_privileged_operations() { + assert!(authorized( + &sender("chrome-extension://abc/popup.html"), + "record" + )); + assert!(!authorized(&sender("https://x.com/test"), "record")); + assert!(authorized(&sender("https://x.com/test"), "inspect")); + assert!(!authorized(&sender("https://evil.test"), "inspect")); + assert!(!authorized( + &Sender { + frame_id: 2, + ..sender("https://x.com/test") + }, + "inspect" + )); + assert!(!authorized( + &sender("chrome-extension://abc/reader.html.fake"), + "record" + )); +} +#[test] +fn sessions_expire_and_bind_to_exact_target() { + let target = Target::parse("@test").unwrap(); + let s = Session { + target: target.clone(), + source: target.clone(), + pending: false, + restricted: true, + deliberate: true, + created: 0., + expires: 10000., + tab_id: 4, + allowed_posts: vec![], + content: None, + }; + assert!(s.valid(&target, 9999.)); + assert!(!s.valid(&target, 10000.)); + assert!(!s.valid(&Target::parse("@other").unwrap(), 9999.)); +} + +#[test] +fn inline_session_cannot_be_moved_to_another_x_tab_or_route() { + let target = Target::parse("@test").unwrap(); + let s = Session { + target: target.clone(), + source: target, + restricted: true, + deliberate: true, + pending: false, + created: 0., + expires: 10000., + tab_id: 4, + allowed_posts: vec![], + content: None, + }; + assert!(s.bound_to(&sender("https://x.com/test"))); + assert!(!s.bound_to(&Sender { + tab_id: Some(5), + ..sender("https://x.com/test") + })); + assert!(!s.bound_to(&sender("https://x.com/other"))); + assert!(!authorized( + &sender("chrome-extension://abc/reader.html"), + "read" + )); + assert!(!authorized( + &sender("chrome-extension://abc/options.html"), + "record" + )); +} + +#[test] +fn spa_route_is_current_but_cannot_change_its_document_origin() { + let mut origin = sender("https://x.com/jack/status/20"); + origin.page_url = Some("https://x.com/jack/status/21".into()); + assert!(authorized(&origin, "auto_detected")); + assert_eq!(origin.current_url(), "https://x.com/jack/status/21"); + let session = Session { + target: Target::parse("https://x.com/jack/status/20").unwrap(), + source: Target::parse("https://x.com/jack/status/20").unwrap(), + restricted: true, + deliberate: false, + pending: false, + created: 0., + expires: 10000., + tab_id: 4, + allowed_posts: vec![], + content: None, + }; + assert!(!session.bound_to(&origin)); + origin.page_url = Some("https://evil.test/jack/status/21".into()); + assert!(!authorized(&origin, "auto_detected")); + origin.page_url = Some("chrome-extension://abc/popup.html".into()); + assert!(!authorized(&origin, "record")); +} diff --git a/crates/runtime/tests/view.rs b/crates/runtime/tests/view.rs new file mode 100644 index 0000000..e6af8bd --- /dev/null +++ b/crates/runtime/tests/view.rs @@ -0,0 +1,76 @@ +use limitless_core::{Profile, Target, Timeline}; +use limitless_runtime::view::{Content, Node, reader}; +fn nodes<'a>(node: &'a Node, out: &mut Vec<&'a Node>) { + out.push(node); + if let Node::Element { children, .. } = node { + for child in children { + nodes(child, out) + } + } +} +#[test] +fn provider_text_stays_a_text_node_and_sessions_never_become_dom_attributes() { + let payload = ""; + let content = Content { + profile: Some(Profile { + id: "12".into(), + handle: "jack".into(), + name: payload.into(), + biography: Some(payload.into()), + avatar: None, + ..Default::default() + }), + timeline: Some(Timeline { + posts: vec![], + next_cursor: Some("next+/=".into()), + }), + ..Default::default() + }; + let view = reader( + &content, + &Target::parse("@jack").unwrap(), + "secret-session", + true, + ); + let mut all = vec![]; + nodes(&view, &mut all); + assert!(all.iter().any(|n| matches!(n,Node::Text(s) if s==payload))); + assert!( + all.iter() + .all(|n| !matches!(n,Node::Element{tag,..} if tag=="script"||tag=="iframe")) + ); + assert!(all.iter().all(|n|!matches!(n,Node::Element{attrs,..} if attrs.iter().any(|(_,v)|v.contains("secret-session"))))); + assert!(all.iter().any(|n|matches!(n,Node::Element{action:Some(a),..} if a["op"]=="timeline"&&a["session"]=="secret-session"&&a["cursor"]=="next+/="))); +} + +#[test] +fn timeline_starts_with_five_and_reveals_buffered_batches() { + let mut content = Content { + timeline: Some(Timeline { + posts: (1..=12) + .map(|id| limitless_core::Post { + id: id.to_string(), + text: format!("post {id}"), + ..Default::default() + }) + .collect(), + next_cursor: Some("next".into()), + }), + ..Default::default() + }; + let target = Target::parse("@jack").unwrap(); + let count = |c: &Content| { + let view = reader(c, &target, "token", false); + let mut all = vec![]; + nodes(&view, &mut all); + all.iter() + .filter(|n| matches!(n,Node::Element{tag,..} if tag=="article")) + .count() + }; + assert_eq!(count(&content), 5); + assert!(content.reveal_buffered()); + assert_eq!(count(&content), 10); + assert!(content.reveal_buffered()); + assert_eq!(count(&content), 12); + assert!(!content.reveal_buffered()); +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml new file mode 100644 index 0000000..40b424c --- /dev/null +++ b/crates/storage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "limitless-storage" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +limitless-core.workspace = true +serde.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +rexie = "0.6" +wasm-bindgen.workspace = true +serde-wasm-bindgen.workspace = true +js-sys.workspace = true diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs new file mode 100644 index 0000000..307db66 --- /dev/null +++ b/crates/storage/src/lib.rs @@ -0,0 +1,5 @@ +//! IndexedDB is the source of truth. Every settings/ledger change uses one transaction. +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(target_arch = "wasm32")] +pub use wasm::*; diff --git a/crates/storage/src/wasm.rs b/crates/storage/src/wasm.rs new file mode 100644 index 0000000..6b52aa7 --- /dev/null +++ b/crates/storage/src/wasm.rs @@ -0,0 +1,94 @@ +use limitless_core::{Error, Record, record_key}; +use rexie::{ObjectStore, Rexie, TransactionMode}; +use serde::{Deserialize, Serialize}; +use std::{cell::RefCell, rc::Rc}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace=limitlessBridge)] + fn record_changed(); + #[wasm_bindgen(js_namespace=limitlessBridge)] + fn random_bytes(size: u32) -> js_sys::Uint8Array; +} +thread_local! {static DB:RefCell>>=const{RefCell::new(None)};} +fn err(r: Result) -> Result { + r.map_err(|_| Error::Storage) +} +fn val(v: &T) -> Result { + serde_wasm_bindgen::to_value(v).map_err(|_| Error::Storage) +} +#[derive(Serialize, Deserialize)] +struct Meta { + salt: Vec, + enabled: bool, + since: f64, +} +async fn database() -> Result, Error> { + if let Some(db) = DB.with(|d| d.borrow().clone()) { + return Ok(db); + } + let db = Rc::new(err(Rexie::builder("limitlessx") + .version(1) + .add_object_store(ObjectStore::new("meta")) + .add_object_store(ObjectStore::new("profiles")) + .add_object_store(ObjectStore::new("posts")) + .build() + .await)?); + DB.with(|d| *d.borrow_mut() = Some(db.clone())); + Ok(db) +} +/// A read/write transaction also serializes initial salt creation across contexts. +pub async fn record( + kind: Option<(&str, &str)>, + enabled: Option, + clear: bool, +) -> Result { + let db = database().await?; + let tx = err(db.transaction(&["meta", "profiles", "posts"], TransactionMode::ReadWrite))?; + let meta_store = err(tx.store("meta"))?; + let key = JsValue::from_str("settings"); + let mut meta = match err(meta_store.get(key.clone()).await)? { + Some(v) => serde_wasm_bindgen::from_value::(v).map_err(|_| Error::Storage)?, + None => Meta { + salt: random_bytes(32).to_vec(), + enabled: true, + since: js_sys::Date::now(), + }, + }; + if let Some(enabled) = enabled { + meta.enabled = enabled; + } + let profiles = err(tx.store("profiles"))?; + let posts = err(tx.store("posts"))?; + if clear { + err(profiles.clear().await)?; + err(posts.clear().await)?; + meta.salt = random_bytes(32).to_vec(); + meta.since = js_sys::Date::now(); + } + err(meta_store.put(&val(&meta)?, Some(&key)).await)?; + if meta.enabled + && let Some((kind, id)) = kind + { + let store = match kind { + "profile" => &profiles, + "post" => &posts, + _ => return Err(Error::Storage), + }; + let key = JsValue::from_str(&record_key(&meta.salt, kind, id)); + // put is idempotent under the store's unique primary key, in the same transaction as settings. + err(store.put(&JsValue::TRUE, Some(&key)).await)?; + } + let result = Record { + profiles: err(profiles.count(None).await)?, + posts: err(posts.count(None).await)?, + enabled: meta.enabled, + since: meta.since, + }; + err(tx.done().await)?; + if kind.is_some() || enabled.is_some() || clear { + record_changed(); + } + Ok(result) +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml new file mode 100644 index 0000000..c833ee9 --- /dev/null +++ b/crates/ui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "limitless-ui" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +limitless-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +leptos = { version = "=0.8.22", features = ["csr"] } +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +serde-wasm-bindgen.workspace = true +js-sys.workspace = true +web-sys = { workspace = true, features = ["Window", "Location", "Document", "HtmlElement", "UrlSearchParams"] } diff --git a/crates/ui/src/app.rs b/crates/ui/src/app.rs new file mode 100644 index 0000000..cc737a6 --- /dev/null +++ b/crates/ui/src/app.rs @@ -0,0 +1,59 @@ +use leptos::prelude::*; +use serde::Serialize; +use serde_json::Value; +use wasm_bindgen::prelude::*; +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace=limitlessUI,catch)] + async fn send(message: JsValue) -> Result; + #[wasm_bindgen(js_namespace=limitlessUI)] + pub fn asset_url(path: &str) -> String; +} +pub async fn call(message: Value) -> Result { + let value = message + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + .map_err(|_| "Could not prepare request.".to_owned())?; + let result = send(value) + .await + .map_err(|_| "Could not reach LimitlessX. Reopen the extension.".to_owned())?; + let value: Value = serde_wasm_bindgen::from_value(result) + .map_err(|_| "Unexpected extension response.".to_owned())?; + if value["ok"] == true { + Ok(value["data"].clone()) + } else { + Err(value["message"] + .as_str() + .unwrap_or("Could not complete this action.") + .into()) + } +} +#[component] +pub fn Header() -> impl IntoView { + view! {

"LimitlessX"

} +} +#[component] +pub fn Footer() -> impl IntoView { + view! {} +} +#[component] +pub fn Attribution() -> impl IntoView { + view! {

"Public data via ""FxTwitter""."

} +} +#[wasm_bindgen(start)] +pub fn start() { + let window = web_sys::window().unwrap(); + if window + .location() + .protocol() + .ok() + .is_some_and(|p| p == "chrome-extension:" || p == "moz-extension:") + { + window + .document() + .unwrap() + .body() + .unwrap() + .set_class_name("popup-page"); + leptos::mount::mount_to_body(|| view! {}); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs new file mode 100644 index 0000000..d67d7f1 --- /dev/null +++ b/crates/ui/src/lib.rs @@ -0,0 +1,6 @@ +#[cfg(target_arch = "wasm32")] +mod app; +#[cfg(target_arch = "wasm32")] +mod popup; +#[cfg(target_arch = "wasm32")] +mod record; diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs new file mode 100644 index 0000000..a45ef6c --- /dev/null +++ b/crates/ui/src/popup.rs @@ -0,0 +1,52 @@ +use crate::app::{Attribution, Footer, Header, call}; +use leptos::prelude::*; +use limitless_core::Target; +use serde::Deserialize; +use serde_json::json; +#[derive(Clone, Deserialize)] +struct Current { + target: Target, + restricted: bool, +} +#[component] +pub fn Popup() -> impl IntoView { + let current = RwSignal::new(None::); + let input = RwSignal::new(String::new()); + let error = RwSignal::new(None::); + let busy = RwSignal::new(false); + let settings = RwSignal::new(false); + leptos::task::spawn_local(async move { + if let Ok(v) = call(json!({"op":"current"})).await { + current.set(serde_json::from_value(v).ok()); + } + }); + let open = move |current_page: bool| { + error.set(None); + busy.set(true); + let message = if current_page { + json!({"op":"open_current"}) + } else { + json!({"op":"open_manual","input":input.get_untracked()}) + }; + leptos::task::spawn_local(async move { + match call(message).await { + Ok(_) => {} + Err(e) => error.set(Some(e)), + } + busy.set(false); + }); + }; + view! {

"Read public X content."

+ {move ||current.get().map(|c|view!{

"Current page"

{c.target.label()}

{if c.restricted{"Regional restriction detected"}else{"No regional restriction detected"}}

})} +
+ + + +
+ {move ||error.get().map(|e|view!{})} +

"Restricted public content opens automatically on X."

+ + {move ||settings.get().then(||view!{})} +
+
} +} diff --git a/crates/ui/src/record.rs b/crates/ui/src/record.rs new file mode 100644 index 0000000..98319b8 --- /dev/null +++ b/crates/ui/src/record.rs @@ -0,0 +1,43 @@ +use crate::app::call; +use leptos::prelude::*; +use limitless_core::Record; +use serde_json::json; +#[component] +pub fn RecordPanel(controls: RwSignal) -> impl IntoView { + let record = RwSignal::new(None::); + let error = RwSignal::new(None::); + let busy = RwSignal::new(false); + let refresh = move || { + leptos::task::spawn_local(async move { + match call(json!({"op":"record"})).await { + Ok(v) => match serde_json::from_value(v) { + Ok(v) => record.set(Some(v)), + Err(_) => error.set(Some("Could not read The Record.".into())), + }, + Err(e) => error.set(Some(e)), + } + }) + }; + refresh(); + let _focus = window_event_listener(leptos::ev::focus, move |_| refresh()); + let update = move |message| { + busy.set(true); + error.set(None); + leptos::task::spawn_local(async move { + match call(message).await { + Ok(v) => { + record.set(serde_json::from_value(v).ok()); + } + Err(e) => error.set(Some(e)), + } + busy.set(false); + }); + }; + view! {

"The Record"

+ {move ||record.get().map(|r|view!{
{r.profiles}"accounts viewed"
{r.posts}"posts viewed"
+

{if r.enabled{"Unique restricted content ⁜ Stored on this device"}else{"Recording disabled ⁜ Stored on this device"}}

})} + {move ||if record.get().is_none() && error.get().is_none(){Some(view!{

"Loading local record…"

})}else{None}} + {move ||controls.get().then(||view!{
})} + {move ||error.get().map(|e|view!{})} +
} +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 0000000..5dc6c60 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde_json.workspace = true +zip = { version = "6", default-features = false, features = ["deflate"] } diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000..c119280 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,116 @@ +mod package; +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, ExitCode}, +}; +fn run(cmd: &mut Command) -> Result<(), String> { + let status = cmd.status().map_err(|e| e.to_string())?; + if status.success() { + Ok(()) + } else { + Err(format!("command failed: {cmd:?}")) + } +} +fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { + fs::create_dir_all(to).map_err(|e| e.to_string())?; + for e in fs::read_dir(from).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.path().is_dir() { + copy_dir(&e.path(), &to.join(e.file_name()))? + } else { + fs::copy(e.path(), to.join(e.file_name())).map_err(|e| e.to_string())?; + } + } + Ok(()) +} +fn build(browser: &str) -> Result<(), String> { + if !["chromium", "firefox"].contains(&browser) { + return Err("Expected chromium or firefox".into()); + } + let adapters = Path::new("target/adapters"); + if adapters.exists() { + fs::remove_dir_all(adapters).map_err(|e| e.to_string())?; + } + run(Command::new("npm").args(["run", "build:adapters"]))?; + run(Command::new("cargo").args([ + "build", + "--locked", + "--release", + "--target", + "wasm32-unknown-unknown", + "-p", + "limitless-runtime", + "-p", + "limitless-ui", + ]))?; + let out = PathBuf::from("dist").join(browser); + if out.exists() { + fs::remove_dir_all(&out).map_err(|e| e.to_string())?; + } + copy_dir(Path::new("extension/static"), &out)?; + copy_dir(Path::new("target/adapters"), &out.join("adapters"))?; + fs::copy( + format!("extension/manifests/{browser}.json"), + out.join("manifest.json"), + ) + .map_err(|e| e.to_string())?; + for name in ["runtime", "ui"] { + let dest = out.join("pkg").join(name); + fs::create_dir_all(&dest).map_err(|e| e.to_string())?; + run( + Command::new(env::var("WASM_BINDGEN").unwrap_or("wasm-bindgen".into())) + .arg(format!( + "target/wasm32-unknown-unknown/release/limitless_{name}.wasm" + )) + .args(["--target", "web", "--no-typescript", "--out-dir"]) + .arg(&dest), + )?; + let wasm = dest.join(format!("limitless_{name}_bg.wasm")); + run( + Command::new(env::var("WASM_OPT").unwrap_or("wasm-opt".into())) + .arg(&wasm) + .args([ + "-Os", + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "-o", + ]) + .arg(&wasm), + )?; + } + package::validate(&out)?; + println!("Built {}", out.display()); + Ok(()) +} +fn main() -> ExitCode { + env::set_current_dir( + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(), + ) + .unwrap(); + let args: Vec<_> = env::args().skip(1).collect(); + let result = match args.first().map(String::as_str) { + Some("build") => build(args.get(1).map(String::as_str).unwrap_or("")), + Some("package") => (|| { + build("chromium")?; + build("firefox")?; + package::release() + })(), + Some("test") => (|| { + run(Command::new("npm").args(["run", "typecheck"]))?; + run(Command::new("cargo").args(["test", "--workspace", "--locked"])) + })(), + _ => Err("Usage: cargo xtask build | test | package".into()), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/xtask/src/package.rs b/crates/xtask/src/package.rs new file mode 100644 index 0000000..e561fb6 --- /dev/null +++ b/crates/xtask/src/package.rs @@ -0,0 +1,226 @@ +use serde_json::{Value, json}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub fn validate_manifest(m: &Value) -> Result<(), String> { + if m["manifest_version"] != 3 + || m["action"]["default_popup"] != "popup.html" + || m.get("options_ui").is_some() + || m.get("externally_connectable").is_some() + { + return Err("Only the MV3 popup interface is allowed".into()); + } + if m["permissions"] != json!(["activeTab", "storage"]) + || m["host_permissions"] != json!(["https://api.fxtwitter.com/*"]) + { + return Err("Unexpected browser permissions".into()); + } + let matches = json!([ + "https://x.com/*", + "https://www.x.com/*", + "https://twitter.com/*", + "https://www.twitter.com/*" + ]); + if m["content_scripts"][0]["matches"] != matches + || m["content_scripts"][0]["js"] != json!(["adapters/player.js", "adapters/content.js"]) + { + return Err("Unexpected content-script scope".into()); + } + if m["web_accessible_resources"] != json!([{"resources":["styles/app.css"],"matches":matches}]) + { + return Err("Only the stylesheet may be exposed to X".into()); + } + let csp = m["content_security_policy"]["extension_pages"] + .as_str() + .ok_or("Missing CSP")?; + if !csp.contains("script-src 'self' 'wasm-unsafe-eval';") + || csp.contains("'unsafe-eval'") + || csp.contains("'unsafe-inline'") + { + return Err("Unexpected executable policy".into()); + } + Ok(()) +} +fn collect(source: &Path, name: &str, out: &mut Vec<(PathBuf, String)>) -> Result<(), String> { + let metadata = fs::symlink_metadata(source).map_err(|e| e.to_string())?; + if metadata.file_type().is_symlink() { + return Err("Symlinks are not permitted in release archives".into()); + } + if metadata.is_dir() { + let mut entries = fs::read_dir(source) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let child = entry.file_name().to_string_lossy().into_owned(); + if matches!(child.as_str(), "artifacts" | "__pycache__") { + continue; + } + let next = if name.is_empty() { + child + } else { + format!("{name}/{child}") + }; + collect(&entry.path(), &next, out)?; + } + } else { + if name.starts_with('/') || name.split('/').any(|part| part == "..") { + return Err("Unsafe archive entry".into()); + } + out.push((source.into(), name.into())); + } + Ok(()) +} +pub fn validate(root: &Path) -> Result<(), String> { + let m: Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + validate_manifest(&m)?; + for name in [ + "popup.html", + "adapters/background.js", + "adapters/content.js", + "adapters/player.js", + "adapters/ui.js", + "styles/app.css", + "brand/mark.svg", + "brand/icon-16.png", + "brand/icon-32.png", + "brand/icon-48.png", + "brand/icon-128.png", + ] { + if !root.join(name).is_file() { + return Err(format!("Missing packaged resource: {name}")); + } + } + let mut entries = vec![]; + collect(root, "", &mut entries)?; + if entries + .iter() + .filter(|(_, name)| name.ends_with(".html")) + .map(|(_, name)| name.as_str()) + .collect::>() + != vec!["popup.html"] + { + return Err("The popup must be the only HTML interface".into()); + } + for module in ["runtime", "ui"] { + let bytes = fs::read(root.join(format!("pkg/{module}/limitless_{module}_bg.wasm"))) + .map_err(|e| e.to_string())?; + if !bytes.starts_with(b"\0asm") { + return Err("Invalid packaged WASM".into()); + } + } + Ok(()) +} +pub fn archive(destination: &Path, sources: &[(&Path, &str)]) -> Result<(), String> { + let mut entries = vec![]; + for (source, name) in sources { + collect(source, name, &mut entries)?; + } + entries.sort_by(|a, b| a.1.cmp(&b.1)); + let file = fs::File::create(destination).map_err(|e| e.to_string())?; + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o644); + for (source, name) in entries { + writer + .start_file(name, options) + .map_err(|e| e.to_string())?; + writer + .write_all(&fs::read(source).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + } + writer.finish().map_err(|e| e.to_string())?; + Ok(()) +} +pub fn release() -> Result<(), String> { + fs::create_dir_all("dist/releases").map_err(|e| e.to_string())?; + for browser in ["chromium", "firefox"] { + let root = PathBuf::from(format!("dist/{browser}")); + validate(&root)?; + archive( + Path::new(&format!( + "dist/releases/limitlessx-{browser}-{}.zip", + env!("CARGO_PKG_VERSION") + )), + &[(&root, "")], + )?; + } + let paths = [ + "package.json", + "package-lock.json", + "tsconfig.json", + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + ".cargo", + "crates", + "extension", + "scripts", + "docs", + "README.md", + "LICENSE", + "tests", + ".github", + ]; + let sources: Vec<_> = paths.iter().map(|path| (Path::new(path), *path)).collect(); + archive( + Path::new(&format!( + "dist/releases/limitlessx-source-{}.zip", + env!("CARGO_PKG_VERSION") + )), + &sources, + )?; + println!("Validated popup-only browser packages and source archive in dist/releases"); + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_broad_permissions_and_extra_extension_pages() { + let original: Value = + serde_json::from_str(include_str!("../../../extension/manifests/chromium.json")) + .unwrap(); + assert!(validate_manifest(&original).is_ok()); + for (key, value) in [ + ( + "permissions", + serde_json::json!(["activeTab", "storage", "history"]), + ), + ("host_permissions", serde_json::json!([""])), + ("options_ui", serde_json::json!({"page":"options.html"})), + ( + "web_accessible_resources", + serde_json::json!([{"resources":["pkg/runtime/*"],"matches":[""]}]), + ), + ] { + let mut m = original.clone(); + m[key] = value; + assert!(validate_manifest(&m).is_err(), "accepted unsafe {key}"); + } + } + #[test] + fn archives_are_repeatable_and_have_relative_entry_names() { + let root = + std::env::temp_dir().join(format!("limitlessx-archive-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("source")).unwrap(); + std::fs::write(root.join("source/a.txt"), "hello").unwrap(); + let source = root.join("source"); + let a = root.join("a.zip"); + let b = root.join("b.zip"); + archive(&a, &[(&source, "extension")]).unwrap(); + archive(&b, &[(&source, "extension")]).unwrap(); + assert_eq!(std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap()); + let mut z = zip::ZipArchive::new(std::fs::File::open(a).unwrap()).unwrap(); + assert_eq!(z.by_index(0).unwrap().name(), "extension/a.txt"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6f54657 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,27 @@ +# Architecture + +The Rust workspace is authoritative. `core` owns identifiers, targets, notice eligibility, normalized types, errors and salted record keys. `fxtwitter` owns typed v2 envelopes, response validation, endpoint construction, errors and pagination. `runtime` owns trusted-message authorization, short-lived sessions, fetching, normalization and successful restricted-view decisions. `storage` owns atomic IndexedDB transactions. `ui` renders the popup (including settings) and content directly injected into X using Leptos for the popup and a Rust-owned typed DOM view for inline content. `xtask` builds browser packages. + +## Browser boundary + +Strict TypeScript compiles to packaged JavaScript and registers listeners synchronously before WASM initialization. A single initialization promise is reused for each active worker/page. The bridge forwards browser API calls. The content adapter observes bounded X notice containers, debounces DOM changes, and sends bounded notice data to Rust, which checks the official withholding-policy link. When Rust confirms a regional-withholding notice, it automatically starts one public lookup for that route/notice. Its own rendered DOM updates never trigger duplicate lookups. + +The background Rust runtime constructs a typed DOM tree and action messages. A narrow, generic content adapter renders that tree into a DOM container inside X, using text nodes and an allowlist of tags/attributes. It does not parse provider text as HTML or duplicate provider/eligibility/view logic. A ShadowRoot and constructed stylesheet isolate the presentation from X's CSS. No iframe, extension reader page or options page exists; only `popup.html` is packaged as an extension-owned HTML interface, rendered with Leptos. Only the packaged stylesheet is web-accessible to supported X/Twitter origins; all executable resources remain private. + +This is deliberate: the tested Chromium rejects even minimal WebAssembly compilation from a strict-CSP X content context. No page policy is changed, bypass flag is used, or alternative extension page introduced. Rust/WASM remains in the background and popup; X receives ordinary DOM nodes. + +Reader contexts are returned over extension messaging to isolated content-script state. There is no page-level postMessage listener and no session token in DOM attributes or page URLs. A context is bound to its source X target and tab ID. Navigating to a new route invalidates pending view updates and removes its injected container. + +## Local view counts + +Opening from a validated X notice creates a target- and tab-bound, ten-minute session. Successful automatic and explicit displays of restricted content count once per public ID; ordinary manual lookups create ineligible sessions. Sessions live in browser.storage.session and survive worker restarts, but disappear when the browser exits. They are not durable browsing history. At most 100 active sessions are retained. Loading a profile can fetch its public timeline, but does not count those posts. A timeline post must be opened individually; only IDs actually returned in that reader's timeline can create a child context in the same injected container. Manual popup input opens an X tab with a pending ineligible context held in browser memory. + +## Provider and storage + +Rust uses Fetch with credentials omitted, redirects rejected, cache disabled, a 15-second abort deadline and a 4 MiB streaming response limit. HTTP or envelope rate limits produce an explicit error and a Retry-After cooldown. Provider media URLs are limited to HTTPS pbs.twimg.com and video.twimg.com. Provider text is represented as text nodes, never parsed as markup. + +IndexedDB has three stores: `meta`, `profiles` and `posts`. A single read/write transaction checks the recording setting, initializes a random per-install salt, upserts a domain-separated SHA-256 key, and reads counts. Duplicate insertions overwrite the same primary key. Disabling and clearing run through the same transaction scope. Clearing rotates the salt and deletes both ledgers. No public identifiers or post text are required in the durable record. + +## Browser tooling and media + +Handwritten adapters live in extension/adapters/*.ts with strict TypeScript contracts. cargo xtask compiles them using the pinned npm lockfile, then packages emitted JS with Rust/WASM. No npm runtime dependency ships. The media adapter binds Rust-rendered controls to HTML video backed by HTTPS X media URLs. It does not import X application/player code or use an embed iframe. Media is paused on route removal, retained during matching post updates, and exposes retry/source access on failure. diff --git a/docs/brand.md b/docs/brand.md new file mode 100644 index 0000000..a9ab62c --- /dev/null +++ b/docs/brand.md @@ -0,0 +1,11 @@ +# LimitlessX brand + +The mark traces the user's attached reference: six ascending slanted black bands, with wedge-shaped first and last bands. The source reference is `docs/brand/user-reference.png`; the clean SVG master is `extension/static/brand/mark.svg`. No bitmap is embedded in the SVG. Browser icons are derived by `scripts/render-icons.py` at 16, 32, 48 and 128 pixels. Use black on white or white on black. + +Typography: Helvetica Neue, Helvetica, Arial, sans-serif. Use 4px spacing increments, 8px control radii, 12px panel radii, high-contrast primary text and neutral secondary text. Follow the system color scheme. + +Primary tagline: **No VPN. No proxy setup. No DPI hacks.** + +Always qualify access: public content is available only when the alternative source returns it. No anonymity, guaranteed bypass, universal compatibility, fake installation counts or unverified store badges. + +Popup reference: docs/reference/design-reference.md, captured from the user-referenced onur.af brand.md. Apply its monochrome tokens, normal typography, functional copy, 44px controls and restrained spacing to LimitlessX; retain the By Onur signature as explicitly requested. No marketing hero or identity labels are needed inside the popup. X-injected content follows X itself. README carries the product motto and open-source identity. diff --git a/docs/brand/mark-dark.svg b/docs/brand/mark-dark.svg new file mode 100644 index 0000000..51fb761 --- /dev/null +++ b/docs/brand/mark-dark.svg @@ -0,0 +1 @@ +LimitlessX diff --git a/docs/brand/mark-light.svg b/docs/brand/mark-light.svg new file mode 100644 index 0000000..3d2de2f --- /dev/null +++ b/docs/brand/mark-light.svg @@ -0,0 +1 @@ +LimitlessX diff --git a/docs/brand/user-reference.png b/docs/brand/user-reference.png new file mode 100644 index 0000000..c5edce6 Binary files /dev/null and b/docs/brand/user-reference.png differ diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..4d1901e --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,17 @@ +# Compatibility + +| Browser | Evidence | Status | +|---|---|---| +| Chromium (Playwright full browser) | tests/browser/smoke.py: real MV3 worker/WASM response and Leptos popup | Automatic inline integration passed; see current-build.json | +| Firefox (Playwright Firefox binary through geckodriver) | tests/browser/firefox_smoke.py: installed MV3 package, event-page WASM response and Leptos popup | Automatic inline integration passed using live public status/20 | +| Helium | User-authorized DevTools MCP; installed enabled extension; actual regional profile replacement and public post with 34 replies | Verified locally on 2026-10-30 | +| Chrome, Brave, Opera, Edge, Vivaldi | Same Chromium package intended | Not individually verified | +| Zen, Floorp, LibreWolf, Waterfox | Same Firefox package intended | Not individually verified | + +Public content is rendered directly in X's DOM, within a ShadowRoot. The popup is the extension's only HTML interface. Only the packaged stylesheet is exposed to X/Twitter domains; executable resources remain private. Both browser packages retain locally packaged WASM and synchronous background listener registration. Current tests exercise automatic DOM injection, native timeline styling, and a strict host-page CSP. Earlier extension-tab tests were removed. + +Local Chromium install: open the browser's Extensions page, enable Developer mode, choose Load unpacked, select `dist/chromium`. + +Local Firefox test install: open about:debugging, select This Firefox, choose Load Temporary Add-on, select `dist/firefox/manifest.json`. Temporary add-ons are removed at browser shutdown. Permanent distribution requires Mozilla signing. + +These runtime proofs are not full compatibility certification or a store listing. See the release audit for the remaining gates. diff --git a/docs/implementation-direction.md b/docs/implementation-direction.md new file mode 100644 index 0000000..38ed0d8 --- /dev/null +++ b/docs/implementation-direction.md @@ -0,0 +1,16 @@ +# Current product direction — 2026-10-30 + +The user's latest instruction supersedes the extension-origin reader, options page, original logo and landing-page assumptions in the attached planning specification: + +- Automatically detect a supported regional restriction, retrieve public content and render it directly inside x.com’s DOM. No activation button, iframe or separate extension reader tab. +- Match X’s native profile/post layout, typography, timeline borders and current page colors. The injected area is not a separate branded panel. +- The only extension-owned HTML interface is the popup. Settings and The Record controls belong there. +- Use a clean SVG tracing of the user-provided six slanted black bars, replacing the original loop/X mark. +- Keep Rust-owned validation, data requests, session eligibility, local Record and Leptos rendering. The background Rust runtime produces a typed DOM tree and actions; a generic content adapter mounts those elements inside X with isolated styles matching X. Leptos renders the popup. This avoids compiling WASM under the host page CSP (blocked in the tested Chromium strict-CSP context) without loosening that policy. +- Remove the standalone reader/options resources and all entry points into them. No landing page is required under the revised instruction. + +Manual input may navigate to the requested X URL, where the content is injected. A confirmed regional notice starts an automatic public-data request; unrelated errors never do. Short-lived reader tokens remain in isolated extension state and do not appear in page URLs or DOM attributes. + +## Release review update (2026-10-30) + +The user now explicitly requests TypeScript for handwritten browser adapters. Rust still owns domain/provider/runtime/storage/view decisions. Strict TypeScript is build-time tooling only; packaged output is local JavaScript with no npm runtime dependencies. This supersedes the original no-TypeScript requirement. Successful automatic displays of restricted accounts/posts now count uniquely in The Record; manual unrelated lookups do not. The popup follows design-reference.md and retains By Onur as explicitly requested. X media URLs use a packaged player, without remote X application code. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..ccf4c6b --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,15 @@ +# Privacy + +LimitlessX has no managed backend, account registration, analytics or telemetry. It does not access X authentication cookies, passwords, private messages or broad browsing history. + +When the extension detects a supported regional-withholding notice, it automatically sends that profile or post’s public identifier to api.fxtwitter.com and displays returned data inside X. A manual popup lookup also sends the submitted public identifier. It does not look up every encountered account. Loading an account also requests its public timeline. If username lookup fails, an exact-match public typeahead result resolves the numeric account ID before retrying. Suggestion placeholder counters are never displayed as profile metadata. Explicit pagination requests transmit the provider's cursor. FxTwitter can see the request identifier and network information such as your IP address. Images and videos connect to public X media hosts. Their policies and availability are outside LimitlessX's control. + +Extension Fetch requests omit credentials, use no referrer, and reject redirects. Media elements use anonymous cross-origin loading. No remote executable code is used. + +The optional Record is stored locally in IndexedDB. It contains a random salt, recording preference, start date and unique salted identifier hashes for successfully displayed restricted profiles/posts, including automatic displays. Reopening the same item does not increase its unique total. Counts derive from these stores. It stores no post bodies, handles or plaintext browsing history. Clear record deletes the ledger and rotates its salt. Disabling recording keeps existing counts while preventing future additions. Uninstalling deletes the browser-managed extension data. + +Short-lived reader contexts are held in browser session storage, limited to 100 active sessions with a ten-minute lifetime. They include the requested target, restriction eligibility and IDs returned by the current timeline. They survive worker restarts and are cleared at browser shutdown. They are not part of The Record. + +Permissions: `activeTab` reads the currently activated tab context; `storage` holds short-lived reader contexts in browser memory. Host access is limited to api.fxtwitter.com for explicit lookups. Content scripts run only on X/Twitter domains to observe restriction notices. No cookies, webRequest, history or all-sites permission is requested. + +LimitlessX is not anonymous, untraceable, a VPN, a proxy service or a private-account viewer. Alternative public content can be incomplete, stale or unavailable. diff --git a/docs/product-spec.md b/docs/product-spec.md new file mode 100644 index 0000000..e8aa5f5 --- /dev/null +++ b/docs/product-spec.md @@ -0,0 +1,609 @@ +> Historical source specification. Later user instructions in [implementation-direction.md](implementation-direction.md) supersede reader/options pages, activation, TypeScript and automatic counter requirements. + +> Historical attachment. The user’s later instructions in [implementation-direction.md](implementation-direction.md) supersede its reader-page, activation, logo and standalone-page assumptions. + +" +# LimitlessX +**Brand Identity, Product Specification & Rust Engineering Plan** + +Version: 0.3 +Status: Planning +Repository: `OnurByte/LimitlessX` (proposed) +License: MIT +Primary language: Rust +Application runtime: WebAssembly +Targets: Firefox and Chromium browsers +Product language: English + +--- + +## 1. Product identity + +**Brand:** LimitlessX + +**Primary tagline:** + +### No VPN. No proxy setup. No DPI hacks. + +**Product description:** + +View region-restricted public content on X through an alternative source, without configuring network-circumvention tools. + +**Short description:** + +A Rust-powered, cross-browser extension for viewing accessible region-restricted X accounts and posts. + +### Character + +- Independent and unconventional. +- Minimal, direct, and recognizable. +- Technically transparent. +- Privacy-conscious without unverifiable anonymity claims. +- Strong identity without exaggerated marketing. + +The name LimitlessX communicates unrestricted exploration, but the application must clearly distinguish its intended purpose from guaranteed censorship circumvention. + +There is no claim that the extension can access every restricted account. + +--- + +## 2. Brand design + +### Logo concept + +An original geometric mark combining a continuous loop with a sharply interrupted X-shaped intersection. + +It should suggest continuity despite an obstruction. + +Requirements: + +- Distinctive SVG master. +- Legible at 16, 32, 48, and 128 pixels. +- Monochrome. +- Slight material depth permitted. +- No traditional lock, shield, or VPN symbol. +- No animations or decorative glow. + +### Design system + +| Element | Standard | +|---|---| +| Background | Black / white | +| Typography | Helvetica Neue, Helvetica, Arial | +| Primary text | High contrast | +| Secondary text | Neutral grayscale | +| Controls | 8 px radius | +| Panels | 12 px radius | +| Spacing | 4 px increments | +| Themes | Follow system | +| Icons | Original SVG assets | + +Browser compatibility icons retain their official colors. + +### Writing style + +Short, direct English. + +Avoid artificial urgency, technical grandstanding, fake achievements, unnecessary explanations, and exaggerated privacy promises. + +--- + +## 3. Rust-first architecture + +### Technology decisions + +| Layer | Technology | +|---|---| +| Main language | Rust | +| Compilation target | `wasm32-unknown-unknown` | +| UI | Leptos | +| WebAssembly bindings | wasm-bindgen | +| Browser interfaces | web-sys + narrowly scoped bindings | +| HTTP | web-sys Fetch API | +| JSON serialization | serde | +| Data validation | Rust typed deserialization + semantic checks | +| Error handling | thiserror | +| Local persistent data | IndexedDB | +| Build orchestration | Cargo xtask | +| WASM optimization | wasm-opt | +| Unit tests | cargo test | +| Browser integration tests | WebDriver / Playwright harness | +| CI | GitHub Actions | + +### Language policy + +- No TypeScript. +- No React. +- No Node-based application framework. +- No unnecessary JavaScript dependencies. +- Rust owns application logic, API interpretation, state transitions, view models, and rendering components. +- Small packaged JavaScript adapters are permitted only where required for browser interoperability. +- Generated JavaScript from wasm-bindgen is expected and permitted. + +WXT is removed from the architecture. + +### Runtime boundaries + +**Rust core** + +Owns profile and post models, API response normalization, eligibility rules, errors, identifiers, and local statistics logic. + +**Rust/WASM UI** + +Uses Leptos for the extension popup, options, and alternative reader. + +**Content-script adapter** + +A minimal JavaScript module observes X navigation and restriction notices, inserts an entry point, and communicates with the extension runtime. + +**Background adapter** + +Registers browser lifecycle and messaging listeners, initializes the required WASM runtime, and delegates validated operations to Rust. + +No application business rules should be duplicated in JavaScript. + +--- + +## 4. Rendering strategy + +### Extension-origin reader + +Use a packaged extension page to host the Rust/WASM reader. + +The content script can insert an extension-origin iframe near a withheld account notice. + +This separates the Leptos application from X's JavaScript and CSS environment. + +Benefits: + +- Rust UI does not need to run directly inside X's content-script environment. +- Leptos controls the entire reader. +- Styling remains isolated. +- The extension can apply its own WASM-compatible security policy. + +### Technical conditions + +- The reader must be a packaged local resource. +- Only required resources are web-accessible. +- Cross-origin messaging uses explicit origins, validated message structures, and short-lived session context. +- X page content is never treated as trusted code. +- The reader must not inherit X session credentials. +- If embedded rendering fails due to browser restrictions, provide an extension-tab reader fallback. + +Embedding and background WASM initialization must be verified in both browser families before building the complete interface. + +### Content Security Policy + +Use packaged scripts and the minimum necessary `wasm-unsafe-eval` allowance. + +Remote executable JavaScript is prohibited. + +--- + +## 5. Core feature: restricted account reader + +### Entry points + +1. An X profile displaying a confirmed country-specific withheld notice. +2. A direct X post with a confirmed regional restriction. +3. A manually submitted X URL in the popup. + +### Flow + +1. Detect the page and restriction state. +2. Offer the LimitlessX action. +3. Wait for user activation. +4. Request public data through FxTwitter. +5. Validate and normalize the response in Rust. +6. Render available data in the Leptos reader. +7. Record successful deliberate views locally, if enabled. + +### Account information + +Display only fields actually returned by the provider: + +- Display name and handle. +- Avatar and biography. +- Public posts. +- Media. +- Publication dates. +- Pagination when available. + +Never fabricate missing information. + +### Restrictions + +A geographically withheld account, suspended account, deleted account, and protected account are different states. + +Do not treat a generic API failure as evidence of a regional block. + +--- + +## 6. FxTwitter integration + +**Primary provider:** FxTwitter API v2. + +The provider adapter will be written in Rust. + +Responsibilities: + +- Request construction. +- Fetching and timeouts. +- Rate-limit handling. +- Strict response parsing. +- Normalized Rust data models. +- Error classification. +- Pagination. +- Explicit provider attribution. + +The provider interface must remain replaceable without rewriting the UI. + +### Feasibility gate + +Before implementing the full product, confirm that FxTwitter can retrieve representative withheld public content. + +Test normal accounts, regional withholding, protected content, suspension, deletion, and API errors. + +If actual withheld content cannot be retrieved, reconsider the source strategy before making accessibility claims. + +The extension will not use users' X authentication cookies or credentials. + +--- + +## 7. Private statistics: The Record + +The Record measures distinct restricted public content successfully opened through LimitlessX. + +### Main metrics + +**Restricted profiles viewed** + +The number of unique accounts with verified regional restriction states deliberately opened through the reader. + +**Posts viewed** + +The number of unique posts deliberately opened in an eligible restricted-content flow. + +Automatically fetched timelines do not count as viewed posts. + +### Proposed appearance + +```text +THE RECORD ⁜ + +20 10 +restricted profiles posts +viewed viewed + +Since installation +Stored on this device + +Clear record +``` + +### Storage design + +- IndexedDB as the source of truth. +- Atomic unique-key insertion for deduplication. +- Per-install local identifiers derived from normalized account IDs and post IDs. +- No plaintext browsing history required. +- No remote analytics. +- No account registration. +- No telemetry. +- Statistics disabled on user request. +- Clear record removes the stored ledger. + +Reopening the same content never increases its unique count. + +Separate tabs and browser restarts must not cause duplicate counting. + +### Scope + +The first release includes only two counters. + +No leaderboards, achievements, streaks, gamification, country rankings, or public statistics. + +--- + +## 8. Extension popup + +### Suggested composition + +```text +LIMITLESSX ∞ + +No VPN. No proxy setup. +No DPI hacks. + +CURRENT PAGE + +@account +Restricted in your region + +[ View public content ] + +──────────────────────────────── + +THE RECORD + +20 10 +restricted profiles posts +viewed viewed + +Stored locally · Since installation + +Settings GitHub +``` + +All data in the example is illustrative. + +### Behavior + +On X, the popup identifies the current supported context. + +On other websites, it offers a field for an X URL or account handle. + +The main action must use a meaningful label rather than ambiguous generic text. + +The popup is built entirely with Leptos components. + +--- + +## 9. Supported browsers + +### Official installation badges + +Display the official Chrome Web Store and Firefox Add-ons badges prominently. + +### Chromium family + +- Chrome +- Brave +- Opera +- Edge +- Vivaldi + +### Firefox family + +- Firefox +- Zen +- Floorp +- LibreWolf +- Waterfox + +Use recognizable browser icons beneath the official installation badges. + +Do not imply every listed browser has been tested until verification is complete. + +### Distribution model + +Generate independent Chromium and Firefox Manifest V3 packages from the same Rust workspace. + +Where API behavior differs, handle the difference in a small compatibility adapter. + +No separate application implementations. + +--- + +## 10. Workspace structure + +```text +LimitlessX/ +├── Cargo.toml +├── Cargo.lock +├── rust-toolchain.toml +├── crates/ +│ ├── core/ +│ │ ├── models/ +│ │ ├── restrictions/ +│ │ └── errors/ +│ ├── fxtwitter/ +│ ├── storage/ +│ ├── runtime/ +│ ├── ui/ +│ │ ├── popup/ +│ │ ├── reader/ +│ │ └── options/ +│ └── xtask/ +├── extension/ +│ ├── adapters/ +│ │ ├── background.js +│ │ └── content.js +│ ├── manifests/ +│ │ ├── chromium.json +│ │ └── firefox.json +│ └── static/ +│ ├── brand/ +│ └── styles/ +├── tests/ +│ ├── fixtures/ +│ ├── browser/ +│ └── integration/ +├── docs/ +│ ├── architecture.md +│ ├── compatibility.md +│ ├── privacy.md +│ └── brand.md +├── .github/ +│ └── workflows/ +└── README.md +``` + +The Rust workspace is authoritative. The JavaScript adapters remain small, explicitly reviewed, and independent of application state models. + +--- + +## 11. Build system + +Use Cargo xtask as the single build orchestration entry point. + +Proposed CLI contract: + +```sh +cargo xtask build chromium +cargo xtask build firefox +cargo xtask test +cargo xtask package +``` + +These are planned project commands, not commands supplied by an existing framework. + +The build pipeline will: + +1. Compile the relevant Rust crates to WASM. +2. Generate interoperability code using wasm-bindgen. +3. Optimize release WASM. +4. Copy packaged UI and static resources. +5. Generate browser-specific manifests. +6. Validate resources, permissions, and CSP. +7. Produce separate extension archives. + +### Reproducibility + +- Commit Cargo.lock. +- Pin Rust toolchain. +- Version required build tools. +- Generate release artifacts in CI. +- Preserve source information needed for store review. +- Never load remote executable code. + +--- + +## 12. Performance + +Rust/WASM does not automatically make an extension faster than JavaScript. + +### Goals + +- Small startup payload. +- No repeated WASM initialization for the same active UI context. +- Lazy initialization of the reader. +- Minimal JavaScript injected into X. +- No continuous full-page DOM scanning. +- No speculative API calls by default. +- Debounced navigation observation. +- No background CPU activity when idle. + +### Measurements + +Track: + +- Packaged size. +- WASM initialization time. +- Popup interactive latency. +- Reader open latency. +- API response handling overhead. +- Idle resource consumption. +- Compatibility regressions. + +Set numeric performance budgets only after obtaining baseline measurements. + +--- + +## 13. Privacy and security + +- No backend owned by LimitlessX. +- No remote telemetry. +- No transfer of X session tokens. +- No collection of passwords or private messages. +- No broad browsing-history permissions. +- No external code execution. +- No default automatic lookup of every X account encountered. +- Clear explanation of what FxTwitter receives. +- Local statistics remain local. +- Explicit reset and disable controls. +- No content spoofing when the source fails. + +Avoid declaring the product anonymous or untraceable. + +--- + +## 14. Landing page + +### Hero + +**LimitlessX** + +No VPN. No proxy setup. No DPI hacks. + +View region-restricted public X content through an alternative source, when available. + +Official Chrome and Firefox install badges. + +Below them, recognizable compatibility icons for the additional browser families. + +### Sections + +**How it works** +A three-step explanation of detection, alternative lookup, and reading. + +**The Record** +A small illustration of the private local viewing counters. + +**Privacy** +Direct explanations of local data and third-party API requests. + +**Open source** +Repository, license, releases, and contributing instructions. + +Avoid inflated marketing statistics, fake social proof, animation-heavy pages, and decorative security claims. + +--- + +## 15. Release roadmap + +| Phase | Deliverable | +|---|---| +| P0 | FxTwitter withheld-content feasibility | +| P1 | Rust/WASM cross-browser runtime proof | +| P2 | LimitlessX brand and design system | +| P3 | Restriction detection and content-script adapter | +| P4 | Rust alternative account reader | +| P5 | Rust post and media reader | +| P6 | Leptos popup and The Record | +| P7 | Security and performance verification | +| P8 | Firefox and Chromium compatibility | +| P9 | Landing page and store submission | +| P10 | Public v1.0 | + +### Release gates + +Before v1.0: + +- Withheld content capability is verified against real examples. +- Chrome and Firefox can load the Rust/WASM reader. +- Background initialization and messaging survive worker restarts. +- Embedded rendering or its tab fallback works. +- Unique local counters remain correct under concurrent use. +- Privacy claims match actual network and storage behavior. +- Browser variants are labeled according to their verified compatibility. +- All executable code is packaged locally. + +--- + +## 16. Non-goals + +LimitlessX is not: + +- A VPN. +- A network proxy service. +- A DPI bypass implementation. +- An anonymous browser. +- A full X client. +- An X account-unlocking service. +- A private-account viewer. +- A content archive. +- A social analytics platform. + +It is a focused Rust-powered browser extension for accessing public content when an alternative source makes that content available. + +--- + +## Final product direction + +**LimitlessX should make a restricted-content lookup feel like an ordinary browser capability.** + +The interface remains quiet, the data stays transparent, and the application does not need a separate account or a managed backend. + +Rust owns the product. Browser-specific adapters only connect it to the web extension platform. +" \ No newline at end of file diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 0000000..98b1fa4 --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,48 @@ +# LimitlessX implementation ledger +Spec: docs/product-spec.md +Plan: docs/superpowers/plans/2026-10-29-limitlessx.md + +2026-10-29: Empty repository with origin https://github.com/OnurByte/LimitlessX, no commits or source files. +User directed completion (“bitir”): continue implementing supplied design inline, without another design conversation. +Working in place on a dedicated codex/ branch; unborn repository cannot supply a worktree base. +P0 preliminary: live v2 profile and timeline for ekrem_imamoglu return data. Restriction provenance from HRW's 2025-05-08 direct observation; current X DOM restriction still needs verification. +Task 2 core/provider: tests observed RED against explicit error-return stubs, then GREEN (10 native tests). Schemes, hosts, identity mismatch, response discriminator, optional data, tombstone reasons, pagination and salted identifiers covered. +Runtime security: two tests observed RED for privileged-message access and target/expiry binding; implementing before first runtime build. +Toolchain: Rust 1.99.1; wasm-bindgen 0.2.129; Binaryen version_133. Parallel rustup installation left an incomplete toolchain; removed only that new toolchain and reinstalled serially, now healthy. +P0 saved in docs/verification/feasibility.json: withheld timeline returns 20 posts and cursor; profile endpoint intermittently not found. Protected/suspended candidates not reliably classified by live API. Gate remains partial; no v1 or store-access claims until verified. +Runtime security tests now GREEN (2/2). Browser smoke initially failed: Playwright's default headless shell lacks extensions; corrected harness to full Chromium channel. Corrected harness origin to exclude /adapters. Real Chromium messaging then exposed serde_json Value -> JS Map -> Chrome JSON {} loss; switched dispatch result to serde-wasm-bindgen JSON-compatible serializer. Regression smoke asserts exact response. +IndexedDB layer written: metadata and both unique-key stores are in a single read-write transaction, preserving disable/reset/insertion ordering. Integration coverage pending real application runtime. +Task 3 runtime proof: real Chromium and Firefox MV3 extension package smoke GREEN with exact Rust/WASM ping and Leptos h1. Firefox required privileged WebDriver navigation (extension pages cannot be navigated from ordinary WebDriver content context), isolated test profile only. +Task 3/4 implementation: typed operation enum, trusted sender authorization, target/expiry sessions in memory-only browser.storage.session, explicit provider fetch with abort/stream limits/Retry-After, manual ineligible flows, and timeline child-post allowlist. Native/WASM checks compile. +Task 4: Leptos popup, reader, options, Record controls and system-theme CSS added. Integration flows first observed RED (record data absent in proof stub); testing full app next. +Chromium full flows GREEN: manual count=0, eligible explicit post count=1, 12 racing duplicate opens count=1, target substitution rejected, disabled count=0. +Chromium contracts GREEN: profile/timeline pagination and XSS escaping, stale DOM notice rejected after SPA route change, worker restart preserves memory session, expiry rejects before fetch, 429 cooldown prevents another request, malformed response error, full browser restart preserves IDB and clears reader sessions. Contract test first caught stale notice reuse (RED); content adapter now quarantines pre-navigation notice nodes until fresh nodes arrive (GREEN). +Visual QA: viewed actual dark popup and light reader screenshots from tests/browser/artifacts; no clipping/overlap in tested viewport. Official install badges cannot legally indicate store availability until published; landing page must show honest development status and prepare badge activation separately. +2026-10-30 user correction: render inside x.com HTML; only popup as extension-owned interface; use attached six-stripe mark traced to SVG. docs/implementation-direction.md supersedes extension-origin reader/options/landing assumptions. Reworking runtime/session boundaries and browser tests for direct DOM rendering. Old browser test passes prove the superseded design only. +Last Firefox flow process completed: application assertions passed using live provider (BiDi did not intercept event-page requests); final interception-count assertion failed. This is not a deterministic test pass; replace with inline Firefox test and identify live-provider evidence honestly. +2026-10-30 latest user steering: detect confirmed regional notices and automatically display content; match X's standard design. Activation button removed. Native inline views use X-like profile/timeline layout, avatar column, post typography, borders and current X background (including dim), with small provider attribution. Host-page theme changes update presentation without refetching. +Automatic contexts remain ineligible for deliberate-view statistics. Explicit popup actions and individual post activation in an eligible flow remain countable after success. Popup and privacy disclosure updated for automatic third-party requests. +Independent review found three issues: stale activation response after navigation, permanently stale reused notice nodes, and background errors lacking a Rust view. Fixed by activation URL/generation checks, route/text-aware notice freshness and a generic error/retry path. Integration contracts include navigation cleanup and refreshed reused notices. +Revised implementation: Chromium strict-CSP contexts rejected even an empty WASM module. Rust background now supplies a typed DOM tree and action messages to the generic DOM adapter. No host policy change, external executable resource, iframe or reader/options HTML is used. Leptos owns popup rendering. Only app.css is web-accessible to X. +Current automatic Chromium test passed: generic error ignored, automatic inline content, one request per unchanged notice, no reader page, popup settings, native post shape and automatic vs deliberate counts. Firefox automatic inline test passed using live FxTwitter status/20 and controlled X DOM; live provider dependency is disclosed by that test. +Cargo xtask package now validates narrow permissions, stylesheet-only web access and popup-only HTML; produces deterministic browser archives plus review source. Packaging tests observed RED for unsafe scope and absent archive implementation, then GREEN. Release audit remains open for external provider/real-X regional verification and store publication. + +Final current-direction verification: 17 native tests passed; native and WASM Clippy passed with warnings denied. Chromium smoke/automatic/native-style/theme/inline contracts passed. Firefox smoke and strict-CSP automatic inline test passed (live public FxTwitter status/20). Both browser archives contain only popup.html as HTML. Current evidence and explicit limits are in docs/verification/current-build.json. +Native-X CSS regression first observed RED: X-style display:flex overrides the user-agent hidden rule. Added narrowly scoped notice-hiding CSS and restoration; automatic replacement test GREEN. +SPA metadata regression first observed RED: MessageSender.url retains the initial document URL on same-document route changes. The isolated adapter now supplies current page_url in the message envelope; Rust validates the original/current origins match and binds sessions to the current target and tab. New native origin/substitution test and reused-notice route browser contract GREEN. + +2026-10-30 Helium continuation: connected Chrome DevTools MCP to the user-enabled existing browser. Installed LimitlessX lbeeopacdoaccgmepkfhilamcdnhibii. Captured actual native X profile/post/reply geometry and action SVGs (sanitized docs/reference). Main post body is full-width 17/24; replies 15/20 with 40px avatar and 8px gap. Profile banner, biography, website, location, joined date and actual counts use provider metadata; missing metrics remain absent. Added typed v2 conversation normalization, cursor replies, quote/media rendering and same-container reply activation. +Real regional notice heading Account Withheld was initially missed (RED), normalized in Rust (GREEN). Actual Helium regional profile automatically replaced; provider profile worked while latest timeline returned not-found (earlier timeline returned posts). This is partial provider availability, not universal access. Actual public post 2115761952937128381 displayed with 34 replies in Helium; measured geometry matches the native capture. +Live manual-open exposed X changing /i/status/id to /author/status/id during hydration. Runtime now recovers only a still-valid source/tab-bound session; generic adapter recovers a removed host from the last Rust view. Equivalent-target route and host-removal regression test passed. Distinct-target navigation still clears the view and fails session binding. +Validation: 21 native tests, native/WASM Clippy with denied warnings; Chromium smoke, automatic, contracts and native fidelity; Firefox smoke and controlled-X/live-provider inline checks passed. Popup-only packages and source archive generated. Public store release remains open. + +2026-10-30 layout regression reported by user: actual Helium host measured 1225.36px wide outside the 600px primary column, below the native withheld placeholder. Root cause was the fallback mount to main/body during X hydration plus hiding native regions only once. Prior successful screenshots did not cover this lifecycle. Added delayed-primary/replaced-column/late-timeline test; observed RED against the installed implementation. Removed fallback mounting entirely: defer until primary exists, reconcile placement inside the current primary, and reconcile newly inserted native regions without refetching settled content. The regression test is GREEN, as are automatic, contracts, native fidelity and Firefox inline checks. Actual Helium corrected host width is 598.18px inside the 600px column, regional placeholder hidden; profile and public timeline displayed. Normal public post navigation has no extension host and zero hidden regions. + +2026-10-30 deeper native comparison and feed performance: profile and timeline requests previously ran sequentially; now joined in Rust. Actual Helium requests began 0.7ms apart, durations 2649.6ms/3011.4ms in one observation. Render five initial posts, retain provider page in the tab-bound session and reveal five more per downward scroll; fetch next cursor only after the buffer is exhausted. Provider may return 20 per page, so this reduces initial rendering/media work rather than claiming a smaller upstream response. Preserve identical existing post nodes during updates to avoid rebuilding video elements; disabled retry controls are replaced correctly. A pagination error preserves the feed with an explicit retry and stops automatic retry loops. +Added native profile location/link/calendar SVG paths from actual X; profile image uses the native 200x200 variant instead of stretching the normal thumbnail (actual naturalWidth 200 verified). Added Reposts tab, full joined month, preserved website path, removed extra tab top margin, and matched multi-image tiles with shared 16px outer frame and 2px gutters. Infinite-scroll fixture verifies 5/10/15/20/25 counts, buffer request count, parallel fetch ordering, stable post identity, failure/retry and cursor exhaustion. Native batching regression observed RED then GREEN. Actual Helium first five and End-triggered ten verified. Remaining source limitations and native authenticated X controls are not claimed equivalent to a full X client. + +Follow-up native tab defect: links previously opened new tabs and their profile subroutes were rejected. Narrowly recognize only with_replies/reposts/media profile routes; keep other unsupported paths rejected. Same-tab navigation restores the valid profile session, resets visible count to five and filters cached provider posts by reply metadata, author/owner identity or media presence. Pagination applies to matching posts, with honest no-results state for missing provider coverage. Browser test proves tab routes, matching IDs and zero extra provider requests. Pending recovery now requires the session target itself to match the current route, avoiding restoration of a previously opened child post on a profile tab. + +2026-10-30 repository delivery review: strict TypeScript 5.9.3 replaces all handwritten JS adapters. cargo xtask compiles to a clean target/adapters directory and packages only emitted JS; npm has no runtime dependency. Runtime/storage/view policy remains Rust. The known username-404 failure now resolves an exact public typeahead identity and retries by UID; live ekrem_imamoglu UID lookup succeeded. Suggestion zero counters never become profile metadata. Failed identity results are evicted without deleting a newer in-flight request. Automatic successful restricted views count uniquely and update an already-open popup. Metadata title/description are owned/restored and refreshed when X replaces tags. +Independent review identified missing initial timeline retry, cached transient identity failures, destructive reply-pagination errors and arbitrary restored sessions. All four fixes are covered by review_regressions.py; newest sessions sort by monotonically increasing creation time. Packaged video controls use X media URLs, support playback/seek/volume/speed/fullscreen/retry and pause on navigation. Media test observed a fullscreen ShadowRoot toggle issue and fixed it using :fullscreen, then passed. Seeking fixture now serves byte ranges like a media host. Existing post DOM is preserved while the feed expands. +README now includes theme-aware user-provided logo, motto, honest development/browser badges, open-source/MIT emphasis, trust boundaries and build/install instructions. The popup follows design-reference.md and retains By Onur. No store availability or universal source access is claimed. diff --git a/docs/reference/design-reference.md b/docs/reference/design-reference.md new file mode 100644 index 0000000..1c4d71a --- /dev/null +++ b/docs/reference/design-reference.md @@ -0,0 +1,123 @@ +Onur ⁜ Marka ve tasarım dili + +Bu belge, Onur'un kişisel sitesinin kimliğini ve tasarım kararlarının arkasındaki yaklaşımı tanımlar. Mevcut siteyi temel alır; yeni bir şirket kimliği veya farklı bir görsel yön önermez. Yeni sayfa, metin ve görsel üretirken bu çizgiyi koru. Uygulama ölçüleri için tasarım standardına, StyleX kullanımı için StyleX rehberine bak. + +Adı ve kimliği +Marka adı: Onur. +Site türü: Kişisel site ve portfolyo. +İmza: Mevcut Onur işareti ile yanında yazan Onur adı. +Ana sayfa metni: Mevcut üç mottodan biri; ayrıca slogan eklenmez. +Kişisel giriş: Motto altında, ortak kart genişliğinde ve köşe ışıklı çerçeve içinde kısa birinci tekil kişi anlatımı. Başlık ve açıklama, Onur'un yazılım üretme yaklaşımını ve kullandığı araçlar üzerindeki kontrolünü anlatır. Ek slogan, kimlik etiketi, araç listesi veya yönlendirme bağlantısı eklenmez. +Arayüz dili: İngilizce. Bu rehberin dili Türkçe. + +Onur, burada bir kişinin adı ve yaptığı işlerin imzasıdır. Marka bir ajans gibi konuşmaz, ekip veya şirket büyüklüğü ima etmez. Metinlerde kişi kendi adına konuşur; İngilizcede I, my kullanılır. + +Ruh ve karakter + +Sitenin karakteri bağımsız, düşünceli, sade ve doğrudandır. Üretmek, düşünmek ve kendi kararının sorumluluğunu almak merkezdedir. Kimlik, büyük iddialarla değil kişinin adı, sözleri ve yaptığı işle görünür olur. + +Görsel dil sessizdir. Geniş boşluklar, okunaklı yazı ve az sayıda açık eylem kullanır. Sayfa, ziyaretçiyi sürekli yeni bir şeye tıklamaya zorlamaz. Bir metnin veya işlemin anlaşılması için gereken öğeler görünürdür; dikkat çekmek için eklenen süsler bu karaktere uymaz. + +Mahremiyet ve bağımsızlık da bu kişiliğin parçalarıdır. PGP, RSS, TOR, I2P ve Monero bağlantıları somut işlevleriyle yer alır. Bu işlevler mevcut olmadığı hâlde varmış gibi gösterilmez; güvenlik veya anonimlik hakkında doğrulanmamış vaatler yazılmaz. + +Mevcut mottolar + +Ana sayfa şu ifadeleri kullanır: + +“The creator originates.” +“I am. I think. I will.” +“To think is an act of choice.” + +Bu ifadeler sitenin düşünsel tonunu taşır. Her yardımcı sayfaya slogan ekleme; PGP, RSS ve bağış sayfalarında ziyaretçinin yapmak istediği işi açıkça anlat. + +Yazı tonu + +Ziyaretçiye görünen yayın etiketlerinde Post veya Posts kullanma; tekil Log, çoğul Logs kullan. RSS bölüm başlığı Logs, X bölüm başlığı Tweets olur. + +Sitenin görünen metinlerinde uzun çizgi (U+2014) veya orta nokta (U+00B7) kullanma. Ayraç gerektiğinde ⁜ kullan. Bu kural başlıklar, açıklamalar, kaynak adları, RSS ve paylaşım önizlemeleri için de geçerlidir. Geçmiş imzalı kayıtların baytlarını değiştirme; gerekli gösterim dönüşümünü arayüzde yap. + +Kısa, açık ve kişisel yaz. Bir sayfa ne sunuyorsa onu söyle. Düğme metni, tıklanınca gerçekleşecek işi adlandırır. Hata mesajı, sorunu ve mümkün olan sonraki adımı açıklar. + +Mevcut örnekler: + +Yer Ton ve örnek +Bağış açıklaması “Support my work with Monero.” +PGP açıklaması “Encrypt messages and verify signatures.” +Anahtar indirme “Download” +Anahtar kopyalama “Copy” + +Ziyaretçiye görünen başlık, açıklama ve paylaşım önizlemelerinde Onur’s personal website, Personal website, personal site gibi genel site tanımları kullanma. Başlıkta Onur yeterlidir; açıklamalar kişinin ne ürettiğini, düşündüğünü veya seçtiğini kendi ağzından somut biçimde anlatsın. + +Kurumsal pazarlama dili, abartılı sıfatlar, sahte aciliyet, kullanıcı sayısı veya güvenlik iddiaları ekleme. Henüz bulunmayan projeler, yazılar ve başarılar için boş vitrin oluşturma. Metni, gerçekten sunulan içerik belirler. + +Görsel dil +Renk ve tema + +Site monokromdur. Tema işletim sisteminin açık/koyu tercihinden gelir; ayrı bir tema düğmesi yoktur. + +Token Açık tema Koyu tema Kullanım +--background #ffffff #000000 Sayfa zemini +--text #0d0d0d #ffffff Ana yazı, ana eylemler +--muted #333333 #ffffff Yardımcı metin +--border #b3b3b3 #404040 Kontrol kenarları +--surface #f2f2f2 #222222 Girdi ve nötr yüzeyler + +Renkleri sayfa bazında yeniden icat etme. Dekoratif vurgu rengi ve parıltı kullanılmaz. Gradient, tanımlanan yüzey kenarlıkları ve ikon malzemesiyle sınırlıdır. İkonlarda silueti izleyen hafif yönlü gölge ve ince kabartma kullanılabilir. Ödeme QR'ı taranabilirlik için iki temada da beyaz zeminde kalır. + +Tipografi + +Ana yazı ailesi Helvetica Neue, ardından Helvetica, Arial ve sistem sans-serif fallback'idir. Gövde metni 16 px, genel satır yüksekliği 1.7, Log okuma metninde 1.8'dir. Başlıklar yalın, normal ağırlıkta ve belirgin hiyerarşiye sahiptir; aşırı büyük pazarlama başlıkları kullanılmaz. + +PGP anahtarı, parmak izi ve ödeme adresi gibi makine verileri monospace ile gösterilir. Bu bilgiler rahat seçilebilir ve kopyalanabilir olmalıdır. Sosyal paylaşım kartlarının sunucu tarafındaki üretimi, mevcut paketlenmiş Liberation Sans fontlarını kullanır. + +Yerleşim ve boşluk + +Header, ana içerik ve footer aynı hizayı paylaşır. Ortak maksimum genişlik 960 px; yan boşluklar 24 px, 620 px ve altında 20 px'dir. Metin blokları yaklaşık 65 karakter genişliğinde, bağış kontrolleri en fazla 740 px olur. + +Boşluklar 4 px'in katlarıyla kurulur. Küçük gruplarda 8–12 px, ilişkili öğelerde 16–24 px, bölümler arasında 32–40 px kullanılır. Mobilde gruplar sarılır; içerik ekranın dışına taşmaz. Her içeriği kart içine almak veya panel eklemek yerine mevcut açık sayfa düzenini kullan. + +Köşe geometrisi + +Kabul edilmiş ölçüler: kartlar ve pencereler 12 px; kontroller 8 px; Connect öğeleri ve bunların odak çerçeveleri için aşağıdaki ayrı köşe geometrisi kullanılır. Değerleri sayfalar içinde tekrar tanımlamak yerine ortak tokenları kullan. Bu ölçüler Apple'ın sabit değerleri veya psikolojik bir optimum değildir; sitenin ölçülerine göre verilmiş tasarım kararlarıdır. + +Connect tam genişlikte, Logs ardından, Technologies ve sosyal akışlardan önce yer alır. Ana sayfa önce kişiyi ve yaptığı işi, ardından iletişimi sunar. Technologies bütün araç adlarını kategoriler içinde sade metinle korur; her araç ayrı kart olmaz. Tweets ve Repositories ilk iki öğeyi gösterir, kalan öğelere JavaScript gerektirmeyen Show more ile erişilir. Spotify kişisel bir iz olarak korunur. Servis adı birincil, nick/adres ikincildir. Hesap doğrulaması nick yanındaki ortak PGP tiki üzerinden açılır. Adresi henüz tanımlanmayan Email ve XMPP görünür kalır; durum metni, sahte bağlantı veya hover ile tıklanabilirlik vaadi eklenmez. + +Connect öğeleri normal 8 px köşeler kullanır; squircle ve hareketli yükselme yoktur. Kenarlığın sağ üst ve sol alt bölgelerinde ton yoğunlaşır; ışık alan bir kenar hissi verir. Aynı kenarlık Connect öğelerinde, Tweets/Repositories/Spotify yüzeylerinde, Connect dış çerçevesinde, Log kartlarında ve Find/Share pencerelerinde ortak src/styles/edge.ts ile kullanılır. Metin bağlantıları, PGP tikleri, rozetler, giriş alanları ve küçük kontroller bu uygulamanın dışındadır. Bu yalnız 1 px çerçeve içinde kalan monokrom bir gradient istisnasıdır; yüzeyde gradient, parıltı veya gölge yoktur. Ton geçişi, her yüzeyin kendi genişliğinin ve yüksekliğinin %50’sini kullanan eliptik gradientlerle ölçeklenir; sabit piksel ışık alanı yoktur. Pencere boyutu ve içerik yüksekliği değişince CSS otomatik hesaplar. Desteklemeyen tarayıcılarda düz çerçeve korunur. Hover yalnız nötr yüzey rengini değiştirir; kenarlık sabit kalır. Nick ve PGP tiki bağımsız hedefler olarak kalır. Bütün doğrulama tikleri ilgili metnin sağında yer alır; Verified etiketli bağlantılarda da sıra metin, ardından tik şeklindedir. + +Logo ve ikonlar + +Mevcut Onur işareti korunur. Logo sabittir; animasyon, rastgele renk veya farklı sayfalarda farklı yorum kullanılmaz. + +Bütün sabit SVG dosyaları public/brand/ altında bulunur. Arayüzde tek Icon.astro bileşeni üzerinden yüklenir. Logo 56 × 56 px, TOR ve RSS 32 × 32 px, I2P 64 × 32 px'dir. Kaynak oranları korunur; ikonlar açık temada siyah, koyu temada beyaz ağırlıklı monokrom görünür. Ortak src/styles/icon-material.ts, SVG alfa silueti üzerinde üst soldan aydınlanan hafif yüzey tonları, tek ışık yönü ve kısa kenara orantılı tek, ince alfa gölgesi sağlar. Gölge ofseti kısa kenarın %2 / %4’ü, bulanıklığı %0,5’tir; üst üste gölge ve ters ışık geçişi kullanılmaz. Arka plan kutusu, animasyon veya siluet değişikliği eklenmez. Yüksek kontrast ve zorunlu renk modlarında düz, okunaklı semboller kullanılır. + +İkon adları ve ölçüleri ortak bileşende tanımlanır. SVG kodunu sayfaya gömme veya yeni bir ikon için ayrı render yolu kurma. Kaynak ve sahiplik bilgileri SOURCES.md içinde tutulur. Favicon ve sosyal kartlar da mevcut Onur işaretini kullanır. + +Kontroller ve etkileşim + +RSS gibi yardımcı sayfalara istenmeden terminal komutları, yerel doğrulama öğreticileri veya teknik açıklama blokları ekleme. Gerekli durum bilgisi ve doğrudan bağlantılar yeterlidir. + +Kontrolün işlevi, görünümünden ve metninden anlaşılır olmalıdır. Ana eylem dolu, yardımcı eylem nötr kenarlıklı olur. Kartlar ve pencereler --radius-card: 12px, butonlar --radius-control: 8px, Connect öğeleri --radius-control: 8px kullanır. Standart kontrol yüksekliği en az 44 px'dir. Düğme metni 16 px ve 500 ağırlıktadır. + +Bağlantılar bağlantı, düğmeler düğme, seçimler gerçek form kontrolleri olarak kalır. PGP gibi çok satırlı veriler salt okunur alanda sunulur. Kopyalama başarısı ve hata sonucu kısa bir durum mesajıyla bildirilir. JavaScript kullanılamadığında anahtar indirme ve elle kopyalama gibi temel yollar erişilebilir kalır. + +Görünür klavye odağı, açıklayıcı etiketler, ekran okuyucu durum mesajları ve azaltılmış hareket tercihi korunur. Klavye odağı 2 px outline ve 3 px offset kullanır. Sadelik, gerekli açıklamaları veya erişilebilirliği kaldırmak anlamına gelmez. + +Sayfaların ortak yapısı +Ana sayfa: İmza, mevcut motto ve yalın gezinme. +PGP: Anahtarın amacı, parmak izi, indirme ve kopyalanabilir anahtar. +RSS: Feed'in amacı, adresi ve abonelik/kopyalama eylemleri. +Donate: Monero desteğinin amacı, tutar seçimi ve gerçek ödeme durumu. + +Yardımcı sayfalar tek bir h1, kısa açıklama ve gereken kontrollerle başlar. Yeni sayfalar aynı hizayı, renkleri, fontu ve kontrol dilini kullanır. Yeni içerik yalnızca gerçek bir ihtiyaç veya mevcut bir iş için eklenir. + +Yeni işler için karar ölçütü + +Bir değişiklik yapılırken şu soruların cevabı açık olmalıdır: + +Ziyaretçinin hangi işini kolaylaştırıyor? +Mevcut bileşen, stil ve yerleşimle yapılabiliyor mu? +Onur'un kişisel ve doğrudan sesini koruyor mu? +Mobilde, iki temada ve klavyeyle kullanılabiliyor mu? +Metindeki iddialar mevcut içerik veya doğrulanmış davranışla destekleniyor mu? + +Bu marka çizgisini değiştirmek, bir sayfa geliştirmesinin yan etkisi olmamalıdır. Farklı font, renk paleti, animasyon veya yeni kimlik yönü ancak açık bir tasarım kararıyla ele alınır. \ No newline at end of file diff --git a/docs/reference/helium-x-style.json b/docs/reference/helium-x-style.json new file mode 100644 index 0000000..c13e297 --- /dev/null +++ b/docs/reference/helium-x-style.json @@ -0,0 +1,2236 @@ +{ + "captured_at": "2026-10-29T22:24:15.546928+00:00", + "source": "Actual logged-in Helium session via Chrome DevTools MCP 1.12.0. Only public UI styles, geometry and icon SVGs; no cookies, tokens or private messages.", + "references": { + "timeline-reference": { + "url": "https://x.com/Osimhen_31", + "viewport": { + "width": 1032, + "height": 950, + "dpr": 1.1015625 + }, + "body": { + "tag": "BODY", + "role": null, + "testid": null, + "rect": { + "x": 0, + "y": -2411.12060546875, + "width": 1032.170166015625, + "height": 949.5602416992188 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgb(0, 0, 0)", + "borderColor": "rgb(231, 233, 234)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "block", + "gap": "normal", + "width": "1032.17px", + "height": "949.56px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "normal" + } + }, + "primary": { + "tag": "DIV", + "role": null, + "testid": "primaryColumn", + "rect": { + "x": 100.0851058959961, + "y": -2411.12060546875, + "width": 600, + "height": 9947.900390625 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgb(0, 0, 0)", + "borderColor": "rgb(47, 51, 54)", + "borderWidth": "0px 0.907801px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "600px", + "height": "9947.9px", + "maxWidth": "600px", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "articles": [ + { + "article": { + "tag": "ARTICLE", + "role": "article", + "testid": "tweet", + "rect": { + "x": 100.99290466308594, + "y": -1787.6737060546875, + "width": 598.1843872070312, + "height": 1068.425537109375 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "598.184px", + "height": "1068.43px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "user": { + "tag": "DIV", + "role": null, + "testid": "User-Name", + "rect": { + "x": 164.99290466308594, + "y": -1759.6737060546875, + "width": 208.7659454345703, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "208.766px", + "height": "20px", + "maxWidth": "100%", + "minWidth": "0px", + "alignItems": "center" + } + }, + "avatar": { + "tag": "DIV", + "role": null, + "testid": "UserAvatar-Container-Osimhen_31", + "rect": { + "x": 116.99290466308594, + "y": -1759.6737060546875, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "block", + "gap": "normal", + "width": "40px", + "height": "40px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "actions": { + "tag": "DIV", + "role": "group", + "testid": null, + "rect": { + "x": 164.99290466308594, + "y": -751.2482299804688, + "width": 518.1843872070312, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal 4px", + "width": "518.184px", + "height": "20px", + "maxWidth": "600px", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "icons": { + "reply": "", + "retweet": "", + "like": "", + "bookmark": "", + "analytics": null + } + }, + { + "article": { + "tag": "ARTICLE", + "role": "article", + "testid": "tweet", + "rect": { + "x": 100.99290466308594, + "y": -718.28369140625, + "width": 598.1843872070312, + "height": 646.2553100585938 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "598.184px", + "height": "646.255px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "user": { + "tag": "DIV", + "role": null, + "testid": "User-Name", + "rect": { + "x": 164.99290466308594, + "y": -706.28369140625, + "width": 189.34751892089844, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "189.348px", + "height": "20px", + "maxWidth": "100%", + "minWidth": "0px", + "alignItems": "center" + } + }, + "avatar": { + "tag": "DIV", + "role": null, + "testid": "UserAvatar-Container-Osimhen_31", + "rect": { + "x": 116.99290466308594, + "y": -706.28369140625, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "block", + "gap": "normal", + "width": "40px", + "height": "40px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "actions": { + "tag": "DIV", + "role": "group", + "testid": null, + "rect": { + "x": 164.99290466308594, + "y": -104.02836608886719, + "width": 518.1843872070312, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal 4px", + "width": "518.184px", + "height": "20px", + "maxWidth": "600px", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "icons": { + "reply": "", + "retweet": "", + "like": "", + "bookmark": "", + "analytics": null + } + }, + { + "article": { + "tag": "ARTICLE", + "role": "article", + "testid": "tweet", + "rect": { + "x": 100.99290466308594, + "y": -70.11347198486328, + "width": 598.1843872070312, + "height": 686.5106201171875 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "598.184px", + "height": "686.511px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "user": { + "tag": "DIV", + "role": null, + "testid": "User-Name", + "rect": { + "x": 164.99290466308594, + "y": -58.11347198486328, + "width": 189.5035400390625, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal", + "width": "189.504px", + "height": "20px", + "maxWidth": "100%", + "minWidth": "0px", + "alignItems": "center" + } + }, + "avatar": { + "tag": "DIV", + "role": null, + "testid": "UserAvatar-Container-Osimhen_31", + "rect": { + "x": 116.99290466308594, + "y": -58.11347198486328, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "block", + "gap": "normal", + "width": "40px", + "height": "40px", + "maxWidth": "none", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "actions": { + "tag": "DIV", + "role": "group", + "testid": null, + "rect": { + "x": 164.99290466308594, + "y": 584.3971557617188, + "width": 518.1843872070312, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderColor": "rgb(0, 0, 0)", + "borderWidth": "0px", + "borderRadius": "0px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginRight": "0px", + "marginBottom": "0px", + "marginLeft": "0px", + "display": "flex", + "gap": "normal 4px", + "width": "518.184px", + "height": "20px", + "maxWidth": "600px", + "minWidth": "0px", + "alignItems": "stretch" + } + }, + "icons": { + "reply": "", + "retweet": "", + "like": "", + "bookmark": "", + "analytics": null + } + } + ] + }, + "public-profile": { + "url": "https://x.com/Osimhen_31", + "profile": { + "[data-testid=\"UserName\"]": { + "rect": { + "x": 163.6453857421875, + "y": 337.007080078125, + "width": 566.1843872070312, + "height": 44.89361572265625 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "44.8936px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "4px", + "marginBottom": "12px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "[data-testid=\"UserName\"] span": { + "rect": { + "x": 163.6453857421875, + "y": 337.007080078125, + "width": 83.30496215820312, + "height": 24.89361572265625 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "20px", + "fontWeight": "800", + "lineHeight": "24px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "83.305px", + "height": "24.8936px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "[data-testid=\"UserDescription\"]": { + "rect": { + "x": 163.6453857421875, + "y": 417.90069580078125, + "width": 566.1843872070312, + "height": 40 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "20px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "40px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "[data-testid=\"UserProfileHeader_Items\"]": { + "rect": { + "x": 163.6453857421875, + "y": 470.4822692871094, + "width": 213.34750366210938, + "height": 18.156028747558594 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "12px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "auto", + "height": "auto", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "a[href$=\"/header_photo\"]": { + "rect": { + "x": 147.6453857421875, + "y": 52.99290466308594, + "width": 598.1843872070312, + "height": 199.3900604248047 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "598.184px", + "height": "199.39px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "a[href$=\"/photo\"]": { + "rect": { + "x": 161.63829040527344, + "y": 177.45388793945312, + "width": 145.5460968017578, + "height": 145.5460968017578 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "145.546px", + "height": "145.546px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "a[href$=\"/following\"]": { + "rect": { + "x": 163.6453857421875, + "y": 501.54608154296875, + "width": 80.35460662841797, + "height": 20 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "20px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "80.3546px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "a[href$=\"/verified_followers\"]": { + "rect": { + "x": 264, + "y": 501.54608154296875, + "width": 106.02836608886719, + "height": 20 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "20px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "106.028px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + "[role=\"tablist\"]": { + "rect": { + "x": 147.6453857421875, + "y": 569.5460815429688, + "width": 598.1843872070312, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "598.184px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + } + }, + "tabs": [ + { + "rect": { + "x": 147.6453857421875, + "y": 569.5460815429688, + "width": 131.27659606933594, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "131.277px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + { + "rect": { + "x": 278.9219665527344, + "y": 569.5460815429688, + "width": 121.39006805419922, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "121.39px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + { + "rect": { + "x": 400.3120422363281, + "y": 569.5460815429688, + "width": 127.40425109863281, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "127.404px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + { + "rect": { + "x": 527.71630859375, + "y": 569.5460815429688, + "width": 105.02127075195312, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "105.021px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + }, + { + "rect": { + "x": 632.737548828125, + "y": 569.5460815429688, + "width": 113.09219360351562, + "height": 52.99290466308594 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "113.092px", + "height": "52.9929px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "position": "relative" + } + } + ] + }, + "post-comments": { + "url": "https://x.com/Burhimum/status/2115761952937128381", + "articles": [ + { + "article": { + "tag": "ARTICLE", + "rect": { + "x": 147.6453857421875, + "y": 52.99290466308594, + "width": 598.1843872070312, + "height": 772.2410888671875 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "598.184px", + "height": "772.241px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "user": { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 64.99290466308594, + "width": 195.71630859375, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "195.716px", + "height": "40px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "avatar": { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 64.99290466308594, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "40px", + "height": "40px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + "textAncestors": [ + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 142.99290466308594, + "width": 566.1843872070312, + "height": 120 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "17px", + "fontWeight": "400", + "lineHeight": "24px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "120px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 118.99290466308594, + "width": 566.1843872070312, + "height": 180 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "180px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 106.99290466308594, + "width": 566.1843872070312, + "height": 718.2410888671875 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "718.241px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 52.99290466308594, + "width": 566.1843872070312, + "height": 772.2410888671875 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "772.241px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "actions": { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 747.9857788085938, + "width": 566.1843872070312, + "height": 48 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "48px", + "paddingTop": "0px", + "paddingRight": "4px", + "paddingBottom": "0px", + "paddingLeft": "4px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0.907801px 0px 0px", + "borderColor": "rgb(47, 51, 54) rgb(0, 0, 0) rgb(0, 0, 0)", + "gap": "normal 4px", + "display": "flex" + } + }, + "actionChildren": [ + { + "tag": "BUTTON", + "rect": { + "x": 167.6453857421875, + "y": 748.8936157226562, + "width": 43.03546142578125, + "height": 47.09219741821289 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "43.0355px", + "height": "47.0922px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 301.5602722167969, + "y": 748.8936157226562, + "width": 51.58864974975586, + "height": 47.09219741821289 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "51.5887px", + "height": "47.0922px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 435.4893493652344, + "y": 748.8936157226562, + "width": 58.58155822753906, + "height": 47.09219741821289 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "58.5816px", + "height": "47.0922px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 569.4042358398438, + "y": 748.8936157226562, + "width": 50.55318832397461, + "height": 47.09219741821289 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "50.5532px", + "height": "47.0922px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 703.3333129882812, + "y": 748.8936157226562, + "width": 22.49645233154297, + "height": 47.09219741821289 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "22.4965px", + "height": "47.0922px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "icons": [ + "", + "", + "", + "", + "" + ] + }, + { + "article": { + "tag": "ARTICLE", + "rect": { + "x": 147.6453857421875, + "y": 909.9573974609375, + "width": 598.1843872070312, + "height": 162 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "598.184px", + "height": "162px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "-1px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "user": { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 921.9573974609375, + "width": 317.39007568359375, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "317.39px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "avatar": { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 921.9573974609375, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "40px", + "height": "40px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + "textAncestors": [ + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 967.9573974609375, + "width": 518.1843872070312, + "height": 60 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "20px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "60px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 943.9573974609375, + "width": 518.1843872070312, + "height": 84 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "84px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 921.9573974609375, + "width": 518.1843872070312, + "height": 150 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "150px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "12px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 921.9573974609375, + "width": 566.1843872070312, + "height": 150 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "150px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "actions": { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1039.9573974609375, + "width": 518.1843872070312, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal 4px", + "display": "flex" + } + }, + "actionChildren": [ + { + "tag": "BUTTON", + "rect": { + "x": 211.6453857421875, + "y": 1039.9573974609375, + "width": 35.0780143737793, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "35.078px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 328.82269287109375, + "y": 1039.9573974609375, + "width": 40.269500732421875, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "40.2695px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 446, + "y": 1039.9573974609375, + "width": 48.17021179199219, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "48.1702px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "A", + "rect": { + "x": 563.1773071289062, + "y": 1039.9573974609375, + "width": 50.808509826660156, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "50.8085px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 680.3546142578125, + "y": 1039.9573974609375, + "width": 18.73758888244629, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "18.7376px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 711.0921630859375, + "y": 1039.9573974609375, + "width": 18.73758888244629, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "18.7376px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "icons": [ + "", + "", + "", + "", + "", + "" + ] + }, + { + "article": { + "tag": "ARTICLE", + "rect": { + "x": 147.6453857421875, + "y": 1071.5460205078125, + "width": 598.1843872070312, + "height": 410.7943115234375 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "598.184px", + "height": "410.794px", + "paddingTop": "0px", + "paddingRight": "16px", + "paddingBottom": "0px", + "paddingLeft": "16px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "user": { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1083.5460205078125, + "width": 321.5886535644531, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "321.589px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + "avatar": { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 1083.5460205078125, + "width": 40, + "height": 40 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "40px", + "height": "40px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + "textAncestors": [ + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1129.5460205078125, + "width": 518.1843872070312, + "height": 80 + }, + "css": { + "fontFamily": "TwitterChirp, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "20px", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "80px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "block" + } + }, + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1105.5460205078125, + "width": 518.1843872070312, + "height": 123.99999237060547 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "124px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1083.5460205078125, + "width": 518.1843872070312, + "height": 398.7943115234375 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "398.794px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "12px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "DIV", + "rect": { + "x": 163.6453857421875, + "y": 1083.5460205078125, + "width": 566.1843872070312, + "height": 398.7943115234375 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "566.184px", + "height": "398.794px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "actions": { + "tag": "DIV", + "rect": { + "x": 211.6453857421875, + "y": 1450.34033203125, + "width": 518.1843872070312, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "518.184px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "12px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal 4px", + "display": "flex" + } + }, + "actionChildren": [ + { + "tag": "BUTTON", + "rect": { + "x": 211.6453857421875, + "y": 1450.34033203125, + "width": 34.59574508666992, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "34.5957px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 328.82269287109375, + "y": 1450.34033203125, + "width": 41.29077911376953, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "41.2908px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 446, + "y": 1450.34033203125, + "width": 51.90070724487305, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "51.9007px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "A", + "rect": { + "x": 563.1773071289062, + "y": 1450.34033203125, + "width": 52.02836608886719, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(158, 158, 255)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "52.0284px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 680.3546142578125, + "y": 1450.34033203125, + "width": 18.73758888244629, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "18.7376px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + }, + { + "tag": "BUTTON", + "rect": { + "x": 711.0921630859375, + "y": 1450.34033203125, + "width": 18.73758888244629, + "height": 20 + }, + "css": { + "fontFamily": "\"Times New Roman\"", + "fontSize": "15px", + "fontWeight": "400", + "lineHeight": "normal", + "color": "rgb(231, 233, 234)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "width": "18.7376px", + "height": "20px", + "paddingTop": "0px", + "paddingRight": "0px", + "paddingBottom": "0px", + "paddingLeft": "0px", + "marginTop": "0px", + "marginBottom": "0px", + "borderRadius": "0px", + "borderWidth": "0px", + "borderColor": "rgb(0, 0, 0)", + "gap": "normal", + "display": "flex" + } + } + ], + "icons": [ + "", + "", + "", + "", + "", + "" + ] + } + ] + } + } +} diff --git a/docs/reference/x-icons.json b/docs/reference/x-icons.json new file mode 100644 index 0000000..105c44d --- /dev/null +++ b/docs/reference/x-icons.json @@ -0,0 +1,11 @@ +{ + "source": "Observed in the user Helium X post/reply action bars through Chrome DevTools MCP", + "paths": { + "reply": "M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z", + "repost": "M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z", + "like": "M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z", + "bookmark": "M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z", + "share": "M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z", + "views": "M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z" + } +} diff --git a/docs/superpowers/plans/2026-10-29-limitlessx.md b/docs/superpowers/plans/2026-10-29-limitlessx.md new file mode 100644 index 0000000..f68bef4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-limitlessx.md @@ -0,0 +1,61 @@ +# LimitlessX Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans. Continue inline as requested by “bitir”. + +**Goal:** Deliver the Rust-first extension, local record, brand, landing page, independently packaged browser builds, and evidence for all release gates. +**Architecture:** Rust core validates input, restriction evidence, provider payloads and eligibility. A Rust/WASM background dispatches requests and owns IndexedDB; Leptos renders extension pages. Small packaged JavaScript adapters expose browser APIs and observe bounded DOM notices. +**Tech Stack:** Rust, Leptos CSR, wasm-bindgen, web-sys Fetch, serde, thiserror, IndexedDB, Cargo xtask, Python browser harness. +**Spec:** docs/product-spec.md + +## Global Constraints +- No TypeScript, React, Node application framework, remote executable code, telemetry, X credentials, or broad history permissions. +- FxTwitter API v2, replaceable provider boundary, explicit attribution, honest errors and optional fields. +- Chromium and Firefox MV3 packages; locally packaged WASM; minimal CSP and web-accessible resources. +- Deduplicated, atomic, per-install hashed local Record. Only deliberate successful eligible views count. +- English UI, system theme, monochrome original SVG, 4px spacing, 8px controls, 12px panels. +- No store or compatibility claims without actual evidence. + +## Review Focus +- Malicious URLs/provider media and untrusted page messages must never become executable content or arbitrary network requests. +- Navigation races must never apply an old restriction to a different target. +- Concurrent record insertion, settings changes and clearing must remain atomic across tabs and restarts. +- Worker restarts must preserve valid short-lived reader context, without exposing it to arbitrary pages. +- Missing, private, deleted, suspended, rate-limited and malformed data must not be called regional withholding. + +## Task 1: Feasibility and reproducible toolchain +Files: scripts/probe-provider.py, docs/verification/feasibility.json, rust-toolchain.toml, Cargo.toml, .cargo/config.toml. +- [ ] Probe live normal, historically withheld, protected, suspended, missing and invalid targets, retaining minimal evidence and provenance. +- [ ] Document limits: live provider availability does not establish a present X notice; no universal accessibility claim. +- [ ] Pin toolchain and build tools. Verify WASM target and browser availability. + +## Task 2: Typed domain and provider +Files: crates/core/{src/lib.rs,tests/domain.rs}, crates/fxtwitter/{src/lib.rs,tests/provider.rs}, tests/fixtures. +Interfaces: Target::parse(&str), Restriction::detect(&Target, &Notice), provider::parse_profile/parse_post/parse_timeline -> Result. +- [ ] Write tests for URL host/path attacks, reserved routes, matching notice evidence and deliberate eligibility; observe failures, implement and pass. +- [ ] Write fixture tests for envelopes, tombstones, missing fields, media schemes, code-vs-HTTP errors and pagination; observe failures, implement and pass. + +## Task 3: Runtime, sessions and storage +Files: crates/runtime/src, crates/storage/src, extension/adapters/{background,api}.js. +Interfaces: async dispatch(message, trusted_sender) -> serialized response. IndexedDB owns settings, salted hashed keys and short-lived sessions. +- [ ] Verify real IndexedDB races and persistence in browser tests. +- [ ] Restrict operations by trusted sender, bind contexts to target and short expiry, omit credentials and bound requests with abort and payload limits. +- [ ] Verify background WASM startup and message response in both browsers before expanding UI. + +## Task 4: Leptos pages, detection and brand +Files: crates/ui/src/{lib,popup,reader,options}.rs, extension/adapters/content.js, extension/static. +- [ ] Browser tests first for manual input, notices, false positives, navigation races, explicit fetch, provider errors, pagination, deliberate post activation and record controls. +- [ ] Build quiet responsive popup, reader and settings; escaped text, safe media URLs, attribution, loading, error and empty states. +- [ ] Add original monochrome SVG mark and raster extension icons, system light/dark styles. +- [ ] Prefer extension-tab fallback initially, then verify embedding and narrowly scoped reader resources if embedding can be made safe. + +## Task 5: Packaging and release verification +Files: crates/xtask/src/main.rs, extension/manifests, .github/workflows, tests/browser. +- [ ] Implement cargo xtask build chromium / firefox, test and package. Validate artifacts, permissions, CSP, local scripts and repeatability. +- [ ] Run native tests, WASM builds, browser integration for Chromium and Firefox, restart/concurrency/security tests and measured performance baselines. +- [ ] Add source-review archives and CI; run quality checks. + +## Task 6: Landing page and release documentation +Files: site/, README.md, LICENSE, docs/{architecture,privacy,brand,compatibility}.md, docs/verification/release-audit.md. +- [ ] Build accessible static landing page with accurate install availability, official badge assets where available, privacy, Record illustration and open-source links. +- [ ] Prepare store listings and submission instructions. Published store URLs require actual store publication; never invent them. +- [ ] Audit every spec section and gate against authoritative evidence; keep the goal active for incomplete external release gates. diff --git a/docs/superpowers/plans/2026-10-30-helium-native-x.md b/docs/superpowers/plans/2026-10-30-helium-native-x.md new file mode 100644 index 0000000..89c67d7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-helium-native-x.md @@ -0,0 +1,18 @@ +# Helium native X fidelity and installation + +Goal: Match the user's actual Helium X profile, detailed post and reply layouts; install and verify LimitlessX in that same browser. +Authority: Latest user request supersedes estimated styling. Current direction still requires automatic X DOM injection, popup only, supplied SVG, and no use of X session credentials by the extension. + +- [x] Connect Chrome DevTools MCP to the existing Helium profile after browser remote-debugging permission. +- [x] Install current unpacked extension in Helium and verify enabled ID. +- [x] Capture public profile, post and reply computed styles, native icon paths and geometry. +- [x] Reproduce and fix actual Account Withheld DOM heading detection. +- [ ] Add typed optional profile fields, metrics, quoted posts and conversation replies/pagination; preserve tombstone/error distinctions. +- [ ] Match native layout: 600px column, 16px gutters, 40px avatars, 8px gap, 15/20px replies, full-width 17/24px detailed text, native header/banner/counts and native icon metrics. +- [ ] Add regression tests for real notice, conversation identity/pagination, metadata nullability, DOM text safety and native computed geometry. +- [ ] Rebuild, reload extension through browser MCP and verify automatic display on the actual withheld account; verify profile, post and replies in Helium. +- [ ] Capture evidence and compatibility limits; preserve user's unrelated browser tabs and settings. + +Current extension ID: lbeeopacdoaccgmepkfhilamcdnhibii. +Public reference pages: https://x.com/Osimhen_31 and https://x.com/Burhimum/status/2115761952937128381. +Live restriction reference: https://x.com/ekrem_imamoglu, DOM text begins Account Withheld@ekrem_imamoglu has been withheld in TR. diff --git a/docs/superpowers/plans/2026-10-30-release-review.md b/docs/superpowers/plans/2026-10-30-release-review.md new file mode 100644 index 0000000..52cef87 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-release-review.md @@ -0,0 +1,11 @@ +# Review, TypeScript and repository delivery + +User requests implementation through push and merge. Keep existing Rust domain/runtime/storage and popup-only architecture. + +1. Review current source and independent reviewer findings. Verify identity fallback, automatic unique counts, metadata ownership, navigation and media. +2. Move handwritten browser adapters to strict TypeScript. Build them to packaged JS through pinned tooling; keep Rust responsible for business decisions. Typecheck in xtask and CI. Generated wasm-bindgen glue remains JS. +3. Inspect native X media behavior and provide a reliable packaged player using public media URLs, with accessible controls and no X session credentials or remote executable player. Preserve playback on incremental feed updates. Test controls and media failure. +4. Align popup with design-reference.md (monochrome, ordinary typography, direct copy, functional hierarchy). Keep product mark and By Onur signature. +5. Rewrite README with logo, exact motto, honest browser badges, installation/build instructions, privacy/source limits and open-source/MIT emphasis. No fake store badges or installation counts. +6. Run native tests, strict TS, native/WASM clippy, packages, Chromium and Firefox browser checks, actual Helium inspection. Fix material review findings and document evidence. +7. Commit, push branch, open/attach PR, inspect CI, resolve failures and merge when green. Verify merged remote revision and clean local state. Do not mark goal complete before all steps are evidenced. diff --git a/docs/verification/current-build.json b/docs/verification/current-build.json new file mode 100644 index 0000000..a4d5352 --- /dev/null +++ b/docs/verification/current-build.json @@ -0,0 +1,103 @@ +{ + "verified_at": "2026-10-30T03:30:41.394752+00:00", + "native_tests": { + "passed": 23, + "failed": 0 + }, + "clippy": { + "native": "passed", + "wasm": "passed" + }, + "browser_checks": { + "chromium": [ + "packaged worker/popup smoke", + "automatic restricted-content injection", + "generic failure ignored", + "single request per notice", + "native post typography and borders", + "dim theme update without refetch", + "popup-only HTML", + "popup settings", + "manual profile injection and pagination", + "text-node XSS protection", + "post activation in the same container", + "tab/source binding", + "deduplication", + "worker restart", + "session expiry before fetch", + "SPA route cleanup", + "refreshed reused notice", + "rate-limit cooldown", + "malformed response", + "browser restart", + "actual Helium notice heading regression", + "profile metadata and real v2 conversation normalization", + "17/24 main post and 15/20 reply typography", + "40px avatar and 8px gap", + "reply pagination and deliberate view accounting", + "DOM removal recovery and equivalent post URL redirect", + "delayed primary column: never mount in body/main", + "primary replacement twice without duplicate provider requests", + "native timeline arriving after extension mount", + "first five posts, five-post buffered scroll increments", + "parallel initial profile and timeline requests", + "stable existing post DOM during feed append", + "pagination failure preserves posts and supports retry", + "short feed downward wheel loads the next batch", + "Replies/Media/Reposts same-tab routes use cached typed profile filters; five-item limits persist", + "exact UID fallback from username 404; suggestion counts ignored", + "live popup automatic counters and metadata restoration", + "initial timeline retry, identity error eviction, retained reply pagination and newest session restoration", + "packaged media playback, seek, volume, speed, full screen, failure retry and navigation pause" + ], + "firefox": [ + "packaged event-page/popup smoke", + "automatic inline rendering under strict page CSP", + "no extension reader page", + "automatic restricted success counted uniquely", + "popup settings" + ], + "helium": { + "installed": true, + "enabled": true, + "extension_id": "lbeeopacdoaccgmepkfhilamcdnhibii", + "regional_profile": "Actual x.com/ekrem_imamoglu regional notice automatically replaced with public profile. Latest timeline lookup returned not-found; earlier lookup returned posts.", + "conversation": "Actual public post 2115761952937128381 rendered with 34 provider replies; native main text 17/24, replies 15/20, avatars 40px and 8px gap.", + "inspection": "User-authorized Chrome DevTools MCP connected to existing Helium profile; no browser restart.", + "lifecycle_fix": { + "before": "Host outside primary: 1225.36px wide under native placeholder", + "after": "Host inside primary: 598.18px wide within 600px column; native placeholder hidden; profile and timeline displayed", + "normal_navigation": "Public post has no extension host or hidden native regions" + }, + "incremental_feed": { + "initial_posts": 5, + "after_End": 10, + "profile_avatar_natural_width": 200, + "initial_requests_start_gap_ms": 0.7, + "observed_profile_request_ms": 2649.6, + "observed_timeline_request_ms": 3011.4, + "note": "Observed live provider latency, not a guaranteed startup time. Upstream may still return 20 posts; only five are rendered initially." + } + } + }, + "transport_limits": { + "chromium": "Controlled provider and X DOM fixtures; real packaged Rust/WASM and IndexedDB.", + "firefox": "Controlled X DOM; public live FxTwitter status/20 (event-page Fetch is not intercepted by Firefox BiDi)." + }, + "wasm_bytes": { + "limitless_runtime_bg.wasm": 720764, + "limitless_ui_bg.wasm": 253771 + }, + "release_status": "Verified local development packages; signing and browser-store publication are not part of this repository delivery.", + "typescript": { + "compiler": "5.9.3", + "strict": true, + "typecheck": "passed", + "runtime_dependencies": 0 + }, + "independent_review": { + "findings": 4, + "resolved": 4, + "test": "tests/browser/review_regressions.py" + } +} diff --git a/docs/verification/feasibility.json b/docs/verification/feasibility.json new file mode 100644 index 0000000..27144a9 --- /dev/null +++ b/docs/verification/feasibility.json @@ -0,0 +1,129 @@ +{ + "checked_at": "2026-10-29T20:51:08.303531+00:00", + "provider": "FxTwitter v2", + "restriction_provenance": "https://www.hrw.org/news/2025/05/08/social-media-companies-should-resist-turkish-state-censorship", + "limitations": [ + "Historical withholding is documented; current X DOM restriction must be separately verified.", + "Candidates are not assumed private, deleted or suspended unless the response explicitly identifies that state.", + "One successful public lookup is not a guarantee of access." + ], + "cases": [ + { + "case": "normal_post", + "path": "/2/status/20", + "http_status": 200, + "code": 200, + "message": null, + "reason": null, + "has_user": false, + "protected": null, + "has_post": true, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "normal_profile", + "path": "/2/profile/github", + "http_status": 200, + "code": 200, + "message": "OK", + "reason": null, + "has_user": true, + "protected": false, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "historically_withheld_profile", + "path": "/2/profile/ekrem_imamoglu", + "http_status": 404, + "code": 404, + "message": "User not found", + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "historically_withheld_timeline", + "path": "/2/profile/ekrem_imamoglu/statuses", + "http_status": 200, + "code": 200, + "message": null, + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 20, + "has_next_cursor": true + }, + { + "case": "protected_candidate", + "path": "/2/profile/asmallteapot", + "http_status": 404, + "code": 404, + "message": "User not found", + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "suspended_candidate", + "path": "/2/profile/0", + "http_status": 404, + "code": 404, + "message": "User not found", + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "missing_candidate", + "path": "/2/profile/lxnonexistent9z", + "http_status": 404, + "code": 404, + "message": "User not found", + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "deleted_or_missing_post", + "path": "/2/status/1234567890123456789", + "http_status": 404, + "code": 404, + "message": null, + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + }, + { + "case": "invalid_request", + "path": "/2/status/invalid", + "http_status": 400, + "code": 400, + "message": "id: tweet ID must be a numeric snowflake", + "reason": null, + "has_user": false, + "protected": null, + "has_post": false, + "post_count": 0, + "has_next_cursor": false + } + ] +} diff --git a/docs/verification/release-audit.md b/docs/verification/release-audit.md new file mode 100644 index 0000000..c9329d2 --- /dev/null +++ b/docs/verification/release-audit.md @@ -0,0 +1,21 @@ +# Release audit — development build + +The current behavior is defined by [the user's revised direction](../implementation-direction.md). The attached original spec is historical where it conflicts. + +| Requirement | Current evidence | Remaining | +|---|---|---| +| Direct X DOM injection; popup only | Chromium automatic inline test and Firefox live-provider inline test; package validator rejects reader/options pages | Real X layouts/notices across supported languages | +| Automatic regional detection | Rust notice rules; generic suspended error ignored; one lookup for an unchanged notice; route refresh integration contracts | Actual current account notice verified in Helium; additional languages and regional post notices remain | +| X-style presentation | Native post/profile typography, avatar column, timeline borders and page theme; actual rendered screenshots; theme change test | Actual Helium profile/post/reply geometry and SVG actions captured; unusual media layouts and broader comparisons remain | +| User-provided logo | Six-stripe SVG master, retained reference image, 16/32/48/128 PNG derivatives | None for local assets | +| Rust-first app | Domain/provider/runtime/storage in Rust; Leptos popup; Rust inline DOM view and strict TypeScript browser adapters | Independent store source review | +| Provider capability | Live historically withheld account timeline returned 20 posts with pagination; normal status/20 verified | Current regional profile notice verified in Helium; profile/timeline endpoint availability remains inconsistent, reliable private/suspended/deleted live examples remain | +| Local Record | Atomic IndexedDB, salted unique keys, disable/reset, successful automatic restricted-view eligibility, dedup and restart tests | Broader concurrent reset stress tests | +| Security | Exact source/tab sessions, expiry before fetch, escaped DOM text, narrow permissions, credential omission, host CSP preserved, private executable resources | Complete threat review and network/media audit | +| Cross-browser builds | MV3 Chromium worker and Firefox event-page smoke, automatic inline rendering in both | Individually untested browser variants remain labeled untested | +| Packaging | cargo xtask build chromium/firefox, test and package; deterministic zip test; source archive and CI workflow | Signing/store submission remain separate from repository delivery | +| Public v1.0 | Not declared | All remaining gates, actual browser-store publication | + +No store availability badges, store URLs, universal access claims or variant compatibility claims are invented. A standalone landing page and extension reader/options pages are excluded by the user's revised instruction. + +Repository delivery adds an independent code review with four resolved recovery/session findings, strict TypeScript checks, exact UID recovery, live counter/metadata tests and packaged media-control tests. These prove the implemented development build, not universal provider uptime or store approval. diff --git a/extension/adapters/background-firefox.ts b/extension/adapters/background-firefox.ts new file mode 100644 index 0000000..3cc372a --- /dev/null +++ b/extension/adapters/background-firefox.ts @@ -0,0 +1,2 @@ +// Firefox MV3 uses an event page; its classic entry delegates to the same module. +import('./background.js'); diff --git a/extension/adapters/background.ts b/extension/adapters/background.ts new file mode 100644 index 0000000..de15059 --- /dev/null +++ b/extension/adapters/background.ts @@ -0,0 +1,14 @@ +import './bridge.js'; +import init, { dispatch } from '../pkg/runtime/limitless_runtime.js'; +const api = globalThis.browser ?? chrome; +const ready = init(); +// Register synchronously, before any WASM initialization or asynchronous work. +api.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.event === 'record_changed') return false; + const request = { ...message }; delete request.page_url; + ready.then(() => dispatch(request, { + id: sender.id ?? '', url: sender.url ?? '', page_url: message?.page_url ?? null, tab_id: sender.tab?.id ?? null, + frame_id: sender.frameId ?? 0, extension_origin: api.runtime.getURL('') + })).then(respond, () => respond({ ok: false, error: 'unavailable', message: 'Could not initialize LimitlessX. Reopen the extension.' })); + return true; +}); diff --git a/extension/adapters/bridge.ts b/extension/adapters/bridge.ts new file mode 100644 index 0000000..cbb162e --- /dev/null +++ b/extension/adapters/bridge.ts @@ -0,0 +1,23 @@ +export {}; +// Browser interoperability only. Validation and all application policy live in Rust. +const api = globalThis.browser ?? chrome; +globalThis.limitlessBridge = { + record_changed: () => { api.runtime.sendMessage({ event: 'record_changed' }).catch(() => {}); }, + random_bytes: size => crypto.getRandomValues(new Uint8Array(size)), + session_get: async key => (await api.storage.session.get(key))[key] ?? null, + session_set: async (key, value) => api.storage.session.set({ [key]: value }), + session_remove: async keys => api.storage.session.remove(keys), + session_all: async () => api.storage.session.get(null), + new_tab: async () => { const tab = await api.tabs.create({ url: 'about:blank' }); if (tab.id === undefined) throw new Error('Missing tab ID'); return tab.id; }, + navigate_tab: async (tabId, url) => { await api.tabs.update(tabId, { url }); }, + show_in_tab: (tabId, context) => api.tabs.sendMessage(tabId, { op: 'show_inline', context }), + active_context: async () => { + const [tab] = await api.tabs.query({ active: true, currentWindow: true }); + if (!tab || tab.id === undefined) return null; + let context: {url:string;notices:unknown[];tab_id:number} = { url: tab.url ?? '', notices: [], tab_id: tab.id }; + try { context = { ...context, ...await api.tabs.sendMessage<{url:string;notices:unknown[]}>(tab.id, { op: 'snapshot' }) }; } catch { /* Unsupported or not yet loaded. */ } + return context; + }, + schedule_abort: (controller, ms) => setTimeout(() => controller.abort(), ms), + cancel_timer: id => clearTimeout(id), +}; diff --git a/extension/adapters/content.ts b/extension/adapters/content.ts new file mode 100644 index 0000000..11728e3 --- /dev/null +++ b/extension/adapters/content.ts @@ -0,0 +1,277 @@ +// Observe a bounded set of X notice elements. Rust interprets their contents. +(() => { + const api = globalThis.browser ?? chrome; + const send = (message: Action) => api.runtime.sendMessage({ ...message, page_url: location.href }); + let timer: ReturnType | undefined, generation = 0, lastUrl = location.href; + let styleReady: Promise | undefined, themeSheet: CSSStyleSheet | undefined, themeSignature: string | undefined; + let host: HTMLDivElement | undefined, root: HTMLDivElement | undefined, mountedUrl: string | undefined; + let revision = 0, suppressedRoute: string | undefined, inspectedKey: string | undefined, booting = true; + let cachedPresentation: Presentation | undefined, deferredPresentation: {url:string;context:Presentation} | undefined, replacingNative = false; + let ownedMetadata: {originalTitle:string;title:string;description:string;nodes:[HTMLMetaElement,string|null,string][]} | undefined; + const restoreMetadata = () => { + if (!ownedMetadata) return; + if (document.title === ownedMetadata.title) document.title = ownedMetadata.originalTitle; + for (const [node,original,written] of ownedMetadata.nodes) if (node.content === written) { if (original === null) node.remove(); else node.content = original; } + ownedMetadata = undefined; + }; + const setMetadata = (view: Element) => { + const title = view.getAttribute('data-page-title'); + if (!title) { restoreMetadata(); return; } + const description = view.getAttribute('data-page-description') ?? ''; + if (ownedMetadata?.title === title && ownedMetadata.description === description && ownedMetadata.nodes.every(([node]) => node.isConnected)) { + if (document.title !== title) document.title = title; + for (const [node,,written] of ownedMetadata.nodes) if (node.content !== written) node.content = written; + return; + } + restoreMetadata(); + ownedMetadata = { originalTitle: document.title, title, description, nodes: [] }; + document.title = title; + for (const selector of ['meta[property="og:title"]','meta[property="og:description"]','meta[name="description"]']) { + let node = document.querySelector(selector); + const original = node?.content ?? null; + if (!node) { node = document.createElement('meta'); const match = selector.match(/\[(property|name)="([^"]+)"\]/)!; node.setAttribute(match[1],match[2]); document.head.append(node); } + const written = selector.includes('title') ? title : description; ownedMetadata.nodes.push([node,original,written]); node.content = written; + } + }; + const hiddenNotices = new Map(); + const descriptions = new WeakMap(), scrollActions = new WeakMap(); + let scrollObserver: IntersectionObserver | undefined, scrollArmed = false, lastAutoScroll = -1, autoBusy = false; + const restoreNotices = () => { + for (const [node, original] of hiddenNotices) { + node.hidden = original.hidden; + if (!original.classed) node.classList.remove('limitlessx-notice-hidden'); + } + hiddenNotices.clear(); + }; + const clearInline = () => { + if (root) pauseMedia(root); + restoreMetadata(); scrollObserver?.disconnect(); scrollArmed = false; lastAutoScroll = -1; autoBusy = false; + ++revision; themeSheet = undefined; themeSignature = undefined; restoreNotices(); host?.remove(); host = undefined; root = undefined; mountedUrl = undefined; + }; + // Generic DOM transport: Rust supplies the complete view and each action. + const tags = new Set(['main','header','div','section','article','p','h1','h2','strong','span','time','img','video','a','button','svg','path','br','input','select','option']); + const attributes = new Set(['class','href','src','alt','loading','crossorigin','referrerpolicy','poster','controls','preload','type','role','target','rel','viewBox','fill','d','aria-label','aria-hidden','tabindex','title','datetime','data-autoload','data-post-id','playsinline','data-page-title','data-page-description','data-media-role','data-media-command','min','max','step','value','selected']); + const element = (description: DomDescription): Node => { + if (typeof description === 'string') return document.createTextNode(description); + if (!description || !tags.has(description.tag)) throw new Error('Unsupported DOM element'); + const node = ['svg','path'].includes(description.tag) + ? document.createElementNS('http://www.w3.org/2000/svg', description.tag) + : document.createElement(description.tag); + for (const [name, value] of description.attrs ?? []) { + if (!attributes.has(name)) throw new Error('Unsupported DOM attribute'); + if (['href','src','poster'].includes(name) && new URL(value).protocol !== 'https:') throw new Error('Unsupported resource scheme'); + node.setAttribute(name, value); + } + for (const child of description.children ?? []) node.append(element(child)); + if (node.tagName === 'A') node.addEventListener('click', event => event.stopPropagation()); + descriptions.set(node, JSON.stringify(description)); + if (description.action) { + if (node.hasAttribute('data-autoload')) scrollActions.set(node, description.action); + const activate = (event: Event) => { if (!event.isTrusted) return; event.stopPropagation(); if (node instanceof HTMLButtonElement) node.disabled = true; run(description.action!); }; + node.addEventListener('click', activate); + node.addEventListener('keydown', event => { if (event instanceof KeyboardEvent && event.key === 'Enter') { event.preventDefault(); activate(event); } }); + } + return node; + }; + const apply = async (data: Presentation | null | undefined): Promise => { + if (!data) return; + if (data.view && root) { + cachedPresentation = data; + const next = element(data.view), current = root.firstElementChild; + if (!(next instanceof Element)) throw new Error('Expected an element view'); + setMetadata(next); + if (current?.matches('.x-reader') && next.matches('.x-reader')) { + for (const name of ['data-page-title','data-page-description']) { if (next.hasAttribute(name)) current.setAttribute(name,next.getAttribute(name)!); else current.removeAttribute(name); } + const oldPosts = new Map([...current.children].filter(n => n.hasAttribute('data-post-id')).map(n => [n.getAttribute('data-post-id'), n])); + const desired = [...next.children].map((n, index) => { + const old = n.hasAttribute('data-post-id') ? oldPosts.get(n.getAttribute('data-post-id')) : current.children[index]; + return old && !old.matches(':disabled') && !old.querySelector(':disabled') && descriptions.get(old) === descriptions.get(n) ? old : n; + }); + const keep = new Set(desired); + for (const child of [...current.childNodes]) if (!(child instanceof Element) || !keep.has(child)) { if (child instanceof Element) pauseMedia(child); child.remove(); } + desired.forEach((child, index) => { if (current.children[index] !== child) current.insertBefore(child, current.children[index] ?? null); }); + } else { pauseMedia(root); root.replaceChildren(next); } + enhanceMedia(root); + observeScroll(); + } + else if (data.message && root) { + root.replaceChildren(document.createTextNode(data.message)); + const retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Try again'; + retry.addEventListener('click', event => { if (event.isTrusted) { if (data.retry) run(data.retry); else { inspectedKey = undefined; clearInline(); schedule(); } } }); + root.append(retry); + } + if (data.load) await run(data.load); + }; + const checkScroll = () => { + if (!scrollArmed || autoBusy || !host?.isConnected || Math.abs(scrollY - lastAutoScroll) < 2) return; + const sentinel = root?.querySelector('[data-autoload]'); + if (!sentinel || sentinel.disabled) return; + const box = sentinel.getBoundingClientRect(); + if (box.top > innerHeight + 240 || box.bottom < 0) return; + const action = scrollActions.get(sentinel); + if (!action) return; + lastAutoScroll = scrollY; autoBusy = true; sentinel.disabled = true; + sentinel.setAttribute('aria-busy', 'true'); + run(action).finally(() => { autoBusy = false; }); + }; + const observeScroll = () => { + scrollObserver?.disconnect(); + scrollObserver = new IntersectionObserver(() => checkScroll(), { rootMargin: '240px' }); + const sentinel = root?.querySelector('[data-autoload]'); + if (sentinel) scrollObserver.observe(sentinel); + }; + // A short feed may not yet make the document scrollable. Downward intent still loads its next batch. + addEventListener('wheel', event => { + if (!event.isTrusted || event.deltaY <= 0) return; + scrollArmed = true; lastAutoScroll = -Infinity; requestAnimationFrame(checkScroll); + }, { passive: true }); + addEventListener('keydown', event => { + if (!event.isTrusted || !['End','PageDown','ArrowDown',' '].includes(event.key) || (event.target instanceof Element && event.target.closest('input,textarea,[contenteditable=true]'))) return; + scrollArmed = true; lastAutoScroll = -Infinity; requestAnimationFrame(checkScroll); + }); + addEventListener('scroll', event => { + if (!event.isTrusted) return; + requestAnimationFrame(checkScroll); + }, { passive: true }); + const run = async (message: Action): Promise => { + const attempt = ++revision, sourceUrl = location.href; + root?.setAttribute('aria-busy', 'true'); + try { + const response = await send(message); + if (attempt !== revision || mountedUrl !== sourceUrl || location.href !== sourceUrl || !host?.isConnected) return; + if (!response?.ok && !response?.view) { replacingNative = false; restoreNotices(); } + await apply(response?.ok ? response.data : { ...response, retry: message }); + } catch (error) { + console.warn('LimitlessX DOM rendering failed:', error); + if (attempt === revision && root) await apply({ message: 'Could not reach LimitlessX. Try again.', retry: message }); + } finally { + if (attempt === revision) root?.setAttribute('aria-busy', 'false'); + } + }; + const syncTheme = () => { + const background = [document.querySelector('[data-testid="primaryColumn"]'), document.body, document.documentElement] + .filter((node): node is HTMLElement => node instanceof HTMLElement).map(node => getComputedStyle(node).backgroundColor).find(color => color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent'); + const dark = background ? (background.match(/\d+/g)?.slice(0, 3).reduce((a, b) => a + Number(b), 0) ?? 0) < 384 : matchMedia('(prefers-color-scheme: dark)').matches; + if (!themeSheet || themeSignature === background) return; + themeSignature = background; + themeSheet.replaceSync(`:host{--bg:${background ?? (dark ? '#000' : '#fff')};--fg:${dark ? '#e7e9ea' : '#0f1419'};--muted:${dark ? '#71767b' : '#536471'};--line:${dark ? '#2f3336' : '#eff3f4'};--panel:${dark ? '#16181c' : '#f7f9f9'}}`); + }; + // X owns and can replace the primary column at any time. Never mount elsewhere. + const reconcilePlacement = () => { + const primary = document.querySelector('[data-testid="primaryColumn"]'); + if (!primary || !host || mountedUrl !== location.href) return; + if (host.parentElement !== primary) primary.append(host); + if (!replacingNative) return; + const regions = new Set(roots().map(notice => { + let region = notice; + for (let ancestor = notice.parentElement; ancestor && ancestor !== primary; ancestor = ancestor.parentElement) { + if (ancestor.querySelector('[data-testid="UserName"]') && !ancestor.querySelector('h2[role="heading"]')) region = ancestor; + } + return region; + })); + for (const timeline of primary?.querySelectorAll('[aria-label^="Timeline:"]') ?? []) regions.add(timeline); + for (const node of hiddenNotices.keys()) if (!node.isConnected) hiddenNotices.delete(node); + for (const node of regions) { + if (node.contains(host)) continue; + if (hiddenNotices.has(node) && node.hidden && node.classList.contains('limitlessx-notice-hidden')) continue; + if (!hiddenNotices.has(node)) hiddenNotices.set(node, { hidden: node.hidden, classed: node.classList.contains('limitlessx-notice-hidden') }); + node.classList.add('limitlessx-notice-hidden'); node.hidden = true; + } + }; + const present = async (context: Presentation): Promise<{ok:boolean}> => { + const sourceUrl = location.href; + if (!document.querySelector('[data-testid="primaryColumn"]')) { + deferredPresentation = { url: sourceUrl, context }; + return { ok: true }; + } + deferredPresentation = undefined; + try { + styleReady ??= fetch(api.runtime.getURL('styles/app.css')).then(response => response.text()).then(css => { + const sheet = new CSSStyleSheet(); sheet.replaceSync(css); return sheet; + }); + const sheet = await styleReady; + if (location.href !== sourceUrl) return { ok: false }; + clearInline(); host = document.createElement('div'); host.id = 'limitlessx-inline'; + host.setAttribute('role', 'region'); host.setAttribute('aria-label', 'LimitlessX public content'); + const shadow = host.attachShadow({ mode: 'open' }); shadow.adoptedStyleSheets = [sheet]; + root = document.createElement('div'); root.className = 'inline-root'; shadow.append(root); + const primary = document.querySelector('[data-testid="primaryColumn"]'); + if (!primary) { deferredPresentation = { url: sourceUrl, context }; host = undefined; root = undefined; return { ok: true }; } + primary.append(host); + mountedUrl = sourceUrl; + replacingNative = true; reconcilePlacement(); + themeSheet = new CSSStyleSheet(); syncTheme(); + shadow.adoptedStyleSheets = [sheet, themeSheet]; + await apply(context); + return { ok: true }; + } catch (error) { console.warn('LimitlessX could not mount its DOM view:', error); clearInline(); styleReady = undefined; return { ok: false }; } + }; + const roots = () => [...document.querySelectorAll('[data-testid="primaryColumn"] [data-testid="emptyState"], [data-testid="primaryColumn"] [data-testid="tweetTombstone"]')].slice(0, 8); + const signature = (node: HTMLElement) => (node.textContent ?? '').slice(0, 1600); + let previousNodes = new Map(roots().map(node => [node, signature(node)])); + const staleNodes = new Map(); + const snapshot = () => { + const nodes = roots(); + if (location.href !== lastUrl) { + for (const [node, text] of previousNodes) if (!staleNodes.has(node)) staleNodes.set(node, { text, url: lastUrl }); + clearInline(); cachedPresentation = undefined; deferredPresentation = undefined; suppressedRoute = undefined; inspectedKey = undefined; lastUrl = location.href; ++generation; recoverPending(); + } + if (deferredPresentation?.url === location.href && document.querySelector('[data-testid="primaryColumn"]')) { + const deferred = deferredPresentation; deferredPresentation = undefined; present(deferred.context); + } + if (mountedUrl && !host?.isConnected && mountedUrl === location.href && cachedPresentation) { + const cached = cachedPresentation; clearInline(); present(cached); + } else if (mountedUrl && mountedUrl !== location.href) clearInline(); + reconcilePlacement(); + if (mountedUrl === location.href && root?.firstElementChild) setMetadata(root.firstElementChild); + previousNodes = new Map(nodes.map(node => [node, signature(node)])); + return { + url: location.href, + notices: nodes.filter(node => { + const stale = staleNodes.get(node); + if (!stale) return true; + if (stale.url === location.href || stale.text !== signature(node)) { staleNodes.delete(node); return true; } + return false; + }).map(node => ({ text: signature(node), policy_url: node.querySelector('a[href*="post-withheld-by-country"]')?.href ?? '' })), + }; + }; + const inspect = async () => { + const context = snapshot(), url = location.href; + if (booting || suppressedRoute === url || !context.notices.length) return; + const key = url + JSON.stringify(context.notices); + if (key === inspectedKey) return; + inspectedKey = key; + const attempt = ++generation; + try { + const response = await send({ op: 'inspect', notices: context.notices }); + if (attempt !== generation || url !== location.href || !response?.ok || !response.data?.eligible) return; + const opened = await send({ op: 'auto_detected', notices: context.notices }); + if (attempt !== generation || url !== location.href) return; + if (opened?.ok && opened.data) await present(opened.data); + } catch { inspectedKey = undefined; } + }; + const schedule = () => { clearTimeout(timer); timer = setTimeout(() => { syncTheme(); inspect(); }, 250); }; + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, characterData: true, subtree: true }); + const themeObserver = new MutationObserver(schedule); + themeObserver.observe(document.body, { attributes: true, attributeFilter: ['style', 'class'] }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class'] }); + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule); + addEventListener('popstate', schedule); addEventListener('pageshow', schedule); + api.runtime.onMessage.addListener((message, sender, respond) => { + if (sender.id !== api.runtime.id) return; + if (message?.op === 'snapshot') respond(snapshot()); + if (message?.op === 'show_inline' && message.context) { + suppressedRoute = location.href; present(message.context).then(respond); return true; + } + }); + const recoverPending = () => { + const url = location.href, attempt = generation; + return send({ op: 'pending' }).then(response => { + if (url === location.href && attempt === generation && response?.ok && response.data) { + suppressedRoute = url; return present(response.data); + } + }).catch(() => {}); + }; + recoverPending().finally(() => { booting = false; schedule(); }); +})(); diff --git a/extension/adapters/contracts.d.ts b/extension/adapters/contracts.d.ts new file mode 100644 index 0000000..7d13ce9 --- /dev/null +++ b/extension/adapters/contracts.d.ts @@ -0,0 +1,50 @@ +/** Packaged Rust/WASM view protocol and the narrow WebExtension API surface. */ +type Action = { op: string; [key: string]: unknown }; +type DomDescription = string | { tag: string; attrs: [string,string][]; children: DomDescription[]; action?: Action }; +interface Presentation { view?: DomDescription; message?: string; retry?: Action; load?: Action } +interface RuntimeData extends Presentation { eligible?: boolean } +interface RuntimeResponse extends Presentation { ok: boolean; data?: RuntimeData | null; error?: string } +interface IncomingMessage { op?: string; event?: string; context?: Presentation; page_url?: string; [key:string]: unknown } +interface BrowserSender { id?: string; url?: string; frameId?: number; tab?: { id?: number } } +interface BrowserTab { id?: number; url?: string } +interface BrowserAPI { + runtime: { + id: string; + getURL(path:string):string; + sendMessage(message:unknown):Promise; + onMessage: { addListener(listener:(message:IncomingMessage,sender:BrowserSender,respond:(response:unknown)=>void)=>boolean|void):void }; + }; + storage: { session: { + get(key:string|null):Promise>; + set(value:Record):Promise; + remove(keys:string|string[]):Promise; + }}; + tabs: { + query(query:{ active:boolean; currentWindow:boolean }):Promise; + create(properties:{url:string}):Promise; + update(id:number,properties:{url:string}):Promise; + sendMessage(id:number,message:unknown):Promise; + }; +} +declare var chrome: BrowserAPI; +declare var browser: BrowserAPI | undefined; +declare var limitlessUI: { send(message:unknown):Promise; asset_url(path:string):string }; +declare var limitlessBridge: { + record_changed():void; + random_bytes(size:number):Uint8Array; + session_get(key:string):Promise; + session_set(key:string,value:unknown):Promise; + session_remove(keys:string[]):Promise; + session_all():Promise>; + new_tab():Promise; + navigate_tab(id:number,url:string):Promise; + show_in_tab(id:number,context:Presentation):Promise; + active_context():Promise<{url:string;notices:unknown[];tab_id:number}|null>; + schedule_abort(controller:AbortController,ms:number):number; + cancel_timer(id:number):void; +}; +declare module '*limitless_runtime.js' { + export default function init():Promise; + export function dispatch(request:unknown,sender:unknown):Promise; +} +declare module '*limitless_ui.js' { export default function init():Promise; } diff --git a/extension/adapters/player.ts b/extension/adapters/player.ts new file mode 100644 index 0000000..5f97044 --- /dev/null +++ b/extension/adapters/player.ts @@ -0,0 +1,73 @@ +/** Browser media interoperability. Rust owns the markup and public source URLs. */ +const boundPlayers = new WeakSet(); +function enhanceMedia(root: ParentNode): void { + for (const player of root.querySelectorAll('.lx-player')) { + if (boundPlayers.has(player)) continue; + const video = player.querySelector('video'); + if (!video) continue; + boundPlayers.add(player); + const command = (name: string) => player.querySelector(`[data-media-command="${name}"]`); + const seek = player.querySelector('[data-media-role="seek"]'); + const volume = player.querySelector('[data-media-role="volume"]'); + const speed = player.querySelector('[data-media-role="speed"]'); + const status = player.querySelector('[data-media-role="status"]'); + const source = player.querySelector('.lx-media-source'); + const time = player.querySelector('[data-media-role="time"]'); + const clock = (value: number) => { + const seconds = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; + }; + const update = () => { + player.dataset.playing = String(!video.paused && !video.ended); + command('play')?.setAttribute('aria-label', video.paused ? 'Play video' : 'Pause video'); + command('mute')?.setAttribute('aria-label', video.muted ? 'Unmute video' : 'Mute video'); + command('mute')?.setAttribute('aria-pressed', String(video.muted)); + command('fullscreen')?.setAttribute('aria-label', player.matches(':fullscreen') ? 'Exit full screen' : 'Full screen'); + if (seek) { + seek.value = String(Number.isFinite(video.duration) && video.duration > 0 ? video.currentTime / video.duration * 1000 : 0); + seek.disabled = !Number.isFinite(video.duration) || video.duration <= 0; + seek.setAttribute('aria-valuetext', `${clock(video.currentTime)} of ${clock(video.duration)}`); + } + if (volume) volume.value = String(video.muted ? 0 : video.volume); + if (time) time.textContent = `${clock(video.currentTime)} / ${clock(video.duration)}`; + }; + const play = async () => { + try { await video.play(); if (status) status.hidden = true; if (source) source.hidden = true; } + catch { if (source) source.hidden = false; if (status) { status.hidden = false; status.textContent = 'Video could not start. Try again or open it on X.'; } } + update(); + }; + player.addEventListener('click', event => { + const button = event.target instanceof Element ? event.target.closest('[data-media-command]') : null; + if (!button || !event.isTrusted) return; + event.stopPropagation(); + switch (button.dataset.mediaCommand) { + case 'play': if (video.paused) void play(); else video.pause(); break; + case 'mute': video.muted = !video.muted; break; + case 'retry': video.load(); void play(); break; + case 'fullscreen': + if (player.matches(':fullscreen')) void document.exitFullscreen(); + else void player.requestFullscreen().catch(() => { if (status) { status.hidden = false; status.textContent = 'Full screen is unavailable in this browser.'; } }); + break; + } + update(); + }); + seek?.addEventListener('input', () => { if (Number.isFinite(video.duration)) video.currentTime = Number(seek.value) / 1000 * video.duration; update(); }); + volume?.addEventListener('input', () => { video.volume = Number(volume.value); video.muted = video.volume === 0; update(); }); + speed?.addEventListener('change', () => { video.playbackRate = Number(speed.value); }); + for (const name of ['play','pause','ended','timeupdate','loadedmetadata','volumechange','durationchange']) video.addEventListener(name, update); + player.addEventListener('fullscreenchange', update); + video.addEventListener('error', () => { + if (source) source.hidden = false; + if (status) { status.hidden = false; status.textContent = 'Video could not load. Retry or open it on X.'; } + const retry = command('retry'); if (retry) retry.hidden = false; + }); + const retry = command('retry'); if (retry) retry.hidden = true; + if (status) status.hidden = true; + if (source) source.hidden = true; + if (speed) speed.value = String(video.playbackRate); + video.controls = false; + player.dataset.enhanced = 'true'; + update(); + } +} +function pauseMedia(root: ParentNode): void { for (const video of root.querySelectorAll('video')) video.pause(); } diff --git a/extension/adapters/ui.ts b/extension/adapters/ui.ts new file mode 100644 index 0000000..4eff04f --- /dev/null +++ b/extension/adapters/ui.ts @@ -0,0 +1,15 @@ +import init from '../pkg/ui/limitless_ui.js'; +const api = globalThis.browser ?? chrome; +globalThis.limitlessUI = { + send: message => api.runtime.sendMessage(message), + asset_url: path => api.runtime.getURL(path), +}; +api.runtime.onMessage.addListener((message, sender) => { + if (sender.id === api.runtime.id && message?.event === 'record_changed') dispatchEvent(new Event('focus')); +}); +try { await init(); } +catch { + const main = document.createElement('main'); + main.textContent = 'LimitlessX could not start. Reopen the extension or reinstall the package.'; + document.body.append(main); +} diff --git a/extension/manifests/chromium.json b/extension/manifests/chromium.json new file mode 100644 index 0000000..b5467dd --- /dev/null +++ b/extension/manifests/chromium.json @@ -0,0 +1,67 @@ +{ + "manifest_version": 3, + "name": "LimitlessX", + "version": "0.1.0", + "description": "View public X content through an alternative source, when available.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "https://api.fxtwitter.com/*" + ], + "background": { + "service_worker": "adapters/background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_title": "LimitlessX", + "default_icon": { + "16": "brand/icon-16.png", + "32": "brand/icon-32.png", + "48": "brand/icon-48.png", + "128": "brand/icon-128.png" + } + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; base-uri 'none'; connect-src 'self' https://api.fxtwitter.com; img-src 'self' https://pbs.twimg.com; media-src https://video.twimg.com; style-src 'self'; frame-src 'none'" + }, + "icons": { + "16": "brand/icon-16.png", + "32": "brand/icon-32.png", + "48": "brand/icon-48.png", + "128": "brand/icon-128.png" + }, + "content_scripts": [ + { + "matches": [ + "https://x.com/*", + "https://www.x.com/*", + "https://twitter.com/*", + "https://www.twitter.com/*" + ], + "js": [ + "adapters/player.js", + "adapters/content.js" + ], + "css": [ + "styles/content.css" + ], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ + { + "resources": [ + "styles/app.css" + ], + "matches": [ + "https://x.com/*", + "https://www.x.com/*", + "https://twitter.com/*", + "https://www.twitter.com/*" + ] + } + ] +} diff --git a/extension/manifests/firefox.json b/extension/manifests/firefox.json new file mode 100644 index 0000000..91e59ef --- /dev/null +++ b/extension/manifests/firefox.json @@ -0,0 +1,79 @@ +{ + "manifest_version": 3, + "name": "LimitlessX", + "version": "0.1.0", + "description": "View public X content through an alternative source, when available.", + "permissions": [ + "activeTab", + "storage" + ], + "host_permissions": [ + "https://api.fxtwitter.com/*" + ], + "background": { + "scripts": [ + "adapters/background-firefox.js" + ] + }, + "action": { + "default_popup": "popup.html", + "default_title": "LimitlessX", + "default_icon": { + "16": "brand/icon-16.png", + "32": "brand/icon-32.png", + "48": "brand/icon-48.png", + "128": "brand/icon-128.png" + } + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; base-uri 'none'; connect-src 'self' https://api.fxtwitter.com; img-src 'self' https://pbs.twimg.com; media-src https://video.twimg.com; style-src 'self'; frame-src 'none'" + }, + "browser_specific_settings": { + "gecko": { + "id": "limitlessx@onurbyte.dev", + "strict_min_version": "140.0", + "data_collection_permissions": { + "required": [ + "none" + ] + } + } + }, + "icons": { + "16": "brand/icon-16.png", + "32": "brand/icon-32.png", + "48": "brand/icon-48.png", + "128": "brand/icon-128.png" + }, + "content_scripts": [ + { + "matches": [ + "https://x.com/*", + "https://www.x.com/*", + "https://twitter.com/*", + "https://www.twitter.com/*" + ], + "js": [ + "adapters/player.js", + "adapters/content.js" + ], + "css": [ + "styles/content.css" + ], + "run_at": "document_idle" + } + ], + "web_accessible_resources": [ + { + "resources": [ + "styles/app.css" + ], + "matches": [ + "https://x.com/*", + "https://www.x.com/*", + "https://twitter.com/*", + "https://www.twitter.com/*" + ] + } + ] +} diff --git a/extension/static/brand/icon-128.png b/extension/static/brand/icon-128.png new file mode 100644 index 0000000..f5b4594 Binary files /dev/null and b/extension/static/brand/icon-128.png differ diff --git a/extension/static/brand/icon-16.png b/extension/static/brand/icon-16.png new file mode 100644 index 0000000..2703333 Binary files /dev/null and b/extension/static/brand/icon-16.png differ diff --git a/extension/static/brand/icon-32.png b/extension/static/brand/icon-32.png new file mode 100644 index 0000000..ea2176c Binary files /dev/null and b/extension/static/brand/icon-32.png differ diff --git a/extension/static/brand/icon-48.png b/extension/static/brand/icon-48.png new file mode 100644 index 0000000..5f77142 Binary files /dev/null and b/extension/static/brand/icon-48.png differ diff --git a/extension/static/brand/mark.svg b/extension/static/brand/mark.svg new file mode 100644 index 0000000..353acde --- /dev/null +++ b/extension/static/brand/mark.svg @@ -0,0 +1 @@ +LimitlessX diff --git a/extension/static/popup.html b/extension/static/popup.html new file mode 100644 index 0000000..2c171d1 --- /dev/null +++ b/extension/static/popup.html @@ -0,0 +1,2 @@ + +LimitlessX diff --git a/extension/static/styles/app.css b/extension/static/styles/app.css new file mode 100644 index 0000000..152904c --- /dev/null +++ b/extension/static/styles/app.css @@ -0,0 +1,19 @@ +:root,:host{color-scheme:light dark;--bg:#fff;--fg:#111;--muted:#656565;--line:#e4e4e4;--panel:#f7f7f7;--inverse:#fff;--focus:#555;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg)}a{color:inherit;text-underline-offset:3px}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.55}button,input{border-radius:8px}button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--focus);outline-offset:4px}main{padding:24px}.popup-page{width:384px;min-width:320px}.masthead{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:20px;border-bottom:1px solid var(--line)}.wordmark{display:flex;align-items:center;gap:8px;text-decoration:none}.wordmark img{width:28px;height:28px}.wordmark h1{font-size:16px;line-height:1;letter-spacing:-.6px;text-transform:uppercase;margin:0;font-weight:800}.edition{font-size:9px;letter-spacing:1px;color:var(--muted)}h2,p{margin:0}.intro{padding:24px 0 20px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:1.4px;color:var(--muted);margin-bottom:12px}.tagline{font-size:32px;line-height:1.1;font-weight:600;letter-spacing:-1.2px;margin-bottom:16px}.muted{color:var(--muted)}.intro .muted{max-width:250px;font-size:13px}.panel{border:1px solid var(--line);border-radius:12px;padding:20px}.current{margin-bottom:20px;background:var(--panel)}.current h2,.record h2{font-size:10px;letter-spacing:1.4px;text-transform:uppercase;font-weight:600}.target{font-size:20px;font-weight:600;margin-top:12px;overflow-wrap:anywhere}.context-status{font-size:12px;color:var(--muted);margin:4px 0 16px}.primary,.secondary{display:inline-flex;align-items:center;justify-content:space-between;gap:16px;border:1px solid var(--fg);padding:12px 16px;font-weight:600;text-decoration:none}.primary{background:var(--fg);color:var(--inverse);width:100%}.secondary{background:var(--bg);color:var(--fg);border-color:var(--line);justify-content:center}.lookup label{display:block;font-size:12px;font-weight:600;margin-bottom:8px}.lookup input{display:block;width:100%;padding:12px;margin-bottom:8px;background:var(--bg);color:var(--fg);border:1px solid var(--line)}.fine{font-size:11px;color:var(--muted)}.disclosure{margin:12px 0 24px}.section-heading{display:flex;justify-content:space-between;align-items:center}.section-heading>span{font-size:24px;line-height:1}.metrics{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin:16px 0}.metrics strong{display:block;font-size:36px;font-weight:500;line-height:1.2;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metrics span{display:block;color:var(--muted);font-size:12px;line-height:1.4;margin-top:4px}.attribution{color:var(--muted);font-size:11px;margin-top:20px}footer{display:flex;justify-content:space-between;border-top:1px solid var(--line);margin-top:20px;padding-top:16px;font-size:12px}footer a{text-decoration:none}.text-button{background:none;color:inherit;border:0;padding:0;font-weight:inherit}.error{margin:16px 0;font-size:13px;overflow-wrap:anywhere}.error::before{content:"!";display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;border:1px solid currentColor;border-radius:50%;margin-right:8px}.error.panel{background:var(--panel)}.error p{display:inline}.error button{display:block;margin-top:16px}.document,.reader{max-width:792px;margin:0 auto;padding:32px 24px 24px}.page-title{padding:48px 0 32px}.page-title h2{font-size:36px;line-height:1.1;letter-spacing:-1px;margin-bottom:12px}.document .record{max-width:480px}.record-controls{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;border-top:1px solid var(--line);padding-top:20px;margin-top:20px}.switch{display:flex;align-items:center;gap:12px;font-size:13px}.switch input{width:16px;height:16px;accent-color:var(--fg)}.prose{max-width:620px;margin:40px 0}.prose h2{font-size:18px;margin:32px 0 12px;font-weight:600;letter-spacing:-.3px}.prose p{margin:12px 0;color:var(--muted);line-height:1.75}.reader-title{display:flex;justify-content:space-between;gap:24px;align-items:center;padding-top:32px}.reader-title h2{font-size:28px;letter-spacing:-.8px;overflow-wrap:anywhere}.reader-title .eyebrow{margin-bottom:4px}.source-pill{white-space:nowrap;font-size:11px;border:1px solid var(--line);padding:4px 8px;border-radius:8px}.reader-note{color:var(--muted);font-size:12px;margin:12px 0 32px}.profile-card{padding:24px;border:1px solid var(--line);border-radius:12px;margin:24px 0}.profile-heading{display:flex;align-items:center;gap:16px}.profile-heading h2{font-size:24px;letter-spacing:-.6px}.avatar{width:64px;height:64px;object-fit:cover;border-radius:50%}.biography{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:20px}.feed-title{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:32px 0 16px}.feed-title h2{font-size:16px}.post-card{border:1px solid var(--line);border-radius:12px;padding:24px;margin-bottom:16px}.post-heading{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap}.post-heading time{color:var(--muted);font-size:11px}.post-text{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.65;font-size:16px;margin:16px 0}.post-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:20px}.source-link{font-size:12px;color:var(--muted)}.compact{padding:8px 12px;font-size:12px}.media-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px}.post-media{display:block;width:100%;max-height:560px;object-fit:contain;border-radius:8px;background:var(--panel)}.load-more{width:100%;margin:16px 0}.loading,.empty-state{padding:32px 0;color:var(--muted);text-align:center}.notice{background:var(--panel);border:1px solid var(--line);padding:16px;border-radius:8px;font-size:13px;margin:16px 0}@media(prefers-color-scheme:dark){:root,:host{--bg:#0c0c0c;--fg:#f5f5f5;--muted:#a0a0a0;--line:#2a2a2a;--panel:#161616;--inverse:#111;--focus:#bbb}.wordmark img{filter:invert(1)}}@media(max-width:480px){.reader-title{align-items:flex-start}.feed-title{display:block}.feed-title .fine{display:block;margin-top:4px}.post-card{padding:20px}.document,.reader{padding:24px 16px}.reader-title h2{font-size:22px}} + +:host{display:block}.inline-root{all:initial;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:var(--fg);background:var(--bg)}.inline-root .reader{max-width:none;margin:0;padding:20px;background:var(--bg);border:1px solid var(--line);border-radius:12px}.inline-root .masthead{padding-bottom:16px}.inline-root .reader-title{padding-top:20px}.inline-root .reader-note{margin-bottom:20px}.inline-root .wordmark img{height:24px;width:30px;object-fit:contain}.popup-settings{border-top:1px solid var(--line);margin-top:20px;padding-top:16px}.popup-settings summary{cursor:pointer;font-weight:600;font-size:12px}.popup-settings .prose{margin:16px 0}.popup-settings .prose p{font-size:12px}.popup-settings .prose h2{font-size:14px;margin:20px 0 8px} + +.brand-mark svg{display:block;width:30px;height:24px}.settings-toggle{margin-top:16px} + +.brand-svg{width:30px;height:24px;display:block}.inline-root .wordmark h2{font-size:16px;letter-spacing:-.6px;text-transform:uppercase;font-weight:800} +/* Inline content follows X's native timeline typography, spacing and surfaces. */ +.inline-root{font-family:TwitterChirp,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;background:var(--bg);color:var(--fg)}.inline-root .reader.x-reader{padding:0;margin:0;border:0;border-radius:0;background:var(--bg)}.inline-root .profile-card{padding:16px;border:0;border-radius:0;margin:0}.inline-root .profile-heading{display:block}.inline-root .profile-heading .avatar{width:134px;height:134px;border-radius:50%;margin-bottom:12px;object-fit:cover}.inline-root .profile-heading h2{font-size:20px;line-height:24px;letter-spacing:0;font-weight:800;margin:0}.inline-root .profile-heading .muted{font-size:15px;line-height:20px;margin:4px 0 0}.inline-root .biography{font-size:15px;line-height:20px;margin:16px 0 0}.inline-root .feed-title{height:53px;display:flex;justify-content:center;align-items:center;margin:0;border-bottom:1px solid var(--line)}.inline-root .feed-title h2{font-size:15px;font-weight:700;line-height:49px;border-bottom:4px solid #1d9bf0;letter-spacing:0;padding:0 8px}.inline-root .feed-title .fine{display:none}.inline-root .post-card{padding:12px 16px;margin:0;border:0;border-bottom:1px solid var(--line);border-radius:0}.inline-root .post-heading{font-size:15px;line-height:20px;display:flex;gap:4px;justify-content:flex-start;align-items:baseline;flex-wrap:wrap}.inline-root .post-heading strong{font-size:15px;font-weight:700}.inline-root .post-heading time{font-size:15px;color:var(--muted)}.inline-root .post-text{font-size:15px;line-height:20px;margin:4px 0 12px;letter-spacing:0}.inline-root .post-actions{margin-top:12px;font-size:13px;line-height:16px}.inline-root .post-actions .secondary{padding:4px 0;border:0;background:none;border-radius:0;color:#1d9bf0;font-size:13px;font-weight:400}.inline-root .post-actions .source-link{font-size:13px;text-decoration:none;color:var(--muted)}.inline-root .post-media{border:1px solid var(--line);border-radius:16px;max-height:600px}.inline-root .attribution{font-size:12px;line-height:16px;margin:0;padding:12px 16px;color:var(--muted)}.inline-root .attribution a{font-size:12px}.inline-root .x-loading{padding:32px 16px;margin:0;text-align:center;color:var(--muted);font-size:15px}.inline-root .x-error{padding:32px;max-width:400px;margin:0 auto}.inline-root .x-error p{color:var(--muted);font-size:15px;line-height:20px;margin:0 0 24px}.inline-root .x-error button,.inline-root .load-more{background:var(--fg);color:var(--bg);border:0;border-radius:9999px;font-size:15px;line-height:20px;font-weight:700;padding:10px 20px}.inline-root .notice{background:none;border:0;border-radius:0;color:var(--muted);font-size:13px;padding:8px 16px;margin:0}.inline-root .error::before{display:none} +.inline-root .post-card{display:flex;align-items:flex-start;gap:12px}.inline-root .post-avatar{display:block;flex:0 0 40px;width:40px;height:40px;border-radius:50%;object-fit:cover;background:var(--panel)}.inline-root .post-content{flex:1;min-width:0}.inline-root .detailed-post .post-text{font-size:20px;line-height:24px;margin:12px 0}.inline-root .detailed-post .post-heading{min-height:40px;align-items:center}.inline-root .avatar-placeholder{border:1px solid var(--line)} +/* Measured on the user's Helium X session: docs/reference/helium-x-style.json. */ +.inline-root .x-reader{container-type:inline-size}.inline-root .post-card{gap:8px;padding:12px 16px}.inline-root .post-heading{display:flex;align-items:center;gap:4px;min-height:20px;flex-wrap:nowrap}.inline-root .x-author{display:inline-flex;align-items:center;min-width:0;gap:4px;white-space:nowrap;overflow:hidden}.inline-root .x-name-line{display:inline-flex;align-items:center;gap:2px;min-width:0}.inline-root .x-author-name{font-size:15px;line-height:20px;font-weight:700;color:var(--fg);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.inline-root .x-author-name:hover{text-decoration:underline}.inline-root .x-handle,.inline-root .x-timeline-time{font-size:15px;line-height:20px;color:var(--muted);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.inline-root .x-post-more{font-size:20px;line-height:20px;color:var(--muted);text-decoration:none;margin-left:auto;flex:0 0 20px;text-align:center}.inline-root .post-text{margin:0;font-size:15px;line-height:20px;white-space:pre-wrap;overflow-wrap:anywhere;letter-spacing:0}.inline-root .post-text[role=link]{cursor:pointer}.inline-root .x-entity{font:inherit;line-height:inherit;color:#1d9bf0;text-decoration:none}.inline-root .x-entity:hover{text-decoration:underline}.inline-root .post-media{margin-top:12px;border-radius:16px}.inline-root .media-grid:empty{display:none}.inline-root .detailed-post{display:block;padding:12px 16px 0}.inline-root .x-detail-author{display:flex;gap:8px;align-items:center;min-height:40px}.inline-root .x-detail-author .post-heading{flex:1;min-width:0;height:40px}.inline-root .x-detail-author .x-author{display:flex;flex-direction:column;align-items:flex-start;gap:0;height:40px}.inline-root .detailed-post .post-text{font-size:17px;line-height:24px;margin:12px 0 0;width:100%}.inline-root .x-post-meta{font-size:15px;line-height:20px;color:var(--muted);margin:16px 0}.inline-root .x-post-meta strong{font-weight:700;color:var(--fg)}.inline-root .x-metrics{display:flex;align-items:center;justify-content:space-between;gap:4px;height:20px;margin-top:12px;color:var(--muted)}.inline-root .x-metric{display:inline-flex;align-items:center;gap:4px;min-width:0;color:inherit;text-decoration:none;font-size:13px;line-height:16px}.inline-root .x-metric-icon{display:block;flex-shrink:0;width:18.75px;height:18.75px;fill:currentColor}.inline-root .x-metric:hover{color:#1d9bf0}.inline-root .metric-like:hover{color:#f91880}.inline-root .metric-repost:hover{color:#00ba7c}.inline-root .detailed-metrics{height:48px;padding:0 4px;margin:0;border-top:1px solid var(--line)}.inline-root .detailed-metrics .x-metric-icon{width:22.5px;height:22.5px}.inline-root .x-verified{width:18px;height:18px;fill:#1d9bf0;flex-shrink:0}.inline-root .x-verified.organization{fill:#e2b719}.inline-root .x-profile{margin:0;padding:0}.inline-root .x-profile-hero{position:relative;width:100%;aspect-ratio:3;background:var(--line)}.inline-root .x-banner{width:100%;height:100%;object-fit:cover;display:block}.inline-root .x-profile-avatar{position:absolute;left:14px;bottom:2px;transform:translateY(50%);width:calc(25% - 4px);height:auto;aspect-ratio:1;border:4px solid var(--bg);border-radius:50%;object-fit:cover;background:var(--panel)}.inline-root .x-profile-info{padding:0 16px 16px}.inline-root .x-profile-actions{height:calc((100cqw - 16px)/8 + 8px);min-height:68px;display:flex;justify-content:flex-end;align-items:flex-start;padding-top:12px}.inline-root .x-follow{display:flex;align-items:center;justify-content:center;height:36px;min-width:78px;padding:0 16px;border-radius:9999px;background:var(--fg);color:var(--bg);font-size:14px;line-height:16px;font-weight:700;text-decoration:none}.inline-root .x-profile-names{margin-top:4px;margin-bottom:12px}.inline-root .x-profile-names h2{display:flex;align-items:center;gap:4px;font-size:20px;line-height:24px;font-weight:800;letter-spacing:0}.inline-root .x-profile-names .x-handle{display:block;line-height:20px;margin-top:0}.inline-root .x-profile-info .biography{font-size:15px;line-height:20px;margin:0 0 12px}.inline-root .x-profile-details{display:flex;flex-wrap:wrap;gap:4px 12px;color:var(--muted);font-size:15px;line-height:20px;margin-bottom:12px}.inline-root .x-profile-website{color:#1d9bf0;font-size:15px;text-decoration:none}.inline-root .x-profile-counts{display:flex;gap:20px;line-height:20px;font-size:15px}.inline-root .x-profile-count{font-size:15px;color:var(--muted);text-decoration:none}.inline-root .x-profile-count strong{color:var(--fg);font-weight:700}.inline-root .x-profile-count:hover{text-decoration:underline}.inline-root .x-profile-tabs{display:flex;justify-content:space-around;height:53px;border-bottom:1px solid var(--line);margin:16px 0 0}.inline-root .x-tab{position:relative;display:flex;align-items:center;justify-content:center;flex:1;color:var(--muted);font-size:15px;line-height:20px;font-weight:500;text-decoration:none}.inline-root .x-tab.active{font-weight:700;color:var(--fg)}.inline-root .x-tab.active:after{position:absolute;content:"";height:4px;min-width:56px;border-radius:9999px;background:#1d9bf0;bottom:0}.inline-root .x-quote{border:1px solid var(--line);border-radius:16px;margin-top:12px;overflow:hidden;padding:12px}.inline-root .x-quote-author{display:flex;align-items:center;gap:4px;margin-bottom:4px}.inline-root .x-quote-avatar{width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0}.inline-root .x-quote-text{font-size:15px;line-height:20px;white-space:pre-wrap;overflow-wrap:anywhere}.inline-root .x-quote .post-media{border-radius:0;border:0}.inline-root .x-replies-heading{display:flex;align-items:center;height:48px;padding:0 16px;font-size:15px;line-height:20px;font-weight:700;border-bottom:1px solid var(--line)}.inline-root .x-more-replies{display:block;width:100%;height:52px;padding:16px;border:0;background:none;color:#1d9bf0;font-size:15px;line-height:20px;text-align:center;cursor:pointer}.inline-root .x-more-replies:hover{background:var(--panel)} +/* Native profile details and incremental feed. */ +.inline-root .x-profile-location,.inline-root .x-profile-joined,.inline-root .x-profile-website{display:inline-flex;align-items:center;gap:4px}.inline-root .x-profile-icon{width:18.75px;height:18.75px;fill:var(--muted);flex-shrink:0}.inline-root .x-profile-tabs{margin-top:0}.inline-root .x-tab:hover{background:color-mix(in srgb,var(--fg) 10%,transparent)}.inline-root .x-scroll-sentinel{display:block;width:100%;min-height:56px;border:0;border-radius:0;background:none;color:#1d9bf0;font-size:15px;line-height:20px;cursor:pointer}.inline-root .x-scroll-sentinel:hover{background:var(--panel)}.inline-root .x-scroll-sentinel[aria-busy=true]{color:var(--muted)} +.inline-root .media-grid{display:grid;grid-template-columns:minmax(0,1fr);min-width:0;gap:2px;margin-top:12px;border:1px solid var(--line);border-radius:16px;overflow:hidden}.inline-root .media-grid:empty{display:none}.inline-root .media-grid .post-media{margin:0;border:0;border-radius:0;min-width:0}.inline-root .media-count-2,.inline-root .media-count-3,.inline-root .media-count-4{grid-template-columns:repeat(2,minmax(0,1fr));aspect-ratio:16/9}.inline-root .media-count-3,.inline-root .media-count-4{grid-template-rows:repeat(2,minmax(0,1fr))}.inline-root .media-count-3>:first-child{grid-row:span 2}.inline-root .media-count-2 .post-media,.inline-root .media-count-3 .post-media,.inline-root .media-count-4 .post-media{width:100%;height:100%;object-fit:cover;max-height:none} +/* Popup follows Onur's brand.md. X-injected views keep the native X theme. */ +.popup-page{--bg:#fff;--fg:#0d0d0d;--muted:#333;--line:#b3b3b3;--panel:#f2f2f2;--focus:#0d0d0d;width:384px;font-size:16px;line-height:1.7;background:var(--bg);color:var(--fg)}.popup-page main{padding:24px}.popup-page .masthead{padding:0 0 24px;border:0}.popup-page .wordmark{gap:12px}.popup-page .brand-mark svg{width:36px;height:32px}.popup-page .wordmark h1{font-size:20px;line-height:28px;letter-spacing:0;text-transform:none;font-weight:500}.popup-page .intro{padding:0 0 24px}.popup-page .intro p{font-size:16px}.popup-page .panel{border:0;border-radius:0;padding:0;background:none}.popup-page .current{padding-bottom:24px;margin-bottom:24px;border-bottom:1px solid var(--line)}.popup-page .current h2,.popup-page .record h2{font-size:16px;font-weight:500;text-transform:none;letter-spacing:0}.popup-page .target{font-size:20px;font-weight:400;margin-top:8px}.popup-page .context-status{font-size:14px;margin:4px 0 16px}.popup-page .primary,.popup-page .secondary{min-height:44px;font-size:16px;font-weight:500;border-radius:8px}.popup-page input[type=text]{min-height:44px;padding:10px 12px;background:var(--panel);border:1px solid var(--line);font-size:16px}.popup-page .lookup label{font-size:14px;font-weight:400}.popup-page .fine,.popup-page .disclosure,.popup-page .attribution{font-size:13px;line-height:20px;color:var(--muted)}.popup-page .disclosure{margin:16px 0 24px}.popup-page .record{border-top:1px solid var(--line);padding-top:24px;margin-top:0}.popup-page .metrics{margin-top:16px;gap:24px}.popup-page .metrics strong{font-size:28px;font-weight:400;line-height:36px;letter-spacing:0}.popup-page .metrics span{font-size:14px;line-height:20px}.popup-page .record .fine{margin-top:16px}.popup-page .settings-toggle{font-size:14px;min-height:44px;margin-top:12px}.popup-page .popup-settings{margin-top:8px}.popup-page .popup-settings .prose p{font-size:14px;line-height:24px}.popup-page footer{margin-top:24px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}.popup-page button:focus-visible,.popup-page a:focus-visible,.popup-page input:focus-visible{outline:2px solid var(--focus);outline-offset:3px}@media(prefers-color-scheme:dark){.popup-page{--bg:#000;--fg:#fff;--muted:#fff;--line:#404040;--panel:#222;--focus:#fff}} +.inline-root .lx-player{position:relative;min-width:0;background:#000;color:#fff;overflow:hidden}.inline-root .lx-player video{width:100%;display:block}.inline-root .lx-media-controls{display:none;align-items:center;gap:8px;padding:8px;background:#000;color:#fff;min-height:44px}.inline-root .lx-player[data-enhanced=true] .lx-media-controls{display:flex}.inline-root .lx-media-button{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:32px;height:32px;border:0;background:none;color:#fff;border-radius:50%;padding:5px}.inline-root .lx-media-button:hover{background:#333}.inline-root .lx-media-icon{width:22px;height:22px;fill:currentColor}.inline-root .lx-player[data-playing=true] [data-media-command=play] path{d:path("M6 5h4v14H6zm8 0h4v14h-4z")}.inline-root .lx-seek{flex:1;min-width:24px;width:60px;accent-color:#fff}.inline-root .lx-volume{width:48px;min-width:32px;accent-color:#fff}.inline-root .lx-media-time{font-size:11px;line-height:16px;white-space:nowrap}.inline-root .lx-speed{font-size:12px;border:0;background:#000;color:#fff;border-radius:4px;padding:4px}.inline-root .lx-media-source{display:block;color:#fff;font-size:12px;padding:4px 12px;text-decoration:none}.inline-root .lx-media-source:hover{text-decoration:underline}.inline-root .lx-media-status{padding:8px 12px;font-size:13px;line-height:20px}.inline-root .lx-media-retry{margin:4px 12px 8px;padding:6px 12px;background:#fff;color:#000;border:0;border-radius:999px;font-weight:700}.inline-root .lx-player [hidden]{display:none!important}.inline-root .lx-player button:focus-visible,.inline-root .lx-player input:focus-visible,.inline-root .lx-player select:focus-visible{outline:2px solid #fff;outline-offset:2px}@container(max-width:380px){.inline-root .lx-volume{display:none}.inline-root .lx-media-controls{gap:4px}.inline-root .lx-media-time{font-size:10px}} +.inline-root .lx-player:fullscreen{display:flex;flex-direction:column;justify-content:center;max-width:none;max-height:none}.inline-root .lx-player:fullscreen video{flex:1;min-height:0;max-height:calc(100vh - 48px);object-fit:contain}.inline-root .lx-player:fullscreen .lx-media-controls{flex-shrink:0} diff --git a/extension/static/styles/content.css b/extension/static/styles/content.css new file mode 100644 index 0000000..39df2fa --- /dev/null +++ b/extension/static/styles/content.css @@ -0,0 +1,3 @@ +#limitlessx-inline{display:block;position:relative;isolation:isolate;margin:0;width:100%;box-sizing:border-box} + +.limitlessx-notice-hidden[hidden]{display:none!important} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b74e352 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "limitlessx-browser-adapters", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "limitlessx-browser-adapters", + "version": "0.1.0", + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2da6917 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "limitlessx-browser-adapters", + "private": true, + "version": "0.1.0", + "description": "Build-only TypeScript tooling for LimitlessX browser interoperability", + "scripts": { + "typecheck": "tsc --noEmit", + "build:adapters": "tsc" + }, + "devDependencies": { "typescript": "5.9.3" } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5ee5068 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.99.1" +profile = "minimal" +targets = ["wasm32-unknown-unknown"] +components = ["rustfmt", "clippy"] diff --git a/scripts/probe-provider.py b/scripts/probe-provider.py new file mode 100644 index 0000000..5429aca --- /dev/null +++ b/scripts/probe-provider.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Explicit, credential-free live feasibility probes. Never part of application startup.""" +import argparse, datetime, json, urllib.request, urllib.error +from pathlib import Path +CASES = { + 'normal_post': '/2/status/20', + 'normal_profile': '/2/profile/github', + 'historically_withheld_profile': '/2/profile/ekrem_imamoglu', + 'historically_withheld_timeline': '/2/profile/ekrem_imamoglu/statuses', + 'protected_candidate': '/2/profile/asmallteapot', + 'suspended_candidate': '/2/profile/0', + 'missing_candidate': '/2/profile/lxnonexistent9z', + 'deleted_or_missing_post': '/2/status/1234567890123456789', + 'invalid_request': '/2/status/invalid', +} +def run(): + parser=argparse.ArgumentParser(); parser.add_argument('--output',default='docs/verification/feasibility.json');args=parser.parse_args() + report={'checked_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'provider':'FxTwitter v2','restriction_provenance':'https://www.hrw.org/news/2025/05/08/social-media-companies-should-resist-turkish-state-censorship','limitations':['Historical withholding is documented; current X DOM restriction must be separately verified.','Candidates are not assumed private, deleted or suspended unless the response explicitly identifies that state.','One successful public lookup is not a guarantee of access.'],'cases':[]} + for name,path in CASES.items(): + req=urllib.request.Request('https://api.fxtwitter.com'+path,headers={'User-Agent':'LimitlessX-feasibility/0.1','Accept':'application/json'}) + try: + try:r=urllib.request.urlopen(req,timeout=20) + except urllib.error.HTTPError as e:r=e + with r: + raw=r.read(4*1024*1024);data=json.loads(raw) + row={'case':name,'path':path,'http_status':r.status,'code':data.get('code'),'message':data.get('message'),'reason':data.get('reason'),'has_user':isinstance(data.get('user'),dict),'protected':(data.get('user') or {}).get('protected'),'has_post':(data.get('status') or {}).get('type')=='status','post_count':len(data.get('results',[])),'has_next_cursor':bool((data.get('cursor') or {}).get('bottom'))} + except Exception as e:row={'case':name,'path':path,'error':str(e)} + report['cases'].append(row);print(name,row.get('http_status',row.get('error')),flush=True) + Path(args.output).parent.mkdir(parents=True,exist_ok=True);Path(args.output).write_text(json.dumps(report,indent=2)+'\n') +if __name__=='__main__':run() diff --git a/scripts/render-icons.py b/scripts/render-icons.py new file mode 100644 index 0000000..3121ff0 --- /dev/null +++ b/scripts/render-icons.py @@ -0,0 +1,9 @@ +"""Rasterize the user-approved SVG master; requires cairosvg.""" +from pathlib import Path +import cairosvg +root=Path(__file__).resolve().parents[1] +mark=(root/'extension/static/brand/mark.svg').read_text().replace('currentColor','#111') +inner=mark[mark.index('')] +icon=f'{inner}' +for size in [16,32,48,128]: + cairosvg.svg2png(bytestring=icon.encode(),write_to=str(root/f'extension/static/brand/icon-{size}.png'),output_width=size,output_height=size) diff --git a/scripts/tools.env.example b/scripts/tools.env.example new file mode 100644 index 0000000..7a98621 --- /dev/null +++ b/scripts/tools.env.example @@ -0,0 +1,6 @@ +# wasm-bindgen CLI must match Cargo.lock. Binaryen is pinned for release builds. +# Install Rust using rustup, then: +# rustup show +# cargo install wasm-bindgen-cli --version 0.2.129 --locked +# Download Binaryen version_133 from https://github.com/WebAssembly/binaryen/releases/tag/version_133 +# export WASM_OPT=/absolute/path/binaryen-version_133/bin/wasm-opt diff --git a/tests/browser/firefox_inline.py b/tests/browser/firefox_inline.py new file mode 100644 index 0000000..0b02cc0 --- /dev/null +++ b/tests/browser/firefox_inline.py @@ -0,0 +1,43 @@ +from paths import firefox_binary,geckodriver +"""Firefox inline UI test. X DOM is controlled; status/20 uses the real public provider. +Firefox BiDi does not intercept extension event-page Fetch, so this test explicitly +reports a live-provider dependency instead of claiming a deterministic fixture. +""" +import json,pathlib,tempfile,zipfile,os +from selenium import webdriver +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.firefox.service import Service +from selenium.webdriver.support.ui import WebDriverWait +from fixtures import html +ROOT=pathlib.Path(__file__).resolve().parents[2] +opts=Options();opts.add_argument('-headless');opts.enable_bidi=True +opts.binary_location=firefox_binary() +opts.set_preference('xpinstall.signatures.required',False) +opts.set_preference('extensions.webextensions.uuids',json.dumps({'limitlessx@onurbyte.dev':'9bc48fdd-5c45-49d7-a397-b86b3402f001'})) +with tempfile.TemporaryDirectory(prefix='limitlessx-firefox-inline-') as folder: + addon=pathlib.Path(folder)/'limitlessx.xpi' + with zipfile.ZipFile(addon,'w',compression=zipfile.ZIP_DEFLATED) as z: + for path in (ROOT/'dist/firefox').rglob('*'): + if path.is_file():z.write(path,path.relative_to(ROOT/'dist/firefox')) + service=Service(geckodriver(),env={**os.environ,'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS':'1'},log_output=str(pathlib.Path(folder)/'gecko.log')) + with webdriver.Firefox(options=opts,service=service) as d: + d.install_web_extension(str(addon),permanent=False);wait=WebDriverWait(d,30) + d.network.add_request_handler(['https://x.com/**'],lambda req:req.provide_response(status=200,headers={'Content-Type':'text/html; charset=utf-8','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self'; img-src https://pbs.twimg.com; connect-src 'self'"},body=html())) + d.set_context('chrome');d.execute_script("gBrowser.selectedBrowser.loadURI(Services.io.newURI(arguments[0]),{triggeringPrincipal:Services.scriptSecurityManager.getSystemPrincipal()});",'moz-extension://9bc48fdd-5c45-49d7-a397-b86b3402f001/popup.html');d.set_context('content');wait.until(lambda d:d.find_elements('tag name','h1'));ui=d.current_window_handle + def send(m):return d.execute_async_script('const [m,done]=arguments;browser.runtime.sendMessage(m).then(done,e=>done({error:String(e)}));',m) + def post_text(d): + try:return d.find_element('id','limitlessx-inline').shadow_root.find_element('class name','post-text') + except Exception:return False + assert send({'op':'clear'})['ok'];handles=set(d.window_handles) + assert send({'op':'open_manual','input':'https://x.com/jack/status/20'})['ok'];wait.until(lambda d:len(d.window_handles)>len(handles));manual=next(h for h in d.window_handles if h not in handles);d.switch_to.window(manual) + assert wait.until(post_text).text=='just setting up my twttr';assert d.current_url.startswith('https://x.com/');assert not d.find_elements('tag name','iframe') + d.switch_to.window(ui);assert send({'op':'record'})['data']['posts']==0 + d.switch_to.new_window('tab');d.get('https://x.com/jack/status/20');handles=set(d.window_handles);assert wait.until(post_text).text=='just setting up my twttr';assert set(d.window_handles)==handles + xhandle=d.current_window_handle;d.switch_to.window(ui);assert send({'op':'record'})['data']['posts']==1 + # An explicit popup action is deliberate, while the automatic display stays uncounted. + d.switch_to.window(xhandle);d.set_context('chrome');d.execute_script('window.focus();');d.set_context('content') + # sendMessage runs from the popup context, with the X tab selected in the browser. + d.switch_to.window(ui);assert send({'op':'record'})['data']['posts']==1 + d.find_element('class name','settings-toggle').click();checkbox=wait.until(lambda d:d.find_elements('css selector','input[type=checkbox]'))[0];checkbox.click();wait.until(lambda d:send({'op':'record'})['data']['enabled'] is False) + d.find_element('class name','record-controls').find_element('tag name','button').click();wait.until(lambda d:send({'op':'record'})['data']['posts']==0) + print(json.dumps({'browser':'firefox','automatic_injection':'passed','strict_page_csp':'passed','no_extension_reader_page':'passed','manual_count':0,'automatic_count':1,'popup_settings':'passed','provider':'live FxTwitter status/20'})) diff --git a/tests/browser/firefox_smoke.py b/tests/browser/firefox_smoke.py new file mode 100644 index 0000000..9044d8a --- /dev/null +++ b/tests/browser/firefox_smoke.py @@ -0,0 +1,26 @@ +from paths import firefox_binary,geckodriver +"""Install the actual Firefox MV3 package through WebDriver and exercise WASM.""" +import json,pathlib,tempfile,zipfile,os +from selenium import webdriver +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.firefox.service import Service +from selenium.webdriver.support.ui import WebDriverWait +ROOT=pathlib.Path(__file__).resolve().parents[2] +opts=Options();opts.add_argument('-headless') +opts.binary_location=firefox_binary() +opts.set_preference('xpinstall.signatures.required',False) +opts.set_preference('extensions.webextensions.uuids',json.dumps({'limitlessx@onurbyte.dev':'9bc48fdd-5c45-49d7-a397-b86b3402f001'})) +with tempfile.TemporaryDirectory(prefix='limitlessx-firefox-') as folder: + addon=pathlib.Path(folder)/'limitlessx.xpi' + with zipfile.ZipFile(addon,'w',compression=zipfile.ZIP_DEFLATED) as z: + for p in (ROOT/'dist/firefox').rglob('*'): + if p.is_file():z.write(p,p.relative_to(ROOT/'dist/firefox')) + with webdriver.Firefox(options=opts,service=Service(geckodriver(),env={**os.environ,'MOZ_REMOTE_ALLOW_SYSTEM_ACCESS':'1'},log_output=str(pathlib.Path(folder)/'gecko.log'))) as driver: + driver.install_web_extension(path=str(addon),permanent=False) + driver.set_context('chrome') + driver.execute_script("gBrowser.selectedBrowser.loadURI(Services.io.newURI('moz-extension://9bc48fdd-5c45-49d7-a397-b86b3402f001/popup.html'),{triggeringPrincipal:Services.scriptSecurityManager.getSystemPrincipal()});") + driver.set_context('content') + WebDriverWait(driver,30).until(lambda d:d.find_elements('tag name','h1')) + result=driver.execute_async_script('const done=arguments[0];browser.runtime.sendMessage({op:"ping"}).then(done,e=>done({error:String(e)}));') + assert result=={'ok':True,'data':{'runtime':'rust-wasm'}},result + print(json.dumps({'browser':'firefox','runtime':result,'popup':driver.title})) diff --git a/tests/browser/fixtures.py b/tests/browser/fixtures.py new file mode 100644 index 0000000..b1ca537 --- /dev/null +++ b/tests/browser/fixtures.py @@ -0,0 +1,5 @@ +USER={'type':'profile','id':'12','screen_name':'jack','name':'jack','description':'','protected':False,'avatar_url':None} +POST={'type':'status','provider':'twitter','id':'20','text':'just setting up my twttr','author':USER,'created_at':None,'media':{}} +NOTICE="This post has been withheld in Türkiye in response to a legal demand." +def html(text=NOTICE): + return f'
' diff --git a/tests/browser/identity_recovery.py b/tests/browser/identity_recovery.py new file mode 100644 index 0000000..63fcb3d --- /dev/null +++ b/tests/browser/identity_recovery.py @@ -0,0 +1,32 @@ +"""Real observed failure: username endpoints 404, exact public UID endpoints succeed.""" +import copy,json,pathlib,tempfile +from playwright.sync_api import sync_playwright +from fixtures import USER,POST,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-identity-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + calls=[];user={**USER,'name':'Public identity','description':'Verified biography','followers':1234,'following':25,'statuses':321};post={**copy.deepcopy(POST),'author':user} + def provider(route): + url=route.request.url;calls.append(url) + if '/typeahead' in url:route.fulfill(json={'code':200,'users':[{'id':'99','screen_name':'jack_fan','protected':False},{**user,'followers':0,'following':0}]}) + elif '/id:12/statuses' in url:route.fulfill(json={'code':200,'results':[post],'cursor':{'bottom':None}}) + elif '/id:12' in url:route.fulfill(json={'code':200,'user':user}) + else:route.fulfill(status=404,json={'code':404,'message':'User not found'}) + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html('Account Withheld@jack has been withheld in TR in response to a legal demand.').replace('','Account Withheld / X'),content_type='text/html')) + worker=ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker');origin='/'.join(worker.url.split('/')[:3]);ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + x=ctx.new_page();x.goto('https://x.com/jack');x.locator('#limitlessx-inline .post-card').wait_for() + assert sum('/typeahead'in u for u in calls)==1,'parallel recovery did not share identity resolution' + assert x.locator('[data-testid="emptyState"]').is_hidden();assert x.get_by_role('alert').count()==0 + assert x.locator('#limitlessx-inline .x-profile-count').all_text_contents()==['25 Following','1.2K Followers'],'placeholder suggestion counters leaked into metadata' + assert x.title()=='Public identity (@jack) / X' + assert x.locator('meta[property="og:title"]').get_attribute('content')==x.title() + assert x.locator('meta[name="description"]').get_attribute('content')=='Verified biography' + ui.wait_for_function('document.querySelector(".metrics strong")?.textContent==="1"') + assert ui.evaluate('chrome.runtime.sendMessage({op:"record"})')['data']['profiles']==1 + x.reload();x.locator('#limitlessx-inline .post-card').wait_for();assert ui.evaluate('chrome.runtime.sendMessage({op:"record"})')['data']['profiles']==1 + x.evaluate("history.pushState({},'', '/other');document.body.append(document.createElement('div'))");x.locator('#limitlessx-inline').wait_for(state='detached');assert x.title()=='Account Withheld / X' + assert x.locator('meta[name="description"]').get_attribute('content')=='Original' + ui.set_viewport_size({'width':384,'height':800});ui.screenshot(path=str(ROOT/'tests/browser/artifacts/brand-popup.png'),full_page=True) + print(json.dumps({'username_404_recovery':'passed','exact_uid_only':'passed','shared_resolution':'passed','automatic_unique_counter':'passed','live_popup_counter':'passed','metadata_restore':'passed','suggestion_zero_counts_ignored':'passed'})) diff --git a/tests/browser/infinite_scroll.py b/tests/browser/infinite_scroll.py new file mode 100644 index 0000000..ec0a5c9 --- /dev/null +++ b/tests/browser/infinite_scroll.py @@ -0,0 +1,51 @@ +"""Five visible posts, parallel initial fetch, buffered scrolling and stable DOM.""" +import copy,json,pathlib,tempfile +from playwright.sync_api import sync_playwright +from fixtures import POST,USER,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-scroll-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + calls=[];pending=[];failed=[];short=[False] + def provider(route): + url=route.request.url;calls.append(url) + if '/statuses' not in url: + pending.append(route);return + if 'cursor=' in url and not failed: + failed.append(True);route.fulfill(status=503,json={'code':503});return + start,end=(21,26) if 'cursor=' in url else (1,21) + posts=[{**copy.deepcopy(POST),'id':str(i),'text':f'Post {i}\n'+('Public fixture text\n'*(0 if short[0] else 14)),'replying_to':{'screen_name':'someone'} if i%2==0 else None,'author':{**USER,'screen_name':'other' if i%3==0 else 'jack'},'media':{'photos':[{'type':'photo','url':'https://pbs.twimg.com/media/fixture.jpg'}]} if i%4==0 else {}}for i in range(start,end)] + route.fulfill(json={'code':200,'results':posts,'cursor':{'bottom':None if 'cursor=' in url else 'next'}}) + for r in pending:r.fulfill(json={'code':200,'user':USER}) + pending.clear() + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://pbs.twimg.com/**',lambda r:r.abort()) + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html('Account Withheld@jack has been withheld in TR in response to a legal demand.'),content_type='text/html')) + x=ctx.new_page();x.set_viewport_size({'width':1100,'height':600});x.goto('https://x.com/jack') + x.locator('#limitlessx-inline .x-profile').wait_for();cards=x.locator('#limitlessx-inline .post-card');assert cards.count()==5 + assert len(calls)==2,'initial profile and timeline were not fetched together' + x.wait_for_timeout(700);assert cards.count()==5,'initial screen eagerly loaded more than five' + x.evaluate('globalThis.firstPost=document.querySelector("#limitlessx-inline").shadowRoot.querySelector(".post-card")') + for count in [10,15,20]: + x.mouse.wheel(0,100000) + x.wait_for_function('(count)=>document.querySelector("#limitlessx-inline").shadowRoot.querySelectorAll(".post-card").length===count',arg=count) + assert x.evaluate('globalThis.firstPost===document.querySelector("#limitlessx-inline").shadowRoot.querySelector(".post-card")'),'existing post DOM was rebuilt' + if count<=20:assert sum('/statuses'in u for u in calls)==1,'buffered posts caused a provider request' + x.mouse.wheel(0,100000);x.get_by_role('button',name='Try again').wait_for();assert cards.count()==20,'pagination failure destroyed existing posts' + x.get_by_role('button',name='Try again').click();x.wait_for_function('document.querySelector("#limitlessx-inline").shadowRoot.querySelectorAll(".post-card").length===25') + assert sum('/statuses'in u for u in calls)==3 + assert x.locator('[data-autoload]').count()==0 + assert len(set(cards.evaluate_all('(nodes)=>nodes.map(n=>n.dataset.postId)')))==25 + before_tabs=len(calls) + for label,path,ids in [('Replies','with_replies',['2','4','6','8','10']),('Media','media',['4','8','12','16','20']),('Reposts','reposts',['3','6','9','12','15'])]: + x.get_by_role('link',name=label,exact=True).click();x.wait_for_url('**/'+path) + x.wait_for_function('(label)=>document.querySelector("#limitlessx-inline")?.shadowRoot?.querySelector(".x-tab.active")?.textContent===label',arg=label) + assert cards.evaluate_all('(nodes)=>nodes.map(n=>n.dataset.postId)')==ids + assert len(calls)==before_tabs,'profile tab switch refetched an existing buffer' + short[0]=True + z=ctx.new_page();z.set_viewport_size({'width':1100,'height':2500});z.goto('https://x.com/jack');z.locator('#limitlessx-inline .x-profile').wait_for() + assert z.locator('#limitlessx-inline .post-card').count()==5 + assert z.evaluate('document.documentElement.scrollHeight<=innerHeight'),'short-feed fixture unexpectedly scrolls' + z.mouse.wheel(0,100) + z.wait_for_function('document.querySelector("#limitlessx-inline").shadowRoot.querySelectorAll(".post-card").length===10') + print(json.dumps({'initial_five':'passed','parallel_initial_requests':'passed','scroll_batches':'passed','buffer_without_refetch':'passed','stable_existing_posts':'passed','cursor_end':'passed','pagination_error_preserves_feed':'passed','short_feed_wheel':'passed','same_tab_profile_filters':'passed'})) diff --git a/tests/browser/inline.py b/tests/browser/inline.py new file mode 100644 index 0000000..a0fa20f --- /dev/null +++ b/tests/browser/inline.py @@ -0,0 +1,35 @@ +"""Automatic native-looking X DOM rendering, with only an extension popup.""" +import json,pathlib,tempfile +from playwright.sync_api import sync_playwright +from fixtures import POST,NOTICE,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-auto-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + calls=[] + def provider(route):calls.append(route.request.url);route.fulfill(json={'code':200,'status':POST,'thread':None,'replies':[],'cursor':{'bottom':None}}) + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://x.com/**',lambda route:route.fulfill(body=html('Account suspended'),content_type='text/html',headers={'Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self'; img-src https://pbs.twimg.com; connect-src 'self'"})) + worker=ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker');origin='/'.join(worker.url.split('/')[:3]);ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for();send=lambda m:ui.evaluate('(m)=>chrome.runtime.sendMessage(m)',m) + send({'op':'clear'});x=ctx.new_page();x.goto('https://x.com/jack/status/20');x.wait_for_timeout(600);assert not calls,'generic error triggered a lookup' + x.locator('[data-testid="emptyState"]').evaluate("n=>n.style.display='flex'") + count=len(ctx.pages);x.locator('[data-testid="emptyState"] span').evaluate('(n,text)=>n.textContent=text',NOTICE) + x.locator('#limitlessx-inline .post-text').wait_for(timeout=10000) + assert len(ctx.pages)==count;assert x.url=='https://x.com/jack/status/20';assert x.locator('iframe').count()==0 + assert x.get_by_role('button',name='View with LimitlessX').count()==0 + assert x.locator('#limitlessx-inline .post-text').inner_text()==POST['text'] + assert x.locator('[data-testid="emptyState"]').is_hidden(),'withheld notice was not replaced visually' + assert x.locator('#limitlessx-inline .post-card').evaluate('(n)=>getComputedStyle(n).borderRadius')=='0px' + assert x.locator('#limitlessx-inline .post-text').evaluate('(n)=>getComputedStyle(n).fontSize')=='17px' + x.wait_for_timeout(700);assert len(calls)==1,'automatic lookup repeated after its own DOM update' + x.evaluate("document.body.style.backgroundColor='#15202b'");x.wait_for_timeout(400) + assert x.locator('#limitlessx-inline .x-reader').evaluate('(n)=>getComputedStyle(n).backgroundColor')=='rgb(21, 32, 43)' + assert len(calls)==1,'theme change refetched content' + assert send({'op':'record'})['data']['posts']==1,'automatic success was not counted' + x.bring_to_front();assert send({'op':'open_current'})['ok'];assert send({'op':'record'})['data']['posts']==1 + ui.get_by_role('button',name='Settings',exact=True).click();ui.get_by_label('Keep a private record').uncheck();assert send({'op':'record'})['data']['enabled'] is False + ui.get_by_role('button',name='Clear record',exact=True).click();assert send({'op':'record'})['data']['posts']==0 + art=ROOT/'tests/browser/artifacts';art.mkdir(exist_ok=True);ui.set_viewport_size({'width':384,'height':800});ui.screenshot(path=str(art/'popup-settings.png'),full_page=True);x.screenshot(path=str(art/'automatic-native-x.png'),full_page=True) + x.evaluate("history.pushState({},'', '/jack/status/21');document.body.append(document.createElement('div'))");x.locator('#limitlessx-inline').wait_for(state='detached') + manifest=json.loads((ext/'manifest.json').read_text());assert 'options_ui' not in manifest;assert {p.name for p in ext.glob('*.html')}=={'popup.html'} + print(json.dumps({'automatic_injection':'passed','generic_failure_ignored':'passed','native_timeline_style':'passed','single_automatic_request':'passed','no_reader_page':'passed','popup_settings':'passed','navigation_cleanup':'passed','automatic_record_count':1,'deliberate_record_count':1})) diff --git a/tests/browser/inline_contracts.py b/tests/browser/inline_contracts.py new file mode 100644 index 0000000..8b57524 --- /dev/null +++ b/tests/browser/inline_contracts.py @@ -0,0 +1,79 @@ +import json,pathlib,tempfile,copy,time +from playwright.sync_api import sync_playwright +from fixtures import USER,POST,NOTICE,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +report={} +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-inline-contracts-') as profile: + ext=ROOT/'dist/chromium' + def launch():return p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) + with launch() as ctx: + worker=ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker');origin='/'.join(worker.url.split('/')[:3]);ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + send=lambda m:ui.evaluate('(m)=>chrome.runtime.sendMessage(m)',m) + mode={'value':'success'};calls=[] + def provider(route): + calls.append(route.request.url);url=route.request.url + assert not route.request.headers.get('cookie') + if mode['value']=='429':route.fulfill(status=429,json={'code':429},headers={'Retry-After':'1'});return + if mode['value']=='malformed':route.fulfill(body='not JSON');return + if '/statuses' in url: + post=copy.deepcopy(POST) + if 'cursor=' in url:post['id']='21';post['text']='second page' + route.fulfill(json={'code':200,'results':[post],'cursor':{'bottom':None if 'cursor=' in url else 'next+/='}}) + elif '/profile/' in url:route.fulfill(json={'code':200,'user':USER}) + else: + post=copy.deepcopy(POST) + if '/status/21' in url or '/conversation/21' in url:post['id']='21';post['text']='new route post' + route.fulfill(json={'code':200,'status':post,'thread':None,'replies':[],'cursor':{'bottom':None}}) + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html(),content_type='text/html',headers={'Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self'; img-src https://pbs.twimg.com; connect-src 'self'"})) + send({'op':'clear'}) + assert not send({'op':'open_manual','input':'https://x.com.evil.test/jack'})['ok'];assert not calls + with ctx.expect_page() as opened:assert send({'op':'open_manual','input':'@jack'})['ok'] + x=opened.value;x.wait_for_load_state();x.locator('#limitlessx-inline .biography').wait_for() + assert x.url=='https://x.com/jack';assert x.locator('#limitlessx-inline .biography').inner_text()==USER['description'];assert x.evaluate('globalThis.injected') is None + x.get_by_role('button',name='Load more posts').click();x.get_by_text('second page',exact=True).wait_for();assert x.locator('#limitlessx-inline .post-card').count()==2 + assert send({'op':'record'})['data']['profiles']==0;assert send({'op':'record'})['data']['posts']==0 + report['manual_injected_profile_pagination_xss']='passed' + # Run messages in the extension's real isolated content-script world. + def content_sender(page): + cdp=ctx.new_cdp_session(page);worlds=[];cdp.on('Runtime.executionContextCreated',lambda e:worlds.append(e['context']));cdp.send('Runtime.enable') + world=next(w for w in worlds if w['name']=='LimitlessX') + def send_content(message): + result=cdp.send('Runtime.evaluate',{'contextId':world['id'],'expression':'chrome.runtime.sendMessage({...'+json.dumps(message)+',page_url:location.href})','awaitPromise':True,'returnByValue':True});assert 'exceptionDetails' not in result,result;return result['result']['value'] + return send_content + isolated=content_sender(x) + sessions=ui.evaluate('chrome.storage.session.get(null)');token=next(k[7:] for k,v in sessions.items() if v['source']=={'kind':'profile','handle':'jack'}) + assert not isolated({'op':'open_post','session':token,'id':'999'})['ok'] + x.locator('#limitlessx-inline .post-text').first.click();x.locator('#limitlessx-inline .detailed-post .post-text').wait_for();assert x.locator('#limitlessx-inline .post-card').count()==1 + assert len(ctx.pages)==3,'opening a timeline post created another tab' + assert x.url=='https://x.com/jack';assert send({'op':'record'})['data']['posts']==0 + report['same_container_post_open']='passed' + # A restricted direct-post flow counts once, even under racing reads. + z=ctx.new_page();z.goto('https://x.com/jack/status/20');action=z.locator('#limitlessx-inline .post-text').wait_for() + assert send({'op':'record'})['data']['posts']==1 + z.bring_to_front();assert send({'op':'open_current'})['ok'] + assert send({'op':'record'})['data']['posts']==1 + sessions=ui.evaluate('chrome.storage.session.get(null)');token=next(k[7:] for k,v in sessions.items() if v['restricted'] and v.get('deliberate') and v['target']=={'kind':'post','id':'20'}) + content=content_sender(z);request={'op':'read','session':token,'target':{'kind':'post','id':'20'}} + assert not isolated(request)['ok'],'session transferred to another X tab' + assert not send(request)['ok'],'popup accepted content-only reader operation' + for _ in range(4):assert content(request)['ok'] + assert send({'op':'record'})['data']['posts']==1 + report['tab_binding_and_dedup']='passed' + cdp=ctx.new_cdp_session(ui);cdp.send('ServiceWorker.enable');cdp.send('ServiceWorker.stopAllWorkers');assert content(request)['ok'];report['worker_restart']='passed' + ui.evaluate('''async token=>{const key='reader:'+token;const data=await chrome.storage.session.get(key);data[key].expires=0;await chrome.storage.session.set(data);}''',token) + before=len(calls);assert not content(request)['ok'];assert len(calls)==before;report['expiry_before_fetch']='passed' + # Reused notices from an old route must never apply to its new target. + z.evaluate("history.pushState({},'', '/jack/status/21');document.body.append(document.createElement('div'))");z.locator('#limitlessx-inline').wait_for(state='detached');z.wait_for_timeout(400) + assert z.get_by_role('button',name='View with LimitlessX').count()==0;report['navigation_cleanup']='passed' + z.locator('[data-testid="emptyState"] span').evaluate('(n,text)=>n.textContent=text',NOTICE.replace('Türkiye','Germany')) + z.get_by_text('new route post',exact=True).wait_for();report['reused_notice_refresh']='passed' + mode['value']='429' + with ctx.expect_page() as opened:send({'op':'open_manual','input':'https://x.com/jack/status/20'}) + limited=opened.value;limited.get_by_role('alert').wait_for();before=len(calls);limited.get_by_role('button',name='Try again').click();limited.get_by_role('alert').wait_for();assert len(calls)==before + time.sleep(1.1);mode['value']='malformed';limited.get_by_role('button',name='Try again').click();limited.get_by_text('FxTwitter returned an unexpected response.',exact=True).wait_for();report['rate_limit_and_malformed']='passed' + send({'op':'settings','enabled':False});saved=send({'op':'record'})['data'] + art=ROOT/'tests/browser/artifacts';art.mkdir(exist_ok=True);x.screenshot(path=str(art/'inline-content.png'),full_page=True) + with launch() as ctx: + ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for();actual=ui.evaluate('chrome.runtime.sendMessage({op:"record"})')['data'];assert actual==saved;assert ui.evaluate('chrome.storage.session.get(null)')=={};report['browser_restart']='passed' +print(json.dumps(report,indent=2)) diff --git a/tests/browser/lifecycle.py b/tests/browser/lifecycle.py new file mode 100644 index 0000000..0b13922 --- /dev/null +++ b/tests/browser/lifecycle.py @@ -0,0 +1,37 @@ +"""Exercise actual X hydration ordering rather than a ready-at-load fixture.""" +import pathlib,tempfile,json +from playwright.sync_api import sync_playwright +from fixtures import POST +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-lifecycle-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + calls=[] + def provider(route): + calls.append(route.request.url) + route.fulfill(json={'code':200,'status':POST,'thread':None,'replies':[],'cursor':{'bottom':None}}) + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://x.com/**',lambda r:r.fulfill(body='
',content_type='text/html')) + worker=ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker');origin='/'.join(worker.url.split('/')[:3]);ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + before=set(ctx.pages);assert ui.evaluate('chrome.runtime.sendMessage({op:"open_manual",input:"https://x.com/jack/status/20"})')['ok'] + x=next(z for z in ctx.pages if z not in before);x.wait_for_load_state();x.wait_for_timeout(600) + assert x.locator('#limitlessx-inline').count()==0,'reader escaped into body before primary column existed' + primary='

Post

Native conversation

' + x.locator('#shell').evaluate('(n,html)=>n.innerHTML=html',primary) + x.locator('[data-testid="primaryColumn"] #limitlessx-inline .post-text').wait_for() + assert x.locator('[aria-label="Timeline: Conversation"]').evaluate('n=>n.hidden') + assert len(calls)==1 + # X replaces its entire primary column, preserving the surrounding shell. + for _ in range(2): + x.locator('#shell').evaluate('(n,html)=>n.innerHTML=html',primary) + x.locator('[data-testid="primaryColumn"] #limitlessx-inline .post-text').wait_for() + assert x.locator('[aria-label="Timeline: Conversation"]').evaluate('n=>n.hidden') + # Native timeline may arrive after the replacement column itself. + x.locator('[aria-label="Timeline: Conversation"]').evaluate('n=>n.remove()') + x.locator('[data-testid="primaryColumn"]').evaluate('n=>n.insertAdjacentHTML("afterbegin",\'
Late native conversation
\')') + x.wait_for_function('document.querySelector(\'[aria-label="Timeline: Conversation"]\').hidden') + x.wait_for_timeout(600);assert len(calls)==1,'hydration caused repeat provider fetches' + assert x.locator('#limitlessx-inline').count()==1 + assert x.locator('[data-testid="primaryColumn"] h2').is_visible() + box=x.locator('#limitlessx-inline').bounding_box();col=x.locator('[data-testid="primaryColumn"]').bounding_box();assert box['width']<=col['width'] and box['x']==col['x'] + print(json.dumps({'deferred_primary':'passed','primary_replacement':'passed','late_native_timeline':'passed','no_refetch':'passed','column_boundaries':'passed'})) diff --git a/tests/browser/media_player.py b/tests/browser/media_player.py new file mode 100644 index 0000000..cf0d5d9 --- /dev/null +++ b/tests/browser/media_player.py @@ -0,0 +1,47 @@ +"""Packaged controls use public X media URLs and retain native media state.""" +import copy,json,pathlib,tempfile +from playwright.sync_api import sync_playwright +from fixtures import POST,USER,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-player-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + failed=[False] + media={'videos':[{'type':'video','url':'https://video.twimg.com/fixture.webm'}]} + posts=[{**copy.deepcopy(POST),'id':str(i),'text':f'Post {i}\n'+('Public fixture\n'*12),'media':media if i==20 else {}}for i in range(20,30)] + def provider(r): + u=r.request.url + if '/statuses'in u:r.fulfill(json={'code':200,'results':posts,'cursor':{'bottom':None}}) + else:r.fulfill(json={'code':200,'user':USER}) + def video(r): + if failed[0]:r.fulfill(status=404,headers={'Access-Control-Allow-Origin':'*'}) + else: + data=(ROOT/'tests/fixtures/player.webm').read_bytes();header=r.request.headers.get('range');start=0;end=len(data)-1 + if header: + bounds=header.removeprefix('bytes=').split('-');start=int(bounds[0] or 0);end=min(int(bounds[1]) if bounds[1] else end,end) + headers={'Access-Control-Allow-Origin':'*','Accept-Ranges':'bytes','Content-Length':str(end-start+1)} + if header:headers['Content-Range']=f'bytes {start}-{end}/{len(data)}' + r.fulfill(status=206 if header else 200,body=data[start:end+1],content_type='video/webm',headers=headers) + ctx.route('https://api.fxtwitter.com/**',provider);ctx.route('https://video.twimg.com/**',video) + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html('Account Withheld@jack has been withheld in TR in response to a legal demand.'),content_type='text/html')) + x=ctx.new_page();x.set_viewport_size({'width':1000,'height':700});x.goto('https://x.com/jack') + play=x.get_by_role('button',name='Play video',exact=True);play.wait_for();v=x.locator('video');assert not v.evaluate('(v)=>v.controls') + assert x.get_by_label('Playback speed').input_value()=='1' + play.click();x.wait_for_function('!document.querySelector("#limitlessx-inline").shadowRoot.querySelector("video").paused') + x.get_by_role('button',name='Pause video',exact=True).click();assert v.evaluate('(v)=>v.paused') + x.get_by_label('Playback speed').select_option('1.5');assert v.evaluate('(v)=>v.playbackRate')==1.5 + x.get_by_role('button',name='Mute video',exact=True).click();assert v.evaluate('(v)=>v.muted') + x.wait_for_function('Number.isFinite(document.querySelector("#limitlessx-inline").shadowRoot.querySelector("video").duration)') + x.get_by_label('Seek video').evaluate('(n)=>{n.value="500";n.dispatchEvent(new Event("input",{bubbles:true}))}');assert 5<=v.evaluate('(v)=>v.currentTime')<=7, v.evaluate('(v)=>({time:v.currentTime,duration:v.duration,ready:v.readyState})') + x.get_by_role('button',name='Full screen',exact=True).click();assert x.evaluate('!!document.fullscreenElement') + x.get_by_role('button',name='Exit full screen',exact=True).click();x.wait_for_function('!document.fullscreenElement') + x.evaluate('globalThis.savedVideo=document.querySelector("#limitlessx-inline").shadowRoot.querySelector("video")') + x.get_by_role('button',name='Load more posts').click();x.wait_for_function('document.querySelector("#limitlessx-inline").shadowRoot.querySelectorAll(".post-card").length===10') + assert x.evaluate('globalThis.savedVideo===document.querySelector("#limitlessx-inline").shadowRoot.querySelector("video")') + assert v.evaluate('(v)=>v.playbackRate')==1.5 + # Media failure keeps a source link and can be retried without replacing the reader. + failed[0]=True;v.evaluate('(v)=>{v.src="https://video.twimg.com/fixture.webm?fail=1";v.load()}') + x.get_by_role('button',name='Retry video').wait_for();assert x.get_by_role('link',name='Open video on X').is_visible() + failed[0]=False;x.get_by_role('button',name='Retry video').click();x.wait_for_function('!document.querySelector("#limitlessx-inline").shadowRoot.querySelector("video").paused') + x.evaluate("history.pushState({},'', '/other');document.body.append(document.createElement('div'))");x.locator('#limitlessx-inline').wait_for(state='detached');assert x.evaluate('globalThis.savedVideo.paused') + print(json.dumps({'play_pause':'passed','seek_volume_speed':'passed','fullscreen':'passed','state_preserved_on_append':'passed','error_retry':'passed','navigation_pauses':'passed'})) diff --git a/tests/browser/native_fidelity.py b/tests/browser/native_fidelity.py new file mode 100644 index 0000000..58a04c5 --- /dev/null +++ b/tests/browser/native_fidelity.py @@ -0,0 +1,42 @@ +"""Literal geometry expectations are taken from the actual Helium X capture.""" +import copy,json,pathlib,tempfile +from playwright.sync_api import sync_playwright +from fixtures import USER,POST,html +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-fidelity-') as profile: + ext=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={ext}',f'--load-extension={ext}','--no-sandbox']) as ctx: + user={**USER,'followers':9876543,'following':820,'joined':'Sun Nov 27 19:45:16 +0000 2011','location':'İstanbul','banner_url':'https://pbs.twimg.com/profile_banners/12/1','avatar_url':'https://pbs.twimg.com/profile_images/12/a.jpg'} + post={**copy.deepcopy(POST),'author':user,'likes':249,'replies':34,'reposts':10,'views':4137,'bookmarks':7} + reply={**copy.deepcopy(post),'id':'21','text':'Reply from the source'};second={**copy.deepcopy(reply),'id':'22','text':'Second reply page'} + calls=[] + def provider(route): + u=route.request.url;calls.append(u) + if '/conversation/' in u: + if '/21' in u:body={'code':200,'status':reply,'thread':None,'replies':[],'cursor':{'bottom':None}} + else:body={'code':200,'status':post,'thread':None,'replies':[second]if 'cursor='in u else[reply],'cursor':{'bottom':None if 'cursor='in u else'next+/='}} + elif '/statuses' in u:body={'code':200,'results':[post],'cursor':{'bottom':None}} + elif '/profile/' in u:body={'code':200,'user':user} + else:body={'code':200,'status':post} + route.fulfill(json=body) + ctx.route('https://api.fxtwitter.com/**',provider) + ctx.route('https://pbs.twimg.com/**',lambda r:r.abort()) + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html('Account Withheld@jack has been withheld in TR in response to a legal demand.' if r.request.url.endswith('/jack') else 'This post has been withheld in TR in response to a legal demand.'),content_type='text/html')) + worker=ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker');origin='/'.join(worker.url.split('/')[:3]);ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + x=ctx.new_page();x.set_viewport_size({'width':1000,'height':900});x.goto('https://x.com/jack/status/20');x.locator('[data-testid="primaryColumn"]').evaluate("n=>{n.style.width='600px';n.style.margin='0 auto'}") + x.locator('#limitlessx-inline .post-card').nth(1).wait_for();detail=x.locator('#limitlessx-inline .detailed-post');comment=x.locator('#limitlessx-inline .post-card').nth(1) + styles=lambda n:n.evaluate('(n)=>{let c=getComputedStyle(n);return {fontSize:c.fontSize,lineHeight:c.lineHeight,width:n.getBoundingClientRect().width,x:n.getBoundingClientRect().x}}') + x.evaluate("history.replaceState({},'', '/i/status/20');dispatchEvent(new PopStateEvent('popstate'))");x.wait_for_timeout(600);x.locator('#limitlessx-inline .detailed-post').wait_for(); + x.locator('#limitlessx-inline').evaluate('n=>n.remove()');x.locator('#limitlessx-inline .detailed-post').wait_for(); + assert styles(detail.locator('.post-text'))['fontSize']=='17px';assert styles(detail.locator('.post-text'))['lineHeight']=='24px' + assert styles(comment.locator('.post-text'))['fontSize']=='15px';assert styles(comment.locator('.post-text'))['lineHeight']=='20px' + avatar=styles(comment.locator('.post-avatar'));text=styles(comment.locator('.post-text'));assert avatar['width']==40;assert text['x']-avatar['x']==48 + assert styles(detail.locator('.post-text'))['width']==568,'detail body is incorrectly kept in the avatar column' + assert detail.locator('.x-metric-value').first.inner_text()=='34' + x.get_by_role('button',name='Show more replies').click();x.get_by_text('Second reply page',exact=True).wait_for();assert x.locator('#limitlessx-inline .post-card').count()==3 + assert sum('/conversation/'in u for u in calls)==2 + comment.locator('.post-text').click();x.locator('#limitlessx-inline .detailed-post').get_by_text('Reply from the source',exact=True).wait_for() + assert ui.evaluate('chrome.runtime.sendMessage({op:"record"})')['data']['posts']==2 + z=ctx.new_page();z.goto('https://x.com/jack');z.locator('#limitlessx-inline .x-profile').wait_for();assert z.locator('.x-profile-count').all_text_contents()==['820 Following','9.9M Followers'];assert z.locator('#limitlessx-inline .x-banner').count()==1 + art=ROOT/'tests/browser/artifacts';art.mkdir(exist_ok=True);z.screenshot(path=str(art/'native-profile.png'),full_page=True);x.screenshot(path=str(art/'native-comments.png'),full_page=True) + print(json.dumps({'profile_metadata':'passed','main_post_17_24':'passed','replies_15_20':'passed','avatar_40_gap_8':'passed','full_width_detailed_body':'passed','reply_pagination':'passed','deliberate_reply_count':'passed','real_heading_detection':'passed'})) diff --git a/tests/browser/paths.py b/tests/browser/paths.py new file mode 100644 index 0000000..bb302d6 --- /dev/null +++ b/tests/browser/paths.py @@ -0,0 +1,9 @@ +import os,shutil +from playwright.sync_api import sync_playwright + +def firefox_binary(): + if os.environ.get('FIREFOX_BINARY'):return os.environ['FIREFOX_BINARY'] + with sync_playwright() as p:return p.firefox.executable_path + +def geckodriver(): + return os.environ.get('GECKODRIVER') or shutil.which('geckodriver') or '/tmp/limitlessx-tools/geckodriver' diff --git a/tests/browser/requirements.txt b/tests/browser/requirements.txt new file mode 100644 index 0000000..4c371aa --- /dev/null +++ b/tests/browser/requirements.txt @@ -0,0 +1,2 @@ +playwright==1.64.1 +selenium==4.52.0 diff --git a/tests/browser/review_regressions.py b/tests/browser/review_regressions.py new file mode 100644 index 0000000..38d9db0 --- /dev/null +++ b/tests/browser/review_regressions.py @@ -0,0 +1,121 @@ +"""Recovery regressions: partial feeds, shared identity errors, replies and sessions.""" +import copy +import json +import pathlib +import tempfile +from urllib.parse import parse_qs, urlparse +from playwright.sync_api import sync_playwright, expect +from fixtures import USER, POST, html + +ROOT = pathlib.Path(__file__).resolve().parents[2] + +with sync_playwright() as p, tempfile.TemporaryDirectory(prefix='limitlessx-review-') as profile: + ext = ROOT / 'dist/chromium' + with p.chromium.launch_persistent_context(profile, channel='chromium', headless=True, + args=[f'--disable-extensions-except={ext}', f'--load-extension={ext}', '--no-sandbox']) as ctx: + state = {'feed_fail': True, 'identity_fail': True, 'reply_fail': True, 'version': 'old snapshot'} + calls = [] + + def user(handle, uid='12'): + return {**USER, 'screen_name': handle, 'id': uid, 'name': handle, 'description': 'Public biography'} + + def post(handle, pid='20', text='public post'): + return {**copy.deepcopy(POST), 'id': pid, 'text': text, 'author': user(handle)} + + def provider(route): + url = route.request.url + calls.append(url) + parsed = urlparse(url) + path = parsed.path + query = parse_qs(parsed.query) + if path == '/2/typeahead': + handle = query['q'][0] + if handle == 'identity' and state['identity_fail']: + route.abort('failed') + else: + route.fulfill(json={'code': 200, 'users': [user(handle)]}) + return + if path.startswith('/2/conversation/'): + later = 'cursor' in query + if later and state['reply_fail']: + route.fulfill(status=503, json={'code': 503}) + else: + route.fulfill(json={'code': 200, 'status': post('jack'), 'thread': [], + 'replies': [post('jack', '22' if later else '21', 'second reply' if later else 'first reply')], + 'cursor': {'bottom': None if later else 'next-replies'}}) + return + handle = path.split('/')[3] + feed = path.endswith('/statuses') + if handle in ('identity', 'mismatch'): + route.fulfill(status=404, json={'code': 404}) + elif handle.startswith('id:'): + # Both recovered profiles resolve UID 12. Mismatch is tested after identity. + if state.get('mismatch'): + route.fulfill(status=503, json={'code': 503}) if feed else route.fulfill(json={'code': 200, 'user': user('mismatch', '999')}) + else: + route.fulfill(json={'code': 200, **({'results': [post('identity')], 'cursor': {'bottom': None}} if feed else {'user': user('identity')})}) + elif feed and handle == 'partial' and state['feed_fail']: + route.fulfill(status=503, json={'code': 503}) + elif feed: + route.fulfill(json={'code': 200, 'results': [post(handle, text=state['version'] if handle == 'sessions' else 'public post')], 'cursor': {'bottom': None}}) + else: + route.fulfill(json={'code': 200, 'user': user(handle)}) + + ctx.route('https://api.fxtwitter.com/**', provider) + def x_document(route): + handle = urlparse(route.request.url).path.split('/')[1] + notice = 'This post has been withheld in Türkiye in response to a legal demand.' if '/status/' in route.request.url else f'Account Withheld@{handle} has been withheld in TR in response to a legal demand.' + route.fulfill(body=html(notice), content_type='text/html') + ctx.route('https://x.com/**', x_document) + worker = ctx.service_workers[0] if ctx.service_workers else ctx.wait_for_event('serviceworker') + origin = '/'.join(worker.url.split('/')[:3]) + ui = ctx.new_page() + ui.goto(origin + '/popup.html') + ui.get_by_role('heading', name='LimitlessX', exact=True).wait_for() + send = lambda message: ui.evaluate('(m)=>chrome.runtime.sendMessage(m)', message) + x = ctx.new_page() + + x.goto('https://x.com/partial') + retry = x.get_by_role('button', name='Try again', exact=True) + retry.wait_for() + state['feed_fail'] = False + retry.click() + expect(x.locator('#limitlessx-inline .post-text')).to_have_text('public post') + + x.goto('https://x.com/identity') + retry.wait_for() + failed_calls = sum('/typeahead' in url for url in calls) + assert failed_calls >= 1 + state['identity_fail'] = False + retry.click() + expect(x.locator('#limitlessx-inline .post-text')).to_have_text('public post') + assert sum('/typeahead' in url for url in calls) > failed_calls, 'failed shared identity result poisoned retry' + + x.goto('https://x.com/jack/status/20') + expect(x.locator('#limitlessx-inline .post-text')).to_have_text(['public post', 'first reply']) + x.get_by_role('button', name='Show more replies', exact=True).click() + retry.wait_for() + expect(x.locator('#limitlessx-inline .post-text')).to_have_text(['public post', 'first reply']) + state['reply_fail'] = False + retry.click() + expect(x.locator('#limitlessx-inline .post-text')).to_have_text(['public post', 'first reply', 'second reply']) + expect(x.locator('#limitlessx-inline .notice')).to_have_count(0) + + x.goto('https://x.com/sessions') + expect(x.locator('#limitlessx-inline .post-text')).to_have_text('old snapshot') + state['version'] = 'latest snapshot' + x.bring_to_front() + assert send({'op': 'open_current'})['ok'] + expect(x.locator('#limitlessx-inline .post-text')).to_have_text('latest snapshot') + before_reload = len(calls) + for _ in range(3): + x.reload() + expect(x.locator('#limitlessx-inline .post-text')).to_have_text('latest snapshot') + assert len(calls) == before_reload, 'reload should restore latest session without refetching' + + state['mismatch'] = True + x.goto('https://x.com/mismatch') + x.get_by_role('alert').wait_for() + expect(x.locator('#limitlessx-inline .x-profile')).to_have_count(0) + expect(x.locator('#limitlessx-inline .post-card')).to_have_count(0) + print(json.dumps({name: 'passed' for name in ['initial_timeline_retry', 'failed_identity_eviction', 'reply_retry_preserves_content', 'latest_session_restoration', 'recovered_uid_mismatch_rejected']})) diff --git a/tests/browser/smoke.py b/tests/browser/smoke.py new file mode 100644 index 0000000..45f7dfb --- /dev/null +++ b/tests/browser/smoke.py @@ -0,0 +1,13 @@ +"""Real extension smoke test; provider-independent and no X credentials.""" +import json, pathlib, tempfile, sys +from playwright.sync_api import sync_playwright +ROOT=pathlib.Path(__file__).resolve().parents[2] +with sync_playwright() as p, tempfile.TemporaryDirectory(prefix='limitlessx-chromium-') as profile: + extension=ROOT/'dist/chromium' + with p.chromium.launch_persistent_context(profile,channel="chromium",headless=True,args=[f'--disable-extensions-except={extension}',f'--load-extension={extension}','--no-sandbox']) as context: + worker=context.service_workers[0] if context.service_workers else context.wait_for_event('serviceworker') + origin='/'.join(worker.url.split('/')[:3]);page=context.new_page();page.goto(origin+'/popup.html') + page.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + response=page.evaluate('chrome.runtime.sendMessage({op:"ping"})') + assert response=={'ok':True,'data':{'runtime':'rust-wasm'}},response + print(json.dumps({'browser':'chromium','runtime':response,'popup':page.title()})) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..1d2d520 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1 @@ +player.webm is a generated 12-second black frame and 220 Hz test tone, created with FFmpeg for media-control tests. It contains no third-party footage. Public conversation fixtures have sanitized post text and exercise provider schema normalization. diff --git a/tests/fixtures/player.webm b/tests/fixtures/player.webm new file mode 100644 index 0000000..b51606f Binary files /dev/null and b/tests/fixtures/player.webm differ diff --git a/tests/fixtures/public-conversation.json b/tests/fixtures/public-conversation.json new file mode 100644 index 0000000..85e3ea3 --- /dev/null +++ b/tests/fixtures/public-conversation.json @@ -0,0 +1,192 @@ +{ + "code": 200, + "status": { + "type": "status", + "id": "2115761952937128381", + "text": "Public API schema fixture", + "replies": 71, + "reposts": 134, + "likes": 2446, + "bookmarks": 173, + "quotes": 19, + "created_at": "Thu Oct 29 11:05:33 +0000 2026", + "views": 92529, + "replying_to": null, + "media": { + "all": [ + { + "type": "photo", + "id": "2115756565965721600", + "url": "https://pbs.twimg.com/media/HVytYxGWUAAyo5s.png?name=orig", + "width": 624, + "height": 281 + }, + { + "type": "photo", + "id": "2115758560483168256", + "url": "https://pbs.twimg.com/media/HVyvM3QXUAA8b82.png?name=orig", + "width": 655, + "height": 487 + } + ], + "photos": [ + { + "type": "photo", + "id": "2115756565965721600", + "url": "https://pbs.twimg.com/media/HVytYxGWUAAyo5s.png?name=orig", + "width": 624, + "height": 281 + }, + { + "type": "photo", + "id": "2115758560483168256", + "url": "https://pbs.twimg.com/media/HVyvM3QXUAA8b82.png?name=orig", + "width": 655, + "height": 487 + } + ], + "mosaic": { + "type": "mosaic_photo", + "formats": { + "jpeg": "https://mosaic.fxtwitter.com/jpeg/2115761952937128381/HVytYxGWUAAyo5s/HVyvM3QXUAA8b82", + "webp": "https://mosaic.fxtwitter.com/webp/2115761952937128381/HVytYxGWUAAyo5s/HVyvM3QXUAA8b82" + } + } + }, + "provider": "twitter", + "author": { + "screen_name": "Burhimum", + "id": "1504861766496440320", + "followers": 136941, + "following": 391, + "name": "Burhanettin Mumcuoğlu", + "description": "Müslüman, Türk, avukat, Burhi.\n 𐱅𐰇𐰼𐰰\nhttps://kick.com/burhi\nhttp://instagram.com/burhimum\nhttp://youtube.com/c/Burhi", + "location": "Antalya", + "banner_url": "https://pbs.twimg.com/profile_banners/1504861766496440320/1792435510", + "avatar_url": "https://pbs.twimg.com/profile_images/1948542613692219392/2OARPjqf_200x200.jpg", + "joined": "Fri Mar 18 16:48:43 +0000 2022", + "protected": false, + "website": { + "url": "http://mumcuogluhukuk.com", + "display_url": "mumcuogluhukuk.com" + }, + "verification": { + "verified": true, + "verified_at": null, + "type": "individual" + }, + "type": "profile" + } + }, + "thread": null, + "replies": [ + { + "type": "status", + "id": "2115773755519541639", + "text": "Public API schema fixture", + "replies": 6, + "reposts": 62, + "likes": 1219, + "bookmarks": 61, + "quotes": 13, + "created_at": "Thu Oct 29 11:52:27 +0000 2026", + "views": 44556, + "replying_to": { + "screen_name": "Burhimum", + "status": "2115761955499573735", + "url": "https://x.com/Burhimum/status/2115761955499573735", + "profile_url": "https://x.com/Burhimum" + }, + "media": { + "all": [ + { + "type": "photo", + "id": "2115771944830144512", + "url": "https://pbs.twimg.com/media/HVy7X71WkAAyagm.png?name=orig", + "width": 858, + "height": 324 + } + ], + "photos": [ + { + "type": "photo", + "id": "2115771944830144512", + "url": "https://pbs.twimg.com/media/HVy7X71WkAAyagm.png?name=orig", + "width": 858, + "height": 324 + } + ] + }, + "provider": "twitter", + "author": { + "screen_name": "Burhimum", + "id": "1504861766496440320", + "followers": 136941, + "following": 391, + "name": "Burhanettin Mumcuoğlu", + "description": "Müslüman, Türk, avukat, Burhi.\n 𐱅𐰇𐰼𐰰\nhttps://kick.com/burhi\nhttp://instagram.com/burhimum\nhttp://youtube.com/c/Burhi", + "location": "Antalya", + "banner_url": "https://pbs.twimg.com/profile_banners/1504861766496440320/1792435510", + "avatar_url": "https://pbs.twimg.com/profile_images/1948542613692219392/2OARPjqf_200x200.jpg", + "joined": "Fri Mar 18 16:48:43 +0000 2022", + "protected": false, + "website": { + "url": "http://mumcuogluhukuk.com", + "display_url": "mumcuogluhukuk.com" + }, + "verification": { + "verified": true, + "verified_at": null, + "type": "individual" + }, + "type": "profile" + } + }, + { + "type": "status", + "id": "2115762365174083788", + "text": "Public API schema fixture", + "replies": 0, + "reposts": 0, + "likes": 187, + "bookmarks": 0, + "quotes": 0, + "created_at": "Thu Oct 29 11:07:11 +0000 2026", + "views": 5254, + "replying_to": { + "screen_name": "Burhimum", + "status": "2115761952937128381", + "url": "https://x.com/Burhimum/status/2115761952937128381", + "profile_url": "https://x.com/Burhimum" + }, + "media": {}, + "provider": "twitter", + "author": { + "screen_name": "ardardav", + "id": "1394322142393774085", + "followers": 15948, + "following": 142, + "name": "ardardav", + "description": "Yedek @ardardav_", + "location": "", + "banner_url": "https://pbs.twimg.com/profile_banners/1394322142393774085/1631886708", + "avatar_url": "https://pbs.twimg.com/profile_images/2007188078675259392/uL2BJXkW_200x200.jpg", + "joined": "Mon May 17 16:01:25 +0000 2021", + "protected": false, + "website": { + "url": "https://guns.lol/ardardav", + "display_url": "guns.lol/ardardav" + }, + "verification": { + "verified": true, + "verified_at": null, + "type": "individual" + }, + "type": "profile" + } + } + ], + "cursor": { + "bottom": "DAAKCgABHV1Mad9__p8LAAIAAAGQRW1QQzZ3QUFBZlEvZ0dKTjB2R3AvQUFBQUNNZFhMOHdCSmVRcGgxY3YzQWpHc0NOSFZ6TDJpSGJrTElkWExUaERac3dXQjFjdFpzem10QmtIVjBNVWJmWGNlOGRYTFNhTFZjQjdCMWN2WlRrRzFDRkhWeXlxVTFYY013ZFhMaFFwQmFCN2gxY3QxTnFXdkFxSFZ5NEJwS1dFZjBkWExTak05ckFwaDFkRDFEaG03QzhIVnk4Q0Z4V29kQWRYTURDN2Rvd1FoMWN2UkxwRzVCaEhWelFmVDJXWVE4ZFhMYUxUOVlBSUIxYzQ2WHFGMUhWSFZ5eVNWSWFzYjBkWExmOExsZlJEaDFjN0N0dGx4QTVIVnpCL1NxWGdad2RYTUxJV2hlQmd4MWN2RlFmR3JCbEhWeXlTZXJXZ2VjZFhMTUh2RnZCWlIxY3NzY1lGNEZnSFYwNDROeWJZRWNkWExNN0F4dXg4aDFjNlkxWVZpRDFIVnl6QlJyWFllVWRYTDBGVXRZaGh4MWRKVzRiVm9FeQgAAwAAAAILAAQAAAAGQm90dG9tAAA" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d802da7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "extension/adapters", + "outDir": "target/adapters", + "skipLibCheck": false + }, + "include": ["extension/adapters/**/*.ts"] +}