From 9b569964e6ccb2a0ace193679ee9160389320042 Mon Sep 17 00:00:00 2001 From: Onur Konuk Date: Fri, 30 Oct 2026 00:03:29 +0300 Subject: [PATCH 1/6] feat: build Rust WASM readers and atomic local record for both browser families --- .cargo/config.toml | 2 + .gitignore | 5 + Cargo.lock | 1940 +++++++++++++++++ Cargo.toml | 30 + LICENSE | 21 + README.md | 37 + crates/core/Cargo.toml | 12 + crates/core/src/lib.rs | 253 +++ crates/core/tests/domain.rs | 125 ++ crates/fxtwitter/Cargo.toml | 12 + crates/fxtwitter/src/lib.rs | 229 ++ crates/fxtwitter/tests/provider.rs | 86 + crates/runtime/Cargo.toml | 24 + crates/runtime/src/lib.rs | 60 + crates/runtime/src/wasm.rs | 428 ++++ crates/runtime/tests/security.rs | 46 + crates/storage/Cargo.toml | 16 + crates/storage/src/lib.rs | 5 + crates/storage/src/wasm.rs | 89 + crates/ui/Cargo.toml | 22 + crates/ui/src/app.rs | 63 + crates/ui/src/lib.rs | 10 + crates/ui/src/options.rs | 11 + crates/ui/src/popup.rs | 48 + crates/ui/src/reader.rs | 133 ++ crates/ui/src/record.rs | 43 + crates/xtask/Cargo.toml | 9 + crates/xtask/src/main.rs | 101 + docs/architecture.md | 19 + docs/brand.md | 9 + docs/compatibility.md | 16 + docs/privacy.md | 15 + docs/product-spec.md | 605 +++++ docs/progress.md | 17 + .../plans/2026-10-29-limitlessx.md | 61 + docs/verification/feasibility.json | 129 ++ docs/verification/release-audit.md | 19 + extension/adapters/background-firefox.js | 2 + extension/adapters/background.js | 12 + extension/adapters/bridge.js | 19 + extension/adapters/content.js | 44 + extension/adapters/ui.js | 12 + extension/manifests/chromium.json | 57 + extension/manifests/firefox.json | 69 + extension/static/brand/icon-128.png | Bin 0 -> 3134 bytes extension/static/brand/icon-16.png | Bin 0 -> 430 bytes extension/static/brand/icon-32.png | Bin 0 -> 719 bytes extension/static/brand/icon-48.png | Bin 0 -> 1227 bytes extension/static/brand/mark.svg | 1 + extension/static/options.html | 2 + extension/static/popup.html | 2 + extension/static/reader.html | 2 + extension/static/styles/app.css | 1 + extension/static/styles/content.css | 1 + rust-toolchain.toml | 5 + scripts/probe-provider.py | 30 + scripts/tools.env.example | 6 + tests/browser/contracts.py | 85 + tests/browser/firefox_smoke.py | 25 + tests/browser/fixtures.py | 5 + tests/browser/flows.py | 36 + tests/browser/smoke.py | 13 + 62 files changed, 5179 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/tests/domain.rs create mode 100644 crates/fxtwitter/Cargo.toml create mode 100644 crates/fxtwitter/src/lib.rs create mode 100644 crates/fxtwitter/tests/provider.rs create mode 100644 crates/runtime/Cargo.toml create mode 100644 crates/runtime/src/lib.rs create mode 100644 crates/runtime/src/wasm.rs create mode 100644 crates/runtime/tests/security.rs create mode 100644 crates/storage/Cargo.toml create mode 100644 crates/storage/src/lib.rs create mode 100644 crates/storage/src/wasm.rs create mode 100644 crates/ui/Cargo.toml create mode 100644 crates/ui/src/app.rs create mode 100644 crates/ui/src/lib.rs create mode 100644 crates/ui/src/options.rs create mode 100644 crates/ui/src/popup.rs create mode 100644 crates/ui/src/reader.rs create mode 100644 crates/ui/src/record.rs create mode 100644 crates/xtask/Cargo.toml create mode 100644 crates/xtask/src/main.rs create mode 100644 docs/architecture.md create mode 100644 docs/brand.md create mode 100644 docs/compatibility.md create mode 100644 docs/privacy.md create mode 100644 docs/product-spec.md create mode 100644 docs/progress.md create mode 100644 docs/superpowers/plans/2026-10-29-limitlessx.md create mode 100644 docs/verification/feasibility.json create mode 100644 docs/verification/release-audit.md create mode 100644 extension/adapters/background-firefox.js create mode 100644 extension/adapters/background.js create mode 100644 extension/adapters/bridge.js create mode 100644 extension/adapters/content.js create mode 100644 extension/adapters/ui.js create mode 100644 extension/manifests/chromium.json create mode 100644 extension/manifests/firefox.json create mode 100644 extension/static/brand/icon-128.png create mode 100644 extension/static/brand/icon-16.png create mode 100644 extension/static/brand/icon-32.png create mode 100644 extension/static/brand/icon-48.png create mode 100644 extension/static/brand/mark.svg create mode 100644 extension/static/options.html create mode 100644 extension/static/popup.html create mode 100644 extension/static/reader.html create mode 100644 extension/static/styles/app.css create mode 100644 extension/static/styles/content.css create mode 100644 rust-toolchain.toml create mode 100644 scripts/probe-provider.py create mode 100644 scripts/tools.env.example create mode 100644 tests/browser/contracts.py create mode 100644 tests/browser/firefox_smoke.py create mode 100644 tests/browser/fixtures.py create mode 100644 tests/browser/flows.py create mode 100644 tests/browser/smoke.py 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/.gitignore b/.gitignore new file mode 100644 index 0000000..e22e62b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +/dist/ +.superpowers/ +__pycache__/ +/tests/browser/artifacts/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1ac8ff6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1940 @@ +# 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 = "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 = "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 = "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 = "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 = [ + "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 = "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 = "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", +] + +[[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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" 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..24d16b9 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# LimitlessX + +**No VPN. No proxy setup. No DPI hacks.** + +A Rust-powered browser extension for viewing public X content through FxTwitter, when available. Firefox and Chromium builds share one Rust workspace and a Leptos interface. + +**Development version. Not yet released in browser stores.** Availability is not guaranteed. The live provider returns some historically withheld public content, but has inconsistent profile responses; current regional-notice verification and the full release audit are still in progress. + +## Build + +Install rustup; the project pins Rust 1.99.1 and the WASM target in `rust-toolchain.toml`. Install matching tools: + +```sh +cargo install wasm-bindgen-cli --version 0.2.129 --locked +# Install Binaryen version_133 and put wasm-opt on PATH. +cargo xtask build chromium +cargo xtask build firefox +cargo xtask test +``` + +If tools are not on PATH, set `WASM_BINDGEN` and `WASM_OPT` to their executable paths. See [tool versions](scripts/tools.env.example). Browser integration requires Python with Playwright and Selenium, plus Chromium, Firefox and geckodriver; see `tests/browser/`. + +## Use + +Load `dist/chromium` as an unpacked extension, or temporarily load `dist/firefox/manifest.json` through Firefox's about:debugging. Open a public X profile/post from the popup, or use the LimitlessX action on a supported regional-withholding notice. Requests start only after activation. The reader opens in a packaged extension tab. + +Manual lookups do not add to The Record. Eligible restricted profiles count once after a successful deliberate open. Fetched timeline posts count only when individually opened. Settings can disable recording or clear the local ledger. + +## Project + +- [Product specification](docs/product-spec.md) +- [Architecture](docs/architecture.md) +- [Privacy](docs/privacy.md) +- [Browser evidence](docs/compatibility.md) +- [Implementation progress](docs/progress.md) + +MIT license. Contributions should preserve Rust-owned application logic, narrow browser adapters, explicit provider attribution and honest capability claims. Run the native tests and both browser suites before proposing a release. Use fixtures for error states; do not classify unavailable content as a regional block. 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..68e9b30 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,253 @@ +//! 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] => 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 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)] +pub struct Profile { + pub id: String, + pub handle: String, + pub name: String, + pub biography: Option, + pub avatar: 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)] +pub struct Post { + pub id: String, + pub text: String, + pub author: Profile, + pub published: Option, + pub media: Vec, +} +#[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..d28c3cb --- /dev/null +++ b/crates/core/tests/domain.rs @@ -0,0 +1,125 @@ +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")); +} 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..26fbd7b --- /dev/null +++ b/crates/fxtwitter/src/lib.rs @@ -0,0 +1,229 @@ +//! 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, +} +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); + } + let handle = valid_handle(&self.screen_name).ok_or(Error::InvalidResponse)?; + Ok(Profile { + id: self.id, + handle, + name: self.name, + biography: self.description, + avatar: self.avatar_url.and_then(|u| safe_media_url(&u)), + }) + } +} +#[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 { + provider: String, + id: String, + text: String, + author: RawProfile, + created_at: Option, + media: Option, + }, + #[serde(rename = "tombstone")] + Tombstone { reason: String }, +} +#[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 { + match self { + Self::Tombstone { reason } => Err(error_reason(&reason)), + Self::Status { + provider, + id, + text, + author, + created_at, + media, + } => { + 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, + }) + } + } + } +} +#[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 }) +} diff --git a/crates/fxtwitter/tests/provider.rs b/crates/fxtwitter/tests/provider.rs new file mode 100644 index 0000000..d650b1f --- /dev/null +++ b/crates/fxtwitter/tests/provider.rs @@ -0,0 +1,86 @@ +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") + ); +} diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml new file mode 100644 index 0000000..e9bd1a5 --- /dev/null +++ b/crates/runtime/Cargo.toml @@ -0,0 +1,24 @@ +[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] +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/lib.rs b/crates/runtime/src/lib.rs new file mode 100644 index 0000000..1384a93 --- /dev/null +++ b/crates/runtime/src/lib.rs @@ -0,0 +1,60 @@ +use limitless_core::Target; +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sender { + pub id: String, + pub url: String, + pub tab_id: Option, + pub frame_id: u32, + pub extension_origin: String, +} +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" | "options.html" | "reader.html") + ) && matches!( + op, + "ping" + | "record" + | "settings" + | "clear" + | "open_manual" + | "read" + | "timeline" + | "open_post" + | "current" + | "open_current" + ); + } + let Ok(u) = url::Url::parse(&sender.url) else { + return false; + }; + 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") +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub target: Target, + pub restricted: bool, + pub expires: f64, + pub tab_id: i32, + #[serde(default)] + pub allowed_posts: Vec, +} +impl Session { + 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/wasm.rs b/crates/runtime/src/wasm.rs new file mode 100644 index 0000000..df5c859 --- /dev/null +++ b/crates/runtime/src/wasm.rs @@ -0,0 +1,428 @@ +use crate::{Sender, Session, authorized}; +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; +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 open_tab(url: &str) -> Result<(), JsValue>; + #[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, + }, + Current, + OpenCurrent, + OpenManual { + input: String, + }, + Read { + session: String, + target: Target, + }, + Timeline { + session: String, + target: Target, + cursor: Option, + }, + OpenPost { + session: String, + id: 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>) -> 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()) { + return Err(Error::Expired); + } + Ok(s) +} +async fn create_reader( + sender: &Sender, + target: Target, + is_restricted: bool, + expires: Option, +) -> 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, + restricted: is_restricted, + expires: expires.unwrap_or(now() + 10. * 60. * 1000.), + tab_id: sender.tab_id.unwrap_or(-1), + allowed_posts: vec![], + }; + session_set(&session_key(&token)?, js(&s)) + .await + .map_err(|_| Error::Storage)?; + let target = serde_json::to_string(&s.target).map_err(|_| Error::InvalidTarget)?; + let mut url = url::Url::parse(&format!("{}reader.html", sender.extension_origin)) + .map_err(|_| Error::Unauthorized)?; + url.query_pairs_mut() + .append_pair("session", &token) + .append_pair("target", &target); + open_tab(url.as_ref()) + .await + .map_err(|_| Error::Unavailable)?; + Ok(json!({"opened":true})) +} +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> { + if COOLDOWN.with(|v| v.get() > now()) { + return Err(Error::RateLimited); + } + let endpoint = provider::endpoint(target, timeline, cursor)?; + 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_profile(target: &Target) -> Result { + let Target::Profile { handle } = target else { + return Err(Error::InvalidTarget); + }; + let (h, b) = fetch(target, false, None).await?; + provider::parse_profile(h, &b, handle) +} +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 = provider::parse_timeline(h, &b)?; + 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 { + 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.url)?; + Ok(json!({"eligible":restricted(&target,¬ices),"target":target})) + } + Request::OpenDetected { notices } => { + let target = Target::parse(&sender.url)?; + if !restricted(&target, ¬ices) { + return Err(Error::Unauthorized); + } + create_reader(&sender, target, true, None).await + } + Request::OpenManual { input } => { + create_reader(&sender, Target::parse(&input)?, false, None).await + } + 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 { + create_reader( + &Sender { + tab_id: Some(c.tab_id), + ..sender + }, + target, + is_restricted, + None, + ) + .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)).await?; + match &target { + Target::Post { .. } => { + let post = get_post(&target).await?; + let record_error = if s.restricted { + limitless_storage::record(Some(("post", &post.id)), None, false) + .await + .err() + .map(|e| e.to_string()) + } else { + None + }; + Ok(json!({"post":post,"restricted":s.restricted,"record_error":record_error})) + } + Target::Profile { handle } => { + let profile = get_profile(&target).await; + let feed = timeline(&session, &mut s, None).await; + if profile.is_err() && feed.is_err() { + return Err(profile.unwrap_err()); + } + let 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 == *handle) + .map(|p| p.author.clone()) + }); + 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); + Ok( + json!({"profile":profile,"timeline":feed.ok(),"profile_error":profile_error,"timeline_error":feed_error,"restricted":s.restricted,"record_error":record_error}), + ) + } + } + } + Request::Timeline { + session, + target, + cursor, + } => { + let mut s = load_session(&session, Some(&target)).await?; + Ok(json!(timeline(&session, &mut s, cursor.as_deref()).await?)) + } + Request::OpenPost { session, id } => { + let s = load_session(&session, None).await?; + if !limitless_core::valid_id(&id) || !s.allowed_posts.contains(&id) { + return Err(Error::Unauthorized); + } + create_reader(&sender, Target::Post { id }, s.restricted, Some(s.expires)).await + } + } +} +#[wasm_bindgen] +pub async fn dispatch(message: JsValue, sender: JsValue) -> JsValue { + 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(),"error":error}), + }) +} diff --git a/crates/runtime/tests/security.rs b/crates/runtime/tests/security.rs new file mode 100644 index 0000000..beae745 --- /dev/null +++ b/crates/runtime/tests/security.rs @@ -0,0 +1,46 @@ +use limitless_core::Target; +use limitless_runtime::{Sender, Session, authorized}; +fn sender(url: &str) -> Sender { + Sender { + id: "abc".into(), + url: url.into(), + 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(), + restricted: true, + expires: 10000., + tab_id: 4, + allowed_posts: vec![], + }; + assert!(s.valid(&target, 9999.)); + assert!(!s.valid(&target, 10000.)); + assert!(!s.valid(&Target::parse("@other").unwrap(), 9999.)); +} 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..e79165b --- /dev/null +++ b/crates/storage/src/wasm.rs @@ -0,0 +1,89 @@ +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 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 { + if 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)?; + 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..a98a7b3 --- /dev/null +++ b/crates/ui/src/app.rs @@ -0,0 +1,63 @@ +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 open_options(); +} +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"

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

"Public data via ""FxTwitter"". Availability can change."

} +} +#[wasm_bindgen(start)] +pub fn start() { + let path = web_sys::window() + .and_then(|w| w.location().pathname().ok()) + .unwrap_or_default(); + let body = web_sys::window() + .unwrap() + .document() + .unwrap() + .body() + .unwrap(); + if path.ends_with("reader.html") { + body.set_class_name("reader-page"); + leptos::mount::mount_to_body(|| view! {}); + } else if path.ends_with("options.html") { + body.set_class_name("options-page"); + leptos::mount::mount_to_body(|| view! {}); + } else { + body.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..15e77b9 --- /dev/null +++ b/crates/ui/src/lib.rs @@ -0,0 +1,10 @@ +#[cfg(target_arch = "wasm32")] +mod app; +#[cfg(target_arch = "wasm32")] +mod options; +#[cfg(target_arch = "wasm32")] +mod popup; +#[cfg(target_arch = "wasm32")] +mod reader; +#[cfg(target_arch = "wasm32")] +mod record; diff --git a/crates/ui/src/options.rs b/crates/ui/src/options.rs new file mode 100644 index 0000000..cb263e3 --- /dev/null +++ b/crates/ui/src/options.rs @@ -0,0 +1,11 @@ +use crate::app::{Footer, Header}; +use leptos::prelude::*; +#[component] +pub fn Options() -> impl IntoView { + view! {

"YOUR DEVICE. YOUR RECORD."

"A little less friction."

"A focused reader, with a record you control."

+

"What The Record counts"

"Distinct restricted profiles and posts you deliberately open after LimitlessX detects a regional restriction on X. Manual lookups and automatically loaded timelines do not add to the counters."

"Reopening the same content never increases its unique count. Turning recording off keeps existing counts. Clear record removes the stored ledger and starts a new record."

+

"What stays here"

"The Record lives in this browser’s IndexedDB. It stores per-install salted hashes of content identifiers, two counters, and your recording preference. It does not store the text of what you read. Uninstalling the extension removes its local data."

+

"What leaves your device"

"Only after you choose to open content, LimitlessX requests public data from api.fxtwitter.com. FxTwitter receives the requested account or post identifier and your network address. Loading images or videos connects to X’s public media hosts. These services have their own policies."

"Requests omit credentials. LimitlessX does not use your X cookies, collect passwords, run analytics, or operate a backend. It makes no anonymity promise."

+

"Availability has limits"

"FxTwitter may return incomplete, stale, or unavailable data. An unavailable response does not prove regional withholding. Protected, suspended and deleted content are separate states. LimitlessX cannot unlock accounts or guarantee access."

+

"Open source"

"Source, issues and releases ↗"" · MIT license · Version 0.1.0"

} +} diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs new file mode 100644 index 0000000..d865e27 --- /dev/null +++ b/crates/ui/src/popup.rs @@ -0,0 +1,48 @@ +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); + 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! {

"PUBLIC CONTENT, WHEN AVAILABLE"

"No VPN."
"No proxy setup."
"No DPI hacks."

"An alternative way to 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!{})} +

"When you open content, its identifier is sent to FxTwitter. Private content is unavailable."

+
} +} diff --git a/crates/ui/src/reader.rs b/crates/ui/src/reader.rs new file mode 100644 index 0000000..24e46d7 --- /dev/null +++ b/crates/ui/src/reader.rs @@ -0,0 +1,133 @@ +use crate::app::{Attribution, Footer, Header, call}; +use leptos::prelude::*; +use limitless_core::{Post, Profile, Target, Timeline}; +use serde::Deserialize; +use serde_json::json; +#[derive(Clone, Deserialize)] +struct Content { + profile: Option, + post: Option, + timeline: Option, + profile_error: Option, + timeline_error: Option, + record_error: Option, + restricted: bool, +} +fn parameters() -> Result<(String, Target), String> { + let query = web_sys::window() + .unwrap() + .location() + .search() + .map_err(|_| "Could not read this address.")?; + let q = web_sys::UrlSearchParams::new_with_str(&query) + .map_err(|_| "Could not read this address.")?; + let token = q + .get("session") + .ok_or("Open a profile or post from the extension to start reading.")?; + let target: Target = serde_json::from_str( + &q.get("target") + .ok_or("This reader address is incomplete.")?, + ) + .map_err(|_| "This reader address is invalid.")?; + if !target.is_valid() { + return Err("This reader address is invalid.".into()); + } + Ok((token, target)) +} +#[component] +fn ProfileCard(profile: Profile) -> impl IntoView { + view! {
{profile.avatar.map(|src|view!{})}

{profile.name}

{format!("@{}",profile.handle)}

{profile.biography.map(|bio|view!{

{bio}

})}
} +} +#[component] +fn PostCard(post: Post, session: String, #[prop(default = false)] detailed: bool) -> impl IntoView { + let busy = RwSignal::new(false); + let error = RwSignal::new(None::); + let id = post.id.clone(); + let target_url = format!("https://x.com/{}/status/{}", post.author.handle, post.id); + view! {
{post.author.name}{format!(" @{}",post.author.handle)}
{post.published.map(|date|view!{})}

{post.text}

+
{post.media.into_iter().map(|m|if m.kind=="photo"{view!{m.alt.unwrap_or_else(||"Image}.into_any()}else{view!{
+
"Original on X ↗"{(!detailed).then(||view!{})}
+ {move ||error.get().map(|e|view!{})} +
} +} +#[component] +pub fn Reader() -> impl IntoView { + let params = parameters(); + let content = RwSignal::new(None::); + let error = RwSignal::new(None::); + let busy = RwSignal::new(true); + let posts = RwSignal::new(Vec::::new()); + let cursor = RwSignal::new(None::); + let params = StoredValue::new(params); + let load = move || { + let Ok((session, target)) = params.get_value() else { + error.set(params.get_value().err()); + busy.set(false); + return; + }; + busy.set(true); + error.set(None); + leptos::task::spawn_local(async move { + match call(json!({"op":"read","session":session,"target":target})).await { + Ok(v) => match serde_json::from_value::(v) { + Ok(c) => { + if let Some(t) = &c.timeline { + posts.set(t.posts.clone()); + cursor.set(t.next_cursor.clone()); + } + content.set(Some(c)); + } + Err(_) => error.set(Some("The reader received an unexpected response.".into())), + }, + Err(e) => error.set(Some(e)), + } + busy.set(false); + }); + }; + load(); + let more = move |_| { + let Ok((session, target)) = params.get_value() else { + return; + }; + busy.set(true); + error.set(None); + let next = cursor.get_untracked(); + leptos::task::spawn_local(async move { + match call(json!({"op":"timeline","session":session,"target":target,"cursor":next})) + .await + { + Ok(v) => match serde_json::from_value::(v) { + Ok(t) => { + posts.update(|posts| { + for post in t.posts { + if !posts.iter().any(|p| p.id == post.id) { + posts.push(post); + } + } + }); + cursor.set(t.next_cursor.filter(|c| Some(c) != next.as_ref())); + } + Err(_) => error.set(Some("Could not read the next page.".into())), + }, + Err(e) => error.set(Some(e)), + } + busy.set(false); + }); + }; + view! {

"ALTERNATIVE SOURCE"

{params.get_value().ok().map(|(_,t)|t.label()).unwrap_or_else(||"Public reader".into())}

"via FxTwitter"

{move ||if content.get().is_some_and(|c|c.restricted){"Opened from a regional restriction notice on X."}else{"Manual public lookup · This view does not add to The Record."}}

+ {move ||content.get().map(|c|view!{ + {c.profile.map(|profile|view!{})} + {c.profile_error.map(|e|view!{

{format!("Profile lookup: {e}")}

})} + {c.timeline_error.map(|e|view!{

{format!("Posts: {e}")}

})} + {c.record_error.map(|e|view!{

{format!("Content opened, but The Record could not update: {e}")}

})} + {c.post.map(|post|view!{})} + {c.timeline.is_some().then(||view!{

"Public posts"

"Only posts you open count toward The Record."
})} + })} +
}/>
+ {move ||(content.get().is_some_and(|c|c.timeline.is_some()) && posts.get().is_empty() && !busy.get()).then(||view!{

"No public posts were returned by the provider."

})} + {move ||cursor.get().map(|_|view!{})} + {move ||busy.get().then(||view!{

"Loading public content…"

})} + {move ||error.get().map(|e|view!{})} +
+
} +} diff --git a/crates/ui/src/record.rs b/crates/ui/src/record.rs new file mode 100644 index 0000000..dddba6a --- /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(#[prop(default = false)] controls: bool) -> 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}"restricted profiles"
"viewed"
{r.posts}"posts"
"viewed"
+

{if r.enabled{"Stored on this device · Since installation"}else{"Recording disabled · Stored on this device"}}

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

"Loading local record…"

})}else{None}} + {controls.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..67348e0 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde_json.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000..ef3def3 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,101 @@ +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()); + } + 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("extension/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), + )?; + } + 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("test") => run(Command::new("cargo").args(["test", "--workspace", "--locked"])), + _ => Err("Usage: cargo xtask build | test".into()), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ba14b87 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,19 @@ +# 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 deliberate-view decisions. `storage` owns atomic IndexedDB transactions. `ui` renders popup, settings and reader using Leptos. `xtask` builds browser packages. + +## Browser boundary + +Packaged JavaScript 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 offers a button only when Rust identifies a supported notice with the official withholding-policy link. It never makes provider requests automatically. + +The reader uses an extension tab. No executable resources are web-accessible and there is no cross-origin postMessage surface. This is the packaged extension-tab fallback allowed by the specification; embedded rendering has not been claimed or shipped. The two runtime smoke tests verify packaged WASM in Chromium service workers and Firefox event pages. + +## Deliberate views + +Opening from a validated X notice creates a target-bound, ten-minute session. A manual lookup creates an ineligible session. 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 deliberately; only IDs actually returned in that reader's timeline can create a child reader session. + +## 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. Leptos escapes all provider text. + +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. diff --git a/docs/brand.md b/docs/brand.md new file mode 100644 index 0000000..7821293 --- /dev/null +++ b/docs/brand.md @@ -0,0 +1,9 @@ +# LimitlessX brand + +The monochrome mark combines two interrupted loop ends with a continuous X intersection. The SVG master is `extension/static/brand/mark.svg`; square raster icons derive from this geometry for browser packaging. Use black on white or white on black. No glow, animation, shield or lock. + +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. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..9962331 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,16 @@ +# Compatibility + +| Browser | Evidence | Status | +|---|---|---| +| Chromium (Playwright full browser) | tests/browser/smoke.py: real MV3 worker/WASM response and Leptos popup | Runtime proof passed; complete flow verification pending | +| Firefox (Playwright Firefox binary through geckodriver) | tests/browser/firefox_smoke.py: installed MV3 package, event-page WASM response and Leptos popup | Runtime proof passed; complete flow verification pending | +| Chrome, Brave, Opera, Edge, Vivaldi | Same Chromium package intended | Not individually verified | +| Zen, Floorp, LibreWolf, Waterfox | Same Firefox package intended | Not individually verified | + +The reader opens in an extension tab. No iframe or web-accessible application resources are required. Firefox MV3 uses a classic bootstrap importing the same background module as Chromium; Chromium uses a module service worker. Both register the message listener before awaiting WASM initialization. + +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/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..c52da32 --- /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 you deliberately open a profile or post, the extension sends its public identifier to api.fxtwitter.com. Opening an account also requests its public timeline. 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 deliberately opened eligible profiles/posts. 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..2820043 --- /dev/null +++ b/docs/product-spec.md @@ -0,0 +1,605 @@ +" +# 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..f48b960 --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,17 @@ +# 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. 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/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..34d50f6 --- /dev/null +++ b/docs/verification/release-audit.md @@ -0,0 +1,19 @@ +# Release audit — development in progress + +A green native test run is not a release decision. Each gate below needs its own evidence. + +| Specification requirement | Evidence so far | Remaining | +|---|---|---| +| P0 FxTwitter withheld capability | Live historically withheld timeline returns 20 posts and pagination; provenance in feasibility.json | Current X notice against real representative accounts/posts, reliable protected/suspended/deleted comparisons | +| P1 Rust/WASM runtime | Real packaged Leptos popup and background ping passed Chromium and Firefox | Full messaging lifecycle/restart tests | +| P2 original brand | SVG master and 16/32/48/128 PNG assets, brand.md | Visual QA of all sizes/themes | +| P3 restriction detection | Native notice/target tests and bounded DOM adapter | Real X notice and navigation/false-positive browser tests | +| P4 profile reader | Typed profile/timeline adapter and Leptos reader | Browser error, pagination and profile identity tests | +| P5 posts and media | Typed post adapter; text escaping and media host allowlist | Actual browser image/video/error tests | +| P6 popup and Record | Leptos pages and Rust IndexedDB transactions | Real concurrent, restart, disable/reset tests | +| P7 security/performance | Sender/expiry tests; credential omission, streaming limits, packaged code | Network evidence, malicious-message tests, measured startup/idle baseline | +| P8 cross-browser compatibility | MV3 runtime smoke on full Chromium and Firefox | Full flow on both; individual variants labeled untested | +| P9 landing/store | Repo README, privacy and compatibility docs | Landing page, source archives, store listings and submission | +| P10 public v1 | No public claim | All release gates and actual store publication | + +No store URLs or install counts are invented. Publishing signed store packages requires store account access and provider feasibility evidence; these remain external release gates. diff --git a/extension/adapters/background-firefox.js b/extension/adapters/background-firefox.js new file mode 100644 index 0000000..3cc372a --- /dev/null +++ b/extension/adapters/background-firefox.js @@ -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.js b/extension/adapters/background.js new file mode 100644 index 0000000..132acdb --- /dev/null +++ b/extension/adapters/background.js @@ -0,0 +1,12 @@ +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) => { + ready.then(() => dispatch(message, { + id: sender.id ?? '', url: sender.url ?? '', 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.js b/extension/adapters/bridge.js new file mode 100644 index 0000000..87d0cfa --- /dev/null +++ b/extension/adapters/bridge.js @@ -0,0 +1,19 @@ +// Browser interoperability only. Validation and all application policy live in Rust. +const api = globalThis.browser ?? chrome; +globalThis.limitlessBridge = { + 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), + open_tab: async url => { await api.tabs.create({ url }); }, + active_context: async () => { + const [tab] = await api.tabs.query({ active: true, currentWindow: true }); + if (!tab) return null; + let context = { url: tab.url ?? '', notices: [], tab_id: tab.id }; + try { context = { ...context, ...await api.tabs.sendMessage(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.js b/extension/adapters/content.js new file mode 100644 index 0000000..66f242a --- /dev/null +++ b/extension/adapters/content.js @@ -0,0 +1,44 @@ +// Observe a bounded set of X notice elements. Rust interprets their contents. +(() => { + const api = globalThis.browser ?? chrome; + let timer, generation = 0, button, lastUrl = location.href; + const snapshot = () => ({ + url: location.href, + notices: [...document.querySelectorAll('[data-testid="primaryColumn"] [data-testid="emptyState"], [data-testid="primaryColumn"] [data-testid="tweetTombstone"]')] + .slice(0, 8).map(node => ({ + text: (node.innerText ?? '').slice(0, 1600), + policy_url: node.querySelector('a[href*="post-withheld-by-country"]')?.href ?? '', + })), + }); + const removeButton = () => { button?.remove(); button = undefined; }; + const inspect = async () => { + const attempt = ++generation, url = location.href, context = snapshot(); + if (url !== lastUrl) { removeButton(); lastUrl = url; } + if (!context.notices.length) { removeButton(); return; } + let response; + try { response = await api.runtime.sendMessage({ op: 'inspect', notices: context.notices }); } catch { removeButton(); return; } + if (attempt !== generation || url !== location.href) return; + if (!response?.ok || !response.data.eligible) { removeButton(); return; } + if (button?.isConnected) return; + button = document.createElement('button'); + button.className = 'limitlessx-action'; button.type = 'button'; button.textContent = 'View with LimitlessX'; + button.addEventListener('click', async () => { + const activeButton = button; if (!activeButton) return; + activeButton.disabled = true; + try { + const result = await api.runtime.sendMessage({ op: 'open_detected', notices: snapshot().notices }); + if (!result?.ok) activeButton.textContent = result?.message ?? 'Could not open LimitlessX. Try again.'; + } catch { activeButton.textContent = 'Could not open LimitlessX. Try again.'; } + finally { activeButton.disabled = false; } + }); + document.querySelector('[data-testid="primaryColumn"] [data-testid="emptyState"], [data-testid="primaryColumn"] [data-testid="tweetTombstone"]')?.append(button); + }; + const schedule = () => { clearTimeout(timer); timer = setTimeout(inspect, 250); }; + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, subtree: true }); + addEventListener('popstate', schedule); addEventListener('pageshow', schedule); + api.runtime.onMessage.addListener((message, sender, respond) => { + if (sender.id === api.runtime.id && message?.op === 'snapshot') respond(snapshot()); + }); + schedule(); +})(); diff --git a/extension/adapters/ui.js b/extension/adapters/ui.js new file mode 100644 index 0000000..3e6d3f3 --- /dev/null +++ b/extension/adapters/ui.js @@ -0,0 +1,12 @@ +import init from '../pkg/ui/limitless_ui.js'; +const api = globalThis.browser ?? chrome; +globalThis.limitlessUI = { + send: message => api.runtime.sendMessage(message), + open_options: () => api.runtime.openOptionsPage(), +}; +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..6073d12 --- /dev/null +++ b/extension/manifests/chromium.json @@ -0,0 +1,57 @@ +{ + "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" + } + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "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/content.js" + ], + "css": [ + "styles/content.css" + ], + "run_at": "document_idle" + } + ] +} diff --git a/extension/manifests/firefox.json b/extension/manifests/firefox.json new file mode 100644 index 0000000..a6ba60b --- /dev/null +++ b/extension/manifests/firefox.json @@ -0,0 +1,69 @@ +{ + "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" + } + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "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/content.js" + ], + "css": [ + "styles/content.css" + ], + "run_at": "document_idle" + } + ] +} diff --git a/extension/static/brand/icon-128.png b/extension/static/brand/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..d1a24b5c931aa115eade9fbad64e9667af14ef2e GIT binary patch literal 3134 zcmV-E48ik>P)Kdz8VjA>}@FyRjg!NdknqV37y6-7M?Qd78y{ke$1 zmUid8KbFPqcDLPq=bd?Hy7Nh%gq>Yxzwh&W=bf2%-goOm4HJRoz+&L1z)YYB7zgAk z`i^WR3|s-afHOb~&=qm$`Ghmb#s zZHRZn4&cXjEAp_NKLG`?7qK3oh-mlVJ-HkG{0O)S9ETrzfc3ZD@W>9f0Ri~I0Ke6z z#X|$*feMJ;22LV+VUT!B=rOwa1X$`XiK0mi^2RyQpdwfLal6k|w*-4*|=;K0V`2Bw7&!10uc{wE|B?N;(3JRhlk2yIx zghC-MUAn~AUw=(=b2H7&&2)8jsg&T6AxfS(FIccZ?BBm%bar-%OgVMxlz8c-m&A-2 zGju;U&&DvfgGGxLiTB@sKjWe&!!QhS1#w_=z+%HHn8FdPsM2GAh1d7{V}&+l7yRty`Z`11ws!NL;vZ!IHwqV;F`w zc<`W@JbAM2B&AjSc-@Jw9c+Tk{=;LBJtnSSzixT);|YaA;<@LZ)19cKv1!vL5s5@b zY=dDKV(Zqe+Vz?;z`AwoM1Oz34JA)bLqmhe&(GJLu-lP%nxOC}K(bv~! zVR_Tz%$YM{)~s2&la_JuV>r+RBY$Yxv}sbecUIBU(<4@_SmCw+BsiCj0PdxeLleQ-;-E;m(IOv*S_z!H^vPD6;tYKhaKy2K&(Y9onJ@ONANKcUZ z&HVg)(b3VNtie`bR}xhh-){Y>B5`5JAgZdWm@r|&a39f*&*x*`zI{|yR_d1GT9P^= zFE7vWM}Ita97&_`6p)k36>Hb7HGTBAwY72JzyUt|@Ix+LxdijJefSanp5)@y*I2Dt04 zyG#xGy!z^^+7c>+`lMQ^)e60)4e~9c zkw`?$ojcbt2{xNlyNa($uQCHPHa1E%;K-39j!U#`Qtd9jS-naPaPi_rDe)UNY;fe! zmT6LT6<=1bQUm1X=8A!V0V(0rr%yK}=GkYTRWi<=jC9A|RgLxArB}HDrc9Y4CHU&q zt4ajv>Quy;n-;r=NbR zRG6J6P+k1^NuetDX^R2o&YdeIQe_WdrwObqluJx7c*CSglcWUp_V!xhP^(Q~b%9)9 zg25ZcjvXs4aKeNMt}}rO?7X{!pI+XPN} z$YdCXsHmt=YXI57AR&aPudlb<06I23WFWi77FUKKkgWF@e?N z@hbK2+OP&w~NBULf4zXZ3|B#k%%ZOD>H8ZKy2T>-ISQahYzPc zKhQ}@jA8fe998;TT3SR-PL6p41OfqZ<;oRPVs`G_>AD2Q&@o3(RU2M>@kR3n0K^+_ zyy1!@y8RTOx-C6DJ!1U$@#YN>2n0lLZ?B`1==O_1tJ?JX>#v(P03fQWsvMg{x8DX1 z7dvZIXU9=>C=u&qT2_8Mhc(LCk`JzY)bR??c2>801#zmWy*dT z!)ZgQI`pR-g@uKpy}eyp?-yTuG18q_`eJtN+U1BOy8U5Lrm=G6N>h4A7#B!iNN#Sf zXl`!Snnc~jH>+PZ-hTUSX|1)jwQ39ih~na6Wj`F@wiP>x*lB_}bLL1d*tu}wf;t00 z^f^H|9JV!ySv+t5cGTC`OY59Hd$u|Q0HUIz!nP!4`MmwxQC3zat#O!NQa%f^^UMI`yi! zCYU*Mrqp*23=E{2C~B<%?C1&HG||MYR@rr%?d|QU_G!HVtW2sKr<$43eDvs1sqcL6 zz4!DQz>1{0d9taD_IdN>NuQpotE-dlbJ)KalI_&h)v;yE7K9MW_nqlqI`R2@?A^N; zzhACo&CSgz-*e^BAH$9ZFiz+RUVH5|>F)@K!^8c-O?MkW2=|*{+qP{fzc(*0Pt?}d zn%e)-qeq8pn>_~bOpuzct}b!lzya~hGtY>H3m1xDFeu8)%f&0Nydv7#+Dz|%{rdIJ zFaS9JK3k`LE9mIx5P5ld&M|=36WB`{f4;|l16Y|vw=YIj$>qzJ#kg_foM`|nl4$o_ z{S6y7i2nY5W%^aKdGqFU+M&+?pzfTi>RCXhv3m7tapT4f1?5spV`F2wKhB}o05G&C z$aG4Wj+MLdv3&V*(cRr`MIEK+?CccNrcFy%zkUP6n;_G9`?`xCi(oJ)JyAm?;c!?y z{P4q>XxCr-coSqYE!-)^k41idzG!G@u(8~Oxq0)ZcVEGAE$EDjz#Xv?kQS6_W49(?dYS*`k$ zm5B-5Q2h8PEiDxvee{tfg*OaCy!qyv>HZj$yA2RCft!jSALZrc;+=QiQ8tWx{P=M* zN4+}@FdCw`xLEAiu|u3Zc{1aofBWsXV&A@f;{N;ZSEk1obAgBb6crUwT3X8D#fvE} zE@tY~sT33x5Q#(p=a~+qse#cAO8X7>r~=lI|7&(TV?P! zok~2k@GqbvW`KIU+rez39^j5!FT%h$V5v4a9(HmK*aX~&Uud=m=+>jh!)E@KA@nP^aKs@JwUf>rNi}J9C2=Fw}l=wy}0|0zX^!D)Q7K!q( z2LV(94YE6U88Fa)@#011F#v2HTGGsU8o2I&26>TwTmgQSteCPa06up>!@L+dP64x$ zm(d(|0=t1e2Q<=)q46Ej`~2Kg1yn^4_&d?7OWlq3B08#93rrlYXjU)-s3AIbpgRfK z76#GVx~)LrNQFv8vXMa9{X77yAUd(+KHw)nfaqz%L!O&NkMK^Q4frq7X?(|l4zoM| YKNxyKbI^L_1^@s607*qoM6N<$f~OiCga7~l literal 0 HcmV?d00001 diff --git a/extension/static/brand/icon-16.png b/extension/static/brand/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..6d60a2e8340fa57e28c56423adfe67f747f47906 GIT binary patch literal 430 zcmV;f0a5;mP)l%Qr>xiPr zn({mcpznK@%jF!fUatX2(-g;XD2l?GjWHNw==+{)6-9yLIIn>1b_>9MzqfI3Ay>t5 zZ2L2a@B577NL5w1u4^G>S=y;_9KV5QS$0h!vMigC-;3?KE>%@Ajw8PBPk>u;S(Y~T zZnv|bTgc&X0ARD(On~=d-=?`j(liBNwOYLb?#)S(*nSj6bX^BPUDpi5K-;!>o;L?b zVzF3|B+2}#B!VE|cs$ZH4d?TjFbwS_KL5{GevtGa>8t*&eoE6yK51Ifk52xrX^S8K Y0`h3VRV0=e5C8xG07*qoM6N<$g1?~3-2eap literal 0 HcmV?d00001 diff --git a/extension/static/brand/icon-32.png b/extension/static/brand/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c60b889968eb3f25d37cd78fa632b7b4923ca3e3 GIT binary patch literal 719 zcmV;=0x{R}o=63dMfa?pG+xh+lfaakEmx#Al0PX?2 zx)8u!=-50OjiOeo;pXNBmSthJTA|zRBAd+$ugDmKs;Xd&iOAmohkhiJNm?$K#0yQ+ zC>D$P_LMP3rfCupk!hO57!#R;hl}BG$ThdaFbtAq`K-Aef)Jp5KJR*PyJh0#IDV0ibtMY>2=>&HHG#-zg2XtLOyS2v)`$SS%KWC*7|?Fa2yb zBT16n0P%R7cDtQx7ZgSDga|tAUJ&(q{q!8D*X#dOk)Pg9MNw$G-8$OcZb$KW+zlW} z63u2a?xO!Y?WI!5r7f4sZU6uz%W|NpE))vF5RpiPEX#6qnx>rs1g%u`ptAG$xSvcW z-T*;+9Ry-JojUhNqY)n<=(LMKq|<5I@AuBVR;%R?5OjNadR^D4*=*8gvvGZ%%jNt5 z{7y68q|35QhGBT_+;I#BgVS@s55oQyi2T0CVlnFVdR+6xVnK;SLLA^2MDQOD0iaAK zL!C~COw%OGvS>b^Q?**9Xf*25T>b183UMY?I|G|8(9RXkgVBkH#j{qi>Zp#bRXYXH&KzG49J)&0HzW{~@ z0BivG;;Dw_BkB_Xe>3Qo%82ja09^Sv=fVZ$Dli(WEvYA!^+AEW@l#+2n4|M zJkHP0ae8`+ot+(2Dizzkd%a%V-Q58I+U>UGJP#Jfz`y`)Y-~`o*(5C$3Iz&j^nK6`tXKf7~0<6)->L)R;xwHWYUs(Ns=DqvH|hoO)8Z#Y`$N) zT#i_lwQSxdME78Lc$jW)Z!G~35#{rF-OhbJAJyx1%{=LJ+7y7Rw-DCW*5LJeWeeo^ z_!xp9KoA6+oSewc_cT90kG;J;a2%(4O}pJjwOUm@4*=zIS@XP-2mn>9RoQ5qot+WS z^M-D}-ZbDijzm$^y_1q8Q8XGgH-+48H{IXg%aScDESQ?ZY7j=vTZ8~jPEIN&8W|Ze zy$~Nmm^E(!0u%~`6qESbcga8Es0mTI2aM*H5)KmX7)5>E2 zqI+ft?a-G*78iQrhvm2Of`yuCA_RL7h&A;_UXsV-Q3haHmP)0Psm`BslgehZiaC~+mXrYi zP%s#zMx$Y<^XJ~Ac%G+PttN}#-``gU06>vQM7J*MnHNQo=H}*XLu4`;S!|=x&<4<3 zNRlK`u~^hJ&v6{3(`nUfj6#INVa3?N!9i^RJqSJXMyHq_gx~L1j2j;x*9R~Qp;YJX zLX3`%D#lDqOc(<&3Zb=0*@0MGT$Ekg>2&%(A?O1<#U^D1!tHibxm=cASE*F=7x7b0 z12cI_r4k8(K&z{(s(Vr@bD13 zySu2>YKn0Tx<6%pYTJP``o{&}52sz&(1i=YPfokAp|=cx5dgmfa64?qioWh0dLimitlessX diff --git a/extension/static/options.html b/extension/static/options.html new file mode 100644 index 0000000..2c171d1 --- /dev/null +++ b/extension/static/options.html @@ -0,0 +1,2 @@ + +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/reader.html b/extension/static/reader.html new file mode 100644 index 0000000..2c171d1 --- /dev/null +++ b/extension/static/reader.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..d9a2269 --- /dev/null +++ b/extension/static/styles/app.css @@ -0,0 +1 @@ +:root{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{--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}} diff --git a/extension/static/styles/content.css b/extension/static/styles/content.css new file mode 100644 index 0000000..93a08ef --- /dev/null +++ b/extension/static/styles/content.css @@ -0,0 +1 @@ +.limitlessx-action {display:block;margin:16px 0;padding:12px 16px;border:1px solid #888;border-radius:8px;background:#000;color:#fff;font:600 14px Helvetica,Arial,sans-serif;cursor:pointer}.limitlessx-action:focus-visible{outline:3px solid #888;outline-offset:4px}.limitlessx-action:disabled{opacity:.6;cursor:wait} 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/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/contracts.py b/tests/browser/contracts.py new file mode 100644 index 0000000..8e8c360 --- /dev/null +++ b/tests/browser/contracts.py @@ -0,0 +1,85 @@ +import copy,json,pathlib,tempfile,time +from urllib.parse import urlsplit,parse_qs +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-contracts-') as profile: + extension=ROOT/'dist/chromium' + def launch():return p.chromium.launch_persistent_context(profile,channel='chromium',headless=True,args=[f'--disable-extensions-except={extension}',f'--load-extension={extension}','--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]) + errors=[];ctx.on('weberror',lambda e:errors.append(str(e.error))) + ui=ctx.new_page();ui.goto(origin+'/popup.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + def send(m):return ui.evaluate('(m)=>chrome.runtime.sendMessage(m)',m) + def open_manual(target): + with ctx.expect_page() as opened:assert send({'op':'open_manual','input':target})['ok'] + reader=opened.value;reader.wait_for_load_state();return reader + calls=[]; mode={'value':'success'} + def provider(route): + calls.append(route.request.url) + assert not route.request.headers.get('cookie'),'credentials leaked' + url=route.request.url + if mode['value']=='429':route.fulfill(status=429,json={'code':429},headers={'Retry-After':'1'});return + if mode['value']=='private':route.fulfill(json={'code':200,'user':{**USER,'protected':True}});return + if mode['value']=='malformed':route.fulfill(body='oops');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:route.fulfill(json={'code':200,'status':POST}) + ctx.route('https://api.fxtwitter.com/**',provider) + assert not send({'op':'open_manual','input':'https://x.com.evil.test/jack'})['ok'];assert not calls + send({'op':'clear'});reader=open_manual('@jack');reader.get_by_role('heading',name='jack',exact=True).wait_for() + assert reader.locator('.biography').inner_text()==USER['description'];assert reader.evaluate('globalThis.injected') is None + assert send({'op':'record'})['data']['profiles']==0 + reader.get_by_role('button',name='Load more posts').click();reader.get_by_text('second page',exact=True).wait_for();assert reader.locator('.post-card').count()==2 + token=parse_qs(urlsplit(reader.url).query)['session'][0] + assert not send({'op':'open_post','session':token,'id':'999'})['ok'] + assert send({'op':'record'})['data']['posts']==0 + report['manual_profile_pagination_xss']='passed' + # Suspended text and user-authored tweets must not produce a restriction action. + ctx.route('https://x.com/**',lambda r:r.fulfill(body=html('Account suspended'),content_type='text/html')) + x=ctx.new_page();x.goto('https://x.com/jack/status/20');x.wait_for_timeout(600);assert x.get_by_role('button',name='View with LimitlessX').count()==0 + assert x.evaluate('typeof chrome.runtime?.sendMessage')=='undefined' + # Install an eligible notice, then navigate without replacing its DOM node. + x.locator('[data-testid="emptyState"] span').evaluate('(n,text)=>n.textContent=text',NOTICE) + x.get_by_role('button',name='View with LimitlessX').wait_for() + x.evaluate("history.pushState({},'', '/jack/status/21');document.body.append(document.createElement('div'))") + x.wait_for_timeout(600) + assert x.get_by_role('button',name='View with LimitlessX').count()==0,'stale notice applied to a new route' + report['stale_notice_navigation']='passed' + # A new notice node is eligible on the new route. + x.locator('[data-testid="emptyState"]').evaluate('(n)=>n.replaceWith(n.cloneNode(true))') + x.get_by_role('button',name='View with LimitlessX').wait_for() + # Restarts: stop workers, then verify reader session and durable record remain. + cdp=ctx.new_cdp_session(ui);cdp.send('ServiceWorker.enable');cdp.send('ServiceWorker.stopAllWorkers') + assert send({'op':'record'})['ok'] + assert send({'op':'timeline','session':token,'target':{'kind':'profile','handle':'jack'},'cursor':None})['ok'] + report['worker_restart']='passed' + # Expired sessions must be rejected before any network request. + 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) + previous=len(calls);assert not send({'op':'read','session':token,'target':{'kind':'profile','handle':'jack'}})['ok'];assert len(calls)==previous + report['expiry_before_fetch']='passed' + # Rate-limit cooldown suppresses repeat requests. + mode['value']='429';r=open_manual('https://x.com/jack/status/20');r.get_by_role('alert').wait_for();previous=len(calls) + r.get_by_role('button',name='Try again').click();r.get_by_role('alert').wait_for();assert len(calls)==previous + time.sleep(1.1);mode['value']='malformed';r.get_by_role('button',name='Try again').click();r.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'] + # Capture actual layouts for visual QA, including dark mode. + art=ROOT/'tests/browser/artifacts';art.mkdir(exist_ok=True) + ui.set_viewport_size({'width':384,'height':800});ui.screenshot(path=str(art/'popup-light.png'),full_page=True) + ui.emulate_media(color_scheme='dark');ui.screenshot(path=str(art/'popup-dark.png'),full_page=True) + reader.screenshot(path=str(art/'reader.png'),full_page=True) + assert not errors,errors + with launch() as ctx: + ui=ctx.new_page();ui.goto(origin+'/options.html');ui.get_by_role('heading',name='LimitlessX',exact=True).wait_for() + actual=ui.evaluate('chrome.runtime.sendMessage({op:"record"})')['data'];assert actual==saved,(actual,saved) + assert ui.evaluate('chrome.storage.session.get(null)')=={},'reader context survived full browser restart' + # Durable keys must not contain public IDs or handles. + keys=ui.evaluate('''async()=>{const db=await new Promise((res,rej)=>{const r=indexedDB.open('limitlessx');r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});return Promise.all(['profiles','posts'].map(name=>new Promise((res,rej)=>{const r=db.transaction(name).objectStore(name).getAllKeys();r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);})));}''') + assert all(len(k)==64 for store in keys for k in store),keys + report['browser_restart_and_local_storage']='passed' +print(json.dumps(report,indent=2)) diff --git a/tests/browser/firefox_smoke.py b/tests/browser/firefox_smoke.py new file mode 100644 index 0000000..d42b255 --- /dev/null +++ b/tests/browser/firefox_smoke.py @@ -0,0 +1,25 @@ +"""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=os.environ.get('FIREFOX_BINARY',str(pathlib.Path.home()/'.cache/ms-playwright/firefox-1555/firefox/firefox')) +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(os.environ.get('GECKODRIVER','/tmp/limitlessx-tools/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..bd5855e --- /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/flows.py b/tests/browser/flows.py new file mode 100644 index 0000000..fb2ea16 --- /dev/null +++ b/tests/browser/flows.py @@ -0,0 +1,36 @@ +"""Behavioral contract for the packaged extension, real IDB and real WASM. +Provider transport is intercepted; response normalization and all application logic stay real. +""" +import json,pathlib,tempfile +from urllib.parse import urlsplit,parse_qs +from playwright.sync_api import sync_playwright +ROOT=pathlib.Path(__file__).resolve().parents[2] +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." +HTML=f'''
{NOTICE}Learn more
''' +with sync_playwright() as p,tempfile.TemporaryDirectory(prefix='limitlessx-flows-') 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: + ctx.route('https://api.fxtwitter.com/**',lambda route:route.fulfill(json={'code':200,'status':POST})) + 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() + def send(message):return ui.evaluate('(m)=>chrome.runtime.sendMessage(m)',message) + assert send({'op':'clear'})['ok'] + assert send({'op':'record'})['data'].get('posts')==0 + with ctx.expect_page() as opened:send({'op':'open_manual','input':'https://x.com/jack/status/20'}) + reader=opened.value;reader.wait_for_load_state();reader.get_by_text('just setting up my twttr',exact=True).wait_for() + assert send({'op':'record'})['data']['posts']==0,'manual lookup must not count' + ctx.route('https://x.com/jack/status/20',lambda route:route.fulfill(body=HTML,content_type='text/html')) + x=ctx.new_page();x.goto('https://x.com/jack/status/20'); + with ctx.expect_page() as opened:x.get_by_role('button',name='View with LimitlessX').click() + reader=opened.value;reader.wait_for_load_state();reader.get_by_text('just setting up my twttr',exact=True).wait_for() + assert send({'op':'record'})['data']['posts']==1 + token=parse_qs(urlsplit(reader.url).query)['session'][0];target={'kind':'post','id':'20'} + results=ui.evaluate('''async ([session,target])=>Promise.all(Array.from({length:12},()=>chrome.runtime.sendMessage({op:'read',session,target})))''',[token,target]);assert all(r['ok'] for r in results),results + assert send({'op':'record'})['data']['posts']==1,'duplicate/racing views must not increment' + assert not send({'op':'read','session':token,'target':{'kind':'post','id':'21'}})['ok'],'target substitution accepted' + send({'op':'settings','enabled':False});send({'op':'clear'}) + assert send({'op':'read','session':token,'target':target})['ok'];assert send({'op':'record'})['data']['posts']==0 + send({'op':'settings','enabled':True}) + print(json.dumps({'manual_count':0,'concurrent_duplicate_count':1,'disabled_count':0,'target_substitution':'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()})) From c4376a147a037dc25ec065fb5ef01f1f4b4e7760 Mon Sep 17 00:00:00 2001 From: Onur Konuk Date: Fri, 30 Oct 2026 00:43:59 +0300 Subject: [PATCH 2/6] feat: automatically render restricted content inside X with popup-only UI --- .github/workflows/check.yml | 46 +++++ Cargo.lock | 83 ++++++++ README.md | 8 +- crates/fxtwitter/src/lib.rs | 2 +- crates/runtime/src/lib.rs | 69 +++++-- crates/runtime/src/view.rs | 287 +++++++++++++++++++++++++++ crates/runtime/src/wasm.rs | 197 ++++++++++++++---- crates/runtime/tests/security.rs | 59 ++++++ crates/runtime/tests/view.rs | 43 ++++ crates/storage/src/wasm.rs | 22 +- crates/ui/src/app.rs | 36 ++-- crates/ui/src/lib.rs | 4 - crates/ui/src/options.rs | 11 - crates/ui/src/popup.rs | 8 +- crates/ui/src/reader.rs | 133 ------------- crates/ui/src/record.rs | 6 +- crates/xtask/Cargo.toml | 1 + crates/xtask/src/main.rs | 9 +- crates/xtask/src/package.rs | 222 +++++++++++++++++++++ docs/architecture.md | 14 +- docs/brand.md | 2 +- docs/brand/user-reference.png | Bin 0 -> 216893 bytes docs/compatibility.md | 6 +- docs/implementation-direction.md | 12 ++ docs/privacy.md | 4 +- docs/product-spec.md | 2 + docs/progress.md | 15 ++ docs/verification/current-build.json | 51 +++++ docs/verification/release-audit.md | 30 +-- extension/adapters/background.js | 5 +- extension/adapters/bridge.js | 4 +- extension/adapters/content.js | 173 ++++++++++++---- extension/adapters/ui.js | 2 +- extension/manifests/chromium.json | 17 +- extension/manifests/firefox.json | 17 +- extension/static/brand/icon-128.png | Bin 3134 -> 3951 bytes extension/static/brand/icon-16.png | Bin 430 -> 604 bytes extension/static/brand/icon-32.png | Bin 719 -> 1204 bytes extension/static/brand/icon-48.png | Bin 1227 -> 1706 bytes extension/static/brand/mark.svg | 2 +- extension/static/options.html | 2 - extension/static/reader.html | 2 - extension/static/styles/app.css | 11 +- extension/static/styles/content.css | 4 +- scripts/render-icons.py | 9 + tests/browser/contracts.py | 85 -------- tests/browser/firefox_inline.py | 43 ++++ tests/browser/firefox_smoke.py | 5 +- tests/browser/fixtures.py | 2 +- tests/browser/flows.py | 36 ---- tests/browser/inline.py | 35 ++++ tests/browser/inline_contracts.py | 79 ++++++++ tests/browser/paths.py | 9 + tests/browser/requirements.txt | 2 + 54 files changed, 1479 insertions(+), 447 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 crates/runtime/src/view.rs create mode 100644 crates/runtime/tests/view.rs delete mode 100644 crates/ui/src/options.rs delete mode 100644 crates/ui/src/reader.rs create mode 100644 crates/xtask/src/package.rs create mode 100644 docs/brand/user-reference.png create mode 100644 docs/implementation-direction.md create mode 100644 docs/verification/current-build.json delete mode 100644 extension/static/options.html delete mode 100644 extension/static/reader.html create mode 100644 scripts/render-icons.py delete mode 100644 tests/browser/contracts.py create mode 100644 tests/browser/firefox_inline.py delete mode 100644 tests/browser/flows.py create mode 100644 tests/browser/inline.py create mode 100644 tests/browser/inline_contracts.py create mode 100644 tests/browser/paths.py create mode 100644 tests/browser/requirements.txt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b48e8bf --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,46 @@ +name: Verify and package +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 + - 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/Cargo.lock b/Cargo.lock index 1ac8ff6..9d93422 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,15 @@ 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" @@ -235,6 +244,15 @@ 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" @@ -256,6 +274,17 @@ dependencies = [ "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" @@ -331,6 +360,15 @@ dependencies = [ "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" @@ -907,6 +945,12 @@ 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" @@ -1406,6 +1450,12 @@ dependencies = [ "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" @@ -1848,6 +1898,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "serde_json", + "zip", ] [[package]] @@ -1933,8 +1984,40 @@ dependencies = [ "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/README.md b/README.md index 24d16b9..3693754 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ A Rust-powered browser extension for viewing public X content through FxTwitter, **Development version. Not yet released in browser stores.** Availability is not guaranteed. The live provider returns some historically withheld public content, but has inconsistent profile responses; current regional-notice verification and the full release audit are still in progress. +Restricted content appears automatically inside X and follows its native timeline styling. + +The current interface direction is [direct X injection with a popup only](docs/implementation-direction.md). + ## Build Install rustup; the project pins Rust 1.99.1 and the WASM target in `rust-toolchain.toml`. Install matching tools: @@ -22,9 +26,9 @@ If tools are not on PATH, set `WASM_BINDGEN` and `WASM_OPT` to their executable ## Use -Load `dist/chromium` as an unpacked extension, or temporarily load `dist/firefox/manifest.json` through Firefox's about:debugging. Open a public X profile/post from the popup, or use the LimitlessX action on a supported regional-withholding notice. Requests start only after activation. The reader opens in a packaged extension tab. +Load `dist/chromium` as an unpacked extension, or temporarily load `dist/firefox/manifest.json` through Firefox's about:debugging. Open a public X profile/post from the popup, or use the LimitlessX action on a supported regional-withholding notice. A confirmed regional-withholding notice automatically starts the public-data lookup. Generic failures do not. Public content is injected directly into the X page. There is no extension reader page or iframe. Manual input opens the corresponding X URL and renders there. -Manual lookups do not add to The Record. Eligible restricted profiles count once after a successful deliberate open. Fetched timeline posts count only when individually opened. Settings can disable recording or clear the local ledger. +Automatic displays and manual lookups do not add to The Record. Eligible restricted profiles count once after a successful deliberate open. Fetched timeline posts count only when individually opened. Settings inside the popup can disable recording or clear the local ledger. ## Project diff --git a/crates/fxtwitter/src/lib.rs b/crates/fxtwitter/src/lib.rs index 26fbd7b..9a52886 100644 --- a/crates/fxtwitter/src/lib.rs +++ b/crates/fxtwitter/src/lib.rs @@ -116,7 +116,7 @@ enum RawPost { provider: String, id: String, text: String, - author: RawProfile, + author: Box, created_at: Option, media: Option, }, diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 1384a93..4b87b55 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,56 +1,89 @@ +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" | "options.html" | "reader.html") - ) && matches!( - op, - "ping" - | "record" - | "settings" - | "clear" - | "open_manual" - | "read" - | "timeline" - | "open_post" - | "current" - | "open_current" - ); + 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; }; - u.scheme() == "https" + 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") + && matches!( + op, + "inspect" + | "open_detected" + | "auto_detected" + | "pending" + | "read" + | "timeline" + | "open_post" + ) } #[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, 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 } diff --git a/crates/runtime/src/view.rs b/crates/runtime/src/view.rs new file mode 100644 index 0000000..77dd746 --- /dev/null +++ b/crates/runtime/src/view.rs @@ -0,0 +1,287 @@ +//! 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, Serialize, Deserialize, Default)] +pub struct Content { + pub profile: Option, + pub post: Option, + pub timeline: Option, + pub profile_error: Option, + pub timeline_error: Option, + pub record_error: Option, +} +fn profile(p: &Profile) -> Node { + let mut heading = vec![]; + if let Some(src) = &p.avatar { + heading.push(attr( + attr( + attr(attr(el("img", "avatar", vec![]), "src", src), "alt", ""), + "crossorigin", + "anonymous", + ), + "referrerpolicy", + "no-referrer", + )); + } + heading.push(el( + "div", + "", + vec![ + el("h2", "", vec![text(&p.name)]), + el("p", "muted", vec![text(format!("@{}", p.handle))]), + ], + )); + let mut children = vec![el("div", "profile-heading", heading)]; + if let Some(bio) = &p.biography { + children.push(el("p", "biography", vec![text(bio)])); + } + el("section", "profile-card x-profile", children) +} +fn post(p: &Post, token: &str, detailed: bool) -> Node { + let mut heading = vec![el( + "div", + "", + vec![ + el("strong", "", vec![text(&p.author.name)]), + el( + "span", + "muted", + vec![text(format!(" @{}", p.author.handle))], + ), + ], + )]; + if let Some(date) = &p.published { + let parts: Vec<_> = date.split_whitespace().collect(); + let display = if parts.len() == 6 + && parts[2].parse::().is_ok() + && parts[5].parse::().is_ok() + { + format!("{} {}, {}", parts[1], parts[2], parts[5]) + } else { + date.clone() + }; + heading.push(el("time", "", vec![text(format!("· {display}"))])); + } + let media = 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 node = attr( + attr( + attr( + attr(el("video", "post-media", vec![]), "src", &m.url), + "controls", + "", + ), + "preload", + "none", + ), + "crossorigin", + "anonymous", + ); + if let Some(preview) = &m.preview { + node = attr(node, "poster", preview); + } + node + } + }) + .collect(); + let mut actions = vec![link( + "Original on X ↗", + format!("https://x.com/{}/status/{}", p.author.handle, p.id), + )]; + if !detailed { + actions.push(button( + "Open post", + json!({"op":"open_post","session":token,"id":p.id}), + )); + } + let body = el( + "div", + "post-content", + vec![ + el("div", "post-heading", heading), + el("p", "post-text", vec![text(&p.text)]), + el("div", "media-grid", media), + el("div", "post-actions", actions), + ], + ); + let avatar = if let Some(src) = &p.author.avatar { + attr( + attr( + attr( + attr(el("img", "post-avatar", vec![]), "src", src), + "alt", + "", + ), + "crossorigin", + "anonymous", + ), + "referrerpolicy", + "no-referrer", + ) + } else { + attr( + el("div", "post-avatar avatar-placeholder", vec![]), + "aria-label", + "Profile image unavailable", + ) + }; + el( + "article", + if detailed { + "post-card detailed-post" + } else { + "post-card" + }, + vec![avatar, body], + ) +} +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)); + } + 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 let Some(t) = &content.timeline { + children.push(el( + "div", + "feed-title", + vec![ + el("h2", "", vec![text("Posts")]), + el("span", "fine", vec![text("")]), + ], + )); + if t.posts.is_empty() { + children.push(el( + "p", + "empty-state", + vec![text("No public posts were returned by the provider.")], + )); + } + for p in &t.posts { + children.push(post(p, token, false)); + } + if let Some(cursor) = &t.next_cursor { + children.push(button( + "Load more posts", + json!({"op":"timeline","session":token,"target":target,"cursor":cursor}), + )); + } + } + children.push(el( + "p", + "attribution", + vec![ + text("LimitlessX · via "), + link("FxTwitter", "https://github.com/FxEmbed/FxEmbed".into()), + text(". Availability can change."), + ], + )); + frame(children) +} diff --git a/crates/runtime/src/wasm.rs b/crates/runtime/src/wasm.rs index df5c859..6743b70 100644 --- a/crates/runtime/src/wasm.rs +++ b/crates/runtime/src/wasm.rs @@ -20,7 +20,11 @@ extern "C" { #[wasm_bindgen(js_namespace=limitlessBridge,catch)] async fn session_all() -> Result; #[wasm_bindgen(js_namespace=limitlessBridge,catch)] - async fn open_tab(url: &str) -> Result<(), JsValue>; + 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)] @@ -56,6 +60,10 @@ enum Request { OpenDetected { notices: Vec, }, + AutoDetected { + notices: Vec, + }, + Pending, Current, OpenCurrent, OpenManual { @@ -94,21 +102,28 @@ fn session_key(token: &str) -> Result { Err(Error::Expired) } } -async fn load_session(token: &str, target: Option<&Target>) -> Result { +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()) { + if !s.valid(target.unwrap_or(&s.target), now()) || !s.bound_to(sender) { return Err(Error::Expired); } Ok(s) } async fn create_reader( - sender: &Sender, + 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 = @@ -132,26 +147,35 @@ async fn create_reader( .map(|b| format!("{b:02x}")) .collect(); let s = Session { - target, + target: target.clone(), + source, + pending, + deliberate, restricted: is_restricted, expires: expires.unwrap_or(now() + 10. * 60. * 1000.), - tab_id: sender.tab_id.unwrap_or(-1), + tab_id, allowed_posts: vec![], + content: None, }; session_set(&session_key(&token)?, js(&s)) .await .map_err(|_| Error::Storage)?; - let target = serde_json::to_string(&s.target).map_err(|_| Error::InvalidTarget)?; - let mut url = url::Url::parse(&format!("{}reader.html", sender.extension_origin)) - .map_err(|_| Error::Unauthorized)?; - url.query_pairs_mut() - .append_pair("session", &token) - .append_pair("target", &target); - open_tab(url.as_ref()) - .await - .map_err(|_| Error::Unavailable)?; + 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 COOLDOWN:Cell=const{Cell::new(0.)};} struct Timer(i32); impl Drop for Timer { @@ -288,6 +312,7 @@ async fn timeline( 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?)), @@ -296,18 +321,62 @@ async fn handle(request: Request, sender: Sender) -> Result { )), Request::Clear => Ok(json!(limitless_storage::record(None, None, true).await?)), Request::Inspect { notices } => { - let target = Target::parse(&sender.url)?; + let target = Target::parse(sender.current_url())?; Ok(json!({"eligible":restricted(&target,¬ices),"target":target})) } - Request::OpenDetected { notices } => { - let target = Target::parse(&sender.url)?; + Request::OpenDetected { notices } | Request::AutoDetected { notices } => { + let target = Target::parse(sender.current_url())?; if !restricted(&target, ¬ices) { return Err(Error::Unauthorized); } - create_reader(&sender, target, true, None).await + create_reader( + sender.tab_id.ok_or(Error::Unauthorized)?, + target.clone(), + target, + true, + None, + false, + !automatic, + ) + .await } Request::OpenManual { input } => { - create_reader(&sender, Target::parse(&input)?, false, None).await + 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)?)?; + for (key, mut session) in all { + if session.pending + && session.valid(&session.target, now()) + && session.bound_to(&sender) + { + session.pending = false; + session_set(&key, js(&session)) + .await + .map_err(|_| Error::Storage)?; + let token = key.strip_prefix("reader:").ok_or(Error::Expired)?; + 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); @@ -316,16 +385,17 @@ async fn handle(request: Request, sender: Sender) -> Result { Ok(target) => { let is_restricted = restricted(&target, &c.notices); if open { - create_reader( - &Sender { - tab_id: Some(c.tab_id), - ..sender - }, + let context = create_reader( + c.tab_id, + target.clone(), target, is_restricted, None, + false, + true, ) - .await + .await?; + deliver(c.tab_id, context).await } else { Ok(json!({"target":target,"restricted":is_restricted})) } @@ -340,11 +410,11 @@ async fn handle(request: Request, sender: Sender) -> Result { } } Request::Read { session, target } => { - let mut s = load_session(&session, Some(&target)).await?; + let mut s = load_session(&session, Some(&target), &sender).await?; match &target { Target::Post { .. } => { let post = get_post(&target).await?; - let record_error = if s.restricted { + let record_error = if s.restricted && s.deliberate { limitless_storage::record(Some(("post", &post.id)), None, false) .await .err() @@ -352,13 +422,25 @@ async fn handle(request: Request, sender: Sender) -> Result { } else { None }; - Ok(json!({"post":post,"restricted":s.restricted,"record_error":record_error})) + s.content = Some(crate::view::Content { + post: Some(post), + 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 = get_profile(&target).await; let feed = timeline(&session, &mut s, None).await; - if profile.is_err() && feed.is_err() { - return Err(profile.unwrap_err()); + if let Err(error) = &profile + && feed.is_err() + { + return Err(error.clone()); } let profile_error = profile.as_ref().err().map(ToString::to_string); let profile = profile.ok().or_else(|| { @@ -369,7 +451,7 @@ async fn handle(request: Request, sender: Sender) -> Result { .find(|p| p.author.handle == *handle) .map(|p| p.author.clone()) }); - let record_error = if s.restricted { + let record_error = if s.restricted && s.deliberate { if let Some(p) = profile.as_ref() { limitless_storage::record(Some(("profile", &p.id)), None, false) .await @@ -382,8 +464,19 @@ async fn handle(request: Request, sender: Sender) -> Result { None }; let feed_error = feed.as_ref().err().map(ToString::to_string); + s.content = Some(crate::view::Content { + 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!({"profile":profile,"timeline":feed.ok(),"profile_error":profile_error,"timeline_error":feed_error,"restricted":s.restricted,"record_error":record_error}), + json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}), ) } } @@ -393,20 +486,46 @@ async fn handle(request: Request, sender: Sender) -> Result { target, cursor, } => { - let mut s = load_session(&session, Some(&target)).await?; - Ok(json!(timeline(&session, &mut s, cursor.as_deref()).await?)) + let mut s = load_session(&session, Some(&target), &sender).await?; + let next = timeline(&session, &mut s, cursor.as_deref()).await?; + 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()); + 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).await?; + 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(&sender, Target::Post { id }, s.restricted, Some(s.expires)).await + 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())?; @@ -423,6 +542,8 @@ pub async fn dispatch(message: JsValue, sender: JsValue) -> JsValue { .await; js(&match result { Ok(data) => json!({"ok":true,"data":data}), - Err(error) => json!({"ok":false,"message":error.to_string(),"error":error}), + 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 index beae745..46df42f 100644 --- a/crates/runtime/tests/security.rs +++ b/crates/runtime/tests/security.rs @@ -4,6 +4,7 @@ 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(), @@ -35,12 +36,70 @@ 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, 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, + 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, + 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..c2cc1c2 --- /dev/null +++ b/crates/runtime/tests/view.rs @@ -0,0 +1,43 @@ +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, + }), + 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+/="))); +} diff --git a/crates/storage/src/wasm.rs b/crates/storage/src/wasm.rs index e79165b..4638c11 100644 --- a/crates/storage/src/wasm.rs +++ b/crates/storage/src/wasm.rs @@ -66,17 +66,17 @@ pub async fn record( meta.since = js_sys::Date::now(); } err(meta_store.put(&val(&meta)?, Some(&key)).await)?; - if meta.enabled { - if 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)?; - } + 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)?, diff --git a/crates/ui/src/app.rs b/crates/ui/src/app.rs index a98a7b3..9a30fb9 100644 --- a/crates/ui/src/app.rs +++ b/crates/ui/src/app.rs @@ -7,7 +7,7 @@ extern "C" { #[wasm_bindgen(js_namespace=limitlessUI,catch)] async fn send(message: JsValue) -> Result; #[wasm_bindgen(js_namespace=limitlessUI)] - pub fn open_options(); + pub fn asset_url(path: &str) -> String; } pub async fn call(message: Value) -> Result { let value = message @@ -29,11 +29,11 @@ pub async fn call(message: Value) -> Result { } #[component] pub fn Header() -> impl IntoView { - view! {

"LimitlessX"

"PUBLIC READER"
} + view! {

"LimitlessX"

"PUBLIC READER"
} } #[component] pub fn Footer() -> impl IntoView { - view! {} + view! {} } #[component] pub fn Attribution() -> impl IntoView { @@ -41,23 +41,19 @@ pub fn Attribution() -> impl IntoView { } #[wasm_bindgen(start)] pub fn start() { - let path = web_sys::window() - .and_then(|w| w.location().pathname().ok()) - .unwrap_or_default(); - let body = web_sys::window() - .unwrap() - .document() - .unwrap() - .body() - .unwrap(); - if path.ends_with("reader.html") { - body.set_class_name("reader-page"); - leptos::mount::mount_to_body(|| view! {}); - } else if path.ends_with("options.html") { - body.set_class_name("options-page"); - leptos::mount::mount_to_body(|| view! {}); - } else { - body.set_class_name("popup-page"); + 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 index 15e77b9..d67d7f1 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,10 +1,6 @@ #[cfg(target_arch = "wasm32")] mod app; #[cfg(target_arch = "wasm32")] -mod options; -#[cfg(target_arch = "wasm32")] mod popup; #[cfg(target_arch = "wasm32")] -mod reader; -#[cfg(target_arch = "wasm32")] mod record; diff --git a/crates/ui/src/options.rs b/crates/ui/src/options.rs deleted file mode 100644 index cb263e3..0000000 --- a/crates/ui/src/options.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::app::{Footer, Header}; -use leptos::prelude::*; -#[component] -pub fn Options() -> impl IntoView { - view! {

"YOUR DEVICE. YOUR RECORD."

"A little less friction."

"A focused reader, with a record you control."

-

"What The Record counts"

"Distinct restricted profiles and posts you deliberately open after LimitlessX detects a regional restriction on X. Manual lookups and automatically loaded timelines do not add to the counters."

"Reopening the same content never increases its unique count. Turning recording off keeps existing counts. Clear record removes the stored ledger and starts a new record."

-

"What stays here"

"The Record lives in this browser’s IndexedDB. It stores per-install salted hashes of content identifiers, two counters, and your recording preference. It does not store the text of what you read. Uninstalling the extension removes its local data."

-

"What leaves your device"

"Only after you choose to open content, LimitlessX requests public data from api.fxtwitter.com. FxTwitter receives the requested account or post identifier and your network address. Loading images or videos connects to X’s public media hosts. These services have their own policies."

"Requests omit credentials. LimitlessX does not use your X cookies, collect passwords, run analytics, or operate a backend. It makes no anonymity promise."

-

"Availability has limits"

"FxTwitter may return incomplete, stale, or unavailable data. An unavailable response does not prove regional withholding. Protected, suspended and deleted content are separate states. LimitlessX cannot unlock accounts or guarantee access."

-

"Open source"

"Source, issues and releases ↗"" · MIT license · Version 0.1.0"

} -} diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index d865e27..32d92c9 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -14,6 +14,7 @@ pub fn Popup() -> impl IntoView { 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()); @@ -40,9 +41,12 @@ pub fn Popup() -> impl IntoView {
- +
{move ||error.get().map(|e|view!{})} -

"When you open content, its identifier is sent to FxTwitter. Private content is unavailable."