From d81e245629b52db3d5f7986b8209aed665b19e61 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 22 Apr 2026 23:33:37 +0200 Subject: [PATCH 1/8] feat: Add Rust Tor runtime and node routing (#256) Introduce Arti-backed Tor runtime integration, node client routing updates, and related Rust + BDK patch plumbing rusqlite to fix conflicts with arti. --- .gitmodules | 4 + CONTRIBUTING.md | 9 +- justfile | 28 + rust/Cargo.lock | 3737 ++++++++++++++++- rust/Cargo.toml | 20 +- rust/crates/cove-common/src/logging.rs | 95 +- rust/external/bdk | 1 + ...in-rusqlite-sqlite-int64-conversions.patch | 155 + ...ve-workspace-lints-in-patched-crates.patch | 56 + ...3-bdk-chain-align-rusqlite-with-arti.patch | 13 + ...bdk-chain-allow-coverage-nightly-cfg.patch | 12 + rust/src/database/global_config.rs | 43 +- rust/src/database/global_flag.rs | 1 + rust/src/database/macros.rs | 1 + rust/src/lib.rs | 26 + rust/src/manager/wallet_manager/actor.rs | 43 +- rust/src/node.rs | 24 + rust/src/node/client.rs | 129 +- rust/src/node/client/electrum.rs | 169 +- rust/src/node/client/esplora.rs | 36 +- rust/src/node/client_builder.rs | 22 +- rust/src/node_connect.rs | 183 +- rust/src/tor_runtime.rs | 354 ++ rust/src/wallet_scanner.rs | 6 +- 24 files changed, 4905 insertions(+), 262 deletions(-) create mode 100644 .gitmodules create mode 160000 rust/external/bdk create mode 100644 rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch create mode 100644 rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch create mode 100644 rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch create mode 100644 rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch create mode 100644 rust/src/tor_runtime.rs diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..802c3f7f4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "rust/external/bdk"] + path = rust/external/bdk + url = https://github.com/bitcoindevkit/bdk.git + shallow = true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8e23a26c..03bbca77d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,11 +24,14 @@ The `COVE_KEYSTORE_*` variables in `.envrc.example` are only needed for signed A ## Quick Start 1. Clone the repository -2. Build the Rust library and bindings: +2. Run setup for submodules: + - `just setup` + - This initializes `rust/external/bdk` and applies project patches from `rust/patches/bdk/` +3. Build the Rust library and bindings: - iOS: `just build-ios` (`just bi`) for simulator or `just build-ios-debug-device` (`just bidd`) for device - Android: `just build-android` (`just ba`) -3. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`) -4. Build and run +4. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`) +5. Build and run ## Release Builds diff --git a/justfile b/justfile index 76571da4a..14dfc09dc 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,34 @@ list: xtask *args: cd rust && test -f target/debug/xtask || cargo build --package xtask -q && ./target/debug/xtask {{args}} +# Setup repository after fresh clone +[group('utils')] +[script('bash')] +setup: + set -euo pipefail + repo_root="$(git rev-parse --show-toplevel)" + bdk_submodule="$repo_root/rust/external/bdk" + bdk_patch_dir="$repo_root/rust/patches/bdk" + git submodule sync --recursive + if ! git submodule update --init --recursive --depth 1 --recommend-shallow; then + echo "Shallow submodule checkout failed, retrying with full history..." + git submodule update --init --recursive + fi + for patch in "$bdk_patch_dir"/*.patch; do + [ -e "$patch" ] || continue + if git -C "$bdk_submodule" apply --check "$patch"; then + git -C "$bdk_submodule" apply "$patch" + echo "Applied $(basename "$patch")" + elif git -C "$bdk_submodule" apply --reverse --check "$patch"; then + echo "Already applied $(basename "$patch")" + else + echo "Failed to apply $(basename "$patch") cleanly" >&2 + exit 1 + fi + done + git submodule status --recursive + echo "Repository setup complete." + # Sign a PSBT and output all formats (base64, hex, binary, bbqr-gif, ur-gif) # Requires MNEMONIC env var (set in .envrc or pass directly) [group('utils')] diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2adbe6c65..0dfc067dc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -23,7 +23,7 @@ dependencies = [ "act-zero", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -51,6 +51,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -91,6 +103,61 @@ dependencies = [ "equator", ] +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.17", + "getrandom 0.3.4", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99bcb75a2982047f733547042fc3968c0f460dfcf7d90b90dea3b2744580e9ad" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -170,7 +237,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -191,6 +258,114 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arti" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f603df52eff853e311527f9292ce378df1e0b87876cff3ef76eb4f495b7d7013" +dependencies = [ + "anyhow", + "arti-client", + "cfg-if", + "clap", + "derive-deftly", + "derive_builder_fork_arti", + "fs-mistrust", + "futures", + "futures-copy", + "hickory-proto", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "libc", + "notify", + "paste", + "pin-project", + "postage", + "rlimit", + "rustls", + "safelog", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "toml 1.1.0+spec-1.1.0", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-config-path", + "tor-error", + "tor-general-addr", + "tor-key-forge", + "tor-keymgr", + "tor-log-ratelim", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tracing", + "tracing-appender", + "tracing-subscriber", + "visibility", + "web-time-compat", + "winapi", +] + +[[package]] +name = "arti-client" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15d2051582670d5c003deda168da03f0d3475f6375bca57d6b9852a9d32eed" +dependencies = [ + "anyhow", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "tempfile", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-hsclient", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + [[package]] name = "as-slice" version = "0.2.1" @@ -200,6 +375,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "askama" version = "0.13.1" @@ -240,7 +421,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -257,7 +438,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -294,6 +475,50 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-compat" version = "0.2.5" @@ -307,6 +532,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -315,7 +552,50 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -399,6 +679,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" @@ -452,8 +738,6 @@ dependencies = [ [[package]] name = "bdk_chain" version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c290eff038799a8ac0c5a82b6160a9ca456baa299a6f22b262c771342d2846c0" dependencies = [ "bdk_core", "bitcoin", @@ -465,8 +749,6 @@ dependencies = [ [[package]] name = "bdk_core" version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3028782f6bf14a6df987244333d34e6b272b5a40a53e4879ec2dfd82275a3a" dependencies = [ "bitcoin", "hashbrown 0.14.5", @@ -502,7 +784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446f02da319941ea78454a479fca72707f3355a62db3bb1c2c13b94e7f963ce0" dependencies = [ "bdk_core", - "bincode", + "bincode 1.3.3", "serde", ] @@ -537,6 +819,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + [[package]] name = "bip329" version = "0.4.0" @@ -641,6 +933,12 @@ dependencies = [ "percent-encoding-rfc3986", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -677,6 +975,17 @@ dependencies = [ "digest", ] +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -686,6 +995,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "built" version = "0.8.0" @@ -698,6 +1018,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -731,6 +1057,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "caret" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" + [[package]] name = "cargo-platform" version = "0.1.9" @@ -852,6 +1184,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -909,7 +1253,8 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", + "terminal_size", ] [[package]] @@ -921,7 +1266,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -930,6 +1275,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -989,6 +1345,40 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "liblzma", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -998,6 +1388,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1032,6 +1431,8 @@ dependencies = [ "ahash", "arc-swap", "argon2", + "arti", + "arti-client", "async-trait", "backon", "base64 0.22.1", @@ -1043,7 +1444,7 @@ dependencies = [ "bip329", "bip39", "bitcoin", - "bitflags", + "bitflags 2.11.0", "bitvec", "cbor4ii", "cfg-if", @@ -1095,12 +1496,15 @@ dependencies = [ "serde_json", "serde_urlencoded", "sha2", - "strsim", - "strum", + "strsim 0.11.1", + "strum 0.28.0", "tap", "tempfile", "thiserror 2.0.18", "tokio", + "tor-config", + "tor-config-path", + "tor-rtcompat", "tracing", "tracing-log", "tracing-subscriber", @@ -1267,7 +1671,7 @@ dependencies = [ "redb", "serde", "serde_json", - "strum", + "strum 0.28.0", "tap", "thiserror 2.0.18", "tracing", @@ -1351,6 +1755,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1371,8 +1790,17 @@ dependencies = [ ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" @@ -1382,6 +1810,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1414,12 +1854,245 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-deftly" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" +dependencies = [ + "derive-deftly-macros", + "heck", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" +dependencies = [ + "heck", + "indexmap 2.13.0", + "itertools 0.14.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum 0.27.2", + "syn 2.0.117", + "void", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1439,7 +2112,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -1456,10 +2129,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1489,15 +2172,79 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dtoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "either" version = "1.15.0" @@ -1521,6 +2268,83 @@ dependencies = [ "winapi", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.4.2" @@ -1538,7 +2362,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1572,6 +2396,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1635,7 +2470,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1648,12 +2483,52 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1670,6 +2545,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + [[package]] name = "flume" version = "0.12.0" @@ -1682,6 +2563,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1708,7 +2595,7 @@ dependencies = [ "heapless", "itertools 0.10.5", "minicbor 0.24.4", - "phf", + "phf 0.11.3", "rand_xoshiro", ] @@ -1730,6 +2617,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs-mistrust" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.18", + "walkdir", +] + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -1761,6 +2673,18 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-copy" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f320278d16b5617859cc2234ca5775a8d9b24faee8c14e54e79540caab053a" +dependencies = [ + "futures", + "libc", + "pin-project", + "windows-sys 0.61.2", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1792,7 +2716,18 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", ] [[package]] @@ -1832,6 +2767,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1868,11 +2804,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1897,6 +2847,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1920,6 +2876,17 @@ dependencies = [ "scroll", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1940,6 +2907,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1967,11 +2940,11 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.5", ] [[package]] @@ -2026,6 +2999,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2044,6 +3042,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "http" version = "1.4.0" @@ -2083,6 +3087,28 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2144,6 +3170,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2231,6 +3281,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -2286,6 +3342,16 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "imgref" version = "1.12.0" @@ -2298,6 +3364,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -2310,6 +3387,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2327,7 +3424,16 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", ] [[package]] @@ -2399,7 +3505,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2458,7 +3564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2483,11 +3589,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2517,20 +3655,49 @@ dependencies = [ "cc", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" dependencies = [ "cc", "openssl-sys", @@ -2606,23 +3773,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "minicbor" -version = "0.24.4" +name = "memmap2" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29be4f60e41fde478b36998b88821946aafac540e53591e76db53921a0cc225b" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ - "minicbor-derive 0.15.3", + "libc", ] [[package]] -name = "minicbor" -version = "2.2.1" +name = "merlin" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70eae6d4f18f7d76877fe7b13f0bc21f7c2b7239d2041c338335f7b388d0dd7" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ - "minicbor-derive 0.19.3", -] - + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "minicbor" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29be4f60e41fde478b36998b88821946aafac540e53591e76db53921a0cc225b" +dependencies = [ + "minicbor-derive 0.15.3", +] + +[[package]] +name = "minicbor" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70eae6d4f18f7d76877fe7b13f0bc21f7c2b7239d2041c338335f7b388d0dd7" +dependencies = [ + "minicbor-derive 0.19.3", +] + [[package]] name = "minicbor-derive" version = "0.15.3" @@ -2631,7 +3819,7 @@ checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2642,7 +3830,7 @@ checksum = "294f0a0c161c510e9746adf546b8b044fbb0b00677d7dfc9a2452f9fdf63439b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2690,6 +3878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2739,12 +3928,53 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2764,6 +3994,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-derive" version = "0.4.2" @@ -2772,7 +4024,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2784,6 +4036,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -2802,6 +4065,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2813,6 +4099,25 @@ dependencies = [ "itoa", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -2827,6 +4132,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2834,6 +4143,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oneshot-fused-workaround" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +dependencies = [ + "futures", +] + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2874,12 +4192,74 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + [[package]] name = "owo-colors" version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2900,7 +4280,7 @@ dependencies = [ "cfg-if", "libc", "petgraph", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2944,6 +4324,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2963,7 +4352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.13.0", ] [[package]] @@ -2972,8 +4361,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -2982,21 +4382,44 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3008,6 +4431,35 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3020,6 +4472,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3038,7 +4511,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -3071,6 +4544,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3080,6 +4568,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3106,56 +4600,119 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "unicode-ident", + "elliptic-curve", ] [[package]] -name = "profiling" -version = "1.0.17" +name = "priority-queue" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ - "profiling-procmacros", + "equivalent", + "indexmap 2.13.0", + "serde", ] [[package]] -name = "profiling-procmacros" -version = "1.0.17" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "quote", - "syn", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] -name = "pubport" -version = "0.5.0" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8cf693e3f6aa628ab34d81cb2ed0b75578b36154f61aeadbbea934ef32e603" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "bitcoin", - "derive_more", - "log", - "memchr", - "miniscript 13.0.0", - "serde", - "serde_json", - "thiserror 2.0.18", + "proc-macro2", + "quote", ] [[package]] -name = "pxfm" -version = "0.1.28" +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pubport" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8cf693e3f6aa628ab34d81cb2ed0b75578b36154f61aeadbbea934ef32e603" +dependencies = [ + "bitcoin", + "derive_more", + "log", + "memchr", + "miniscript 13.0.0", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "pxfm" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" @@ -3347,6 +4904,17 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_jitter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +dependencies = [ + "libc", + "rand_core 0.9.5", + "winapi", +] + [[package]] name = "rand_xoshiro" version = "0.6.0" @@ -3426,6 +4994,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rdrand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "redb" version = "2.6.3" @@ -3441,7 +5018,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -3455,6 +5041,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -3546,6 +5164,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "retry-error" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0" +dependencies = [ + "humantime", + "web-time", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3566,18 +5204,49 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", + "time", ] [[package]] @@ -3616,13 +5285,22 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3725,6 +5403,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safelog" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9f10dd250956c65d58a19507dd06ff976f898560fe843580d05134541f0898" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.18", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3734,6 +5425,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" + [[package]] name = "schannel" version = "0.1.29" @@ -3743,6 +5449,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3766,7 +5496,21 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", ] [[package]] @@ -3796,7 +5540,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -3833,6 +5577,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -3860,7 +5614,17 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", ] [[package]] @@ -3876,6 +5640,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.0" @@ -3897,6 +5670,48 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3908,6 +5723,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3917,6 +5742,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3924,16 +5760,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "simd-adler32" -version = "0.3.9" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] [[package]] -name = "simd_helpers" -version = "0.1.0" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" dependencies = [ "quote", ] @@ -3956,6 +5812,29 @@ 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 = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.18", + "void", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -3987,6 +5866,58 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" +dependencies = [ + "cipher", + "ssh-encoding-fork-arti", +] + +[[package]] +name = "ssh-encoding-fork-arti" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key-fork-arti" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433782176b73ea7907763dc314c4a17438a231864d4aa683ba47c07c1cf0a388" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher-fork-arti", + "ssh-encoding-fork-arti", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3999,19 +5930,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4023,7 +5981,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4032,6 +5990,23 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -4060,7 +6035,21 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] @@ -4082,6 +6071,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -4117,7 +6116,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4128,7 +6127,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4154,6 +6153,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4161,6 +6191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4180,88 +6211,1197 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] -name = "tokio" -version = "1.50.0" +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "tor-async-utils" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4680d5ecdb052e950b31cd7c7852aa025968f648046e55251446d7d0e58138e" +dependencies = [ + "derive-deftly", + "educe", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "tor-basic-utils" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2351dee62e4edabb624a7e7ba85f6f2b73073ec4590e76c9b6f90fc30b91e9" +dependencies = [ + "derive_more", + "getrandom 0.3.4", + "hex", + "itertools 0.14.0", + "libc", + "paste", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.18", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-bytes" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86f91871fcd2e2fb16fcf55b7821b050b11d05d42b7e98104b662417bafda92" +dependencies = [ + "bytes", + "derive-deftly", + "digest", + "educe", + "getrandom 0.4.2", + "safelog", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee932b1f0890a0ac689f6fdeec0fe4cc9e11bc1a18b2a09c2fb7adbd0761df4" +dependencies = [ + "amplify", + "bitflags 2.11.0", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "smallvec", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f774e4097af30e759ddcfcdd50e594183b62c2e70e3dd55f4b61e254d527505" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest", + "thiserror 2.0.18", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-chanmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31db74e069430a70f290820d7f60e2cd96364dcb4b8bbd92aee9bf94b9a2dfca" +dependencies = [ + "async-trait", + "base64ct", + "caret", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "futures", + "httparse", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-checkable" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65310bcfd125f65e4ac8ee290b352d20a80909ef7226741f57e9a049391d3d7" +dependencies = [ + "humantime", + "signature", + "thiserror 2.0.18", + "tor-llcrypto", + "web-time-compat", +] + +[[package]] +name = "tor-circmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1310bb4288894f3bae03d0201ee397bed95fd08b24b18b5303a0bdbee012fba" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools 0.14.0", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.9.2", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-config" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a1148697cfeeb8c35a3bd2ee3eb98a3510610635fca63e46bca33dfb3f450e" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools 0.14.0", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum 0.28.0", + "thiserror 2.0.18", + "toml 1.1.0+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3cc17b20f63fa231e3811005c3c39dd55b4c3bc44c73565be2a632cc53a442" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-consdiff" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b5da8bcea62b5f6695f9271f8367d6d00977a6bffd2da9a0795c29cb839790" +dependencies = [ + "derive_more", + "digest", + "hex", + "imara-diff", + "static_assertions", + "thiserror 2.0.18", + "tor-error", + "tor-llcrypto", + "tor-netdoc", +] + +[[package]] +name = "tor-dirclient" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139aec98a8579613a08b1283ca9da4ceda110e0627798bff22b1b692a7a03bf0" +dependencies = [ + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http", + "httparse", + "httpdate", + "itertools 0.14.0", + "memchr", + "thiserror 2.0.18", + "tor-circmgr", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-dircommon" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0e029a636b59ac25e4c2e261e306775dac5200c1632f99696cfc2c236b3b5d" +dependencies = [ + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-dirmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a659fc3d3aa9f1c11d940cd5fcc37a641a8db3595ec2b8df6dc4595e7d71ef08" +dependencies = [ + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "event-listener", + "fs-mistrust", + "fslock", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.9.2", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature", + "static_assertions", + "strum 0.28.0", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-error" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8163b225e07e00be618cbebdbbc8ff003795121971cdf54297e820cc177569" +dependencies = [ + "derive_more", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum 0.28.0", + "thiserror 2.0.18", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-general-addr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53d8066194461d13603437331c24c7a722d3d4e375ed1552c434b3240880f17" +dependencies = [ + "derive_more", + "thiserror 2.0.18", + "void", +] + +[[package]] +name = "tor-guardmgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119df8b27549a1db143713b9afde6d0d5ef1736b9fd03264715bce9d83616d10" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-hsclient" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9598bfa74aa4734be431d0538e4e6a39f34ab166a4f31ffb584a406b0c72d708" +dependencies = [ + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "either", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "postage", + "rand 0.9.2", + "retry-error", + "safelog", + "slotmap-careful", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-dirclient", + "tor-error", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "web-time-compat", +] + +[[package]] +name = "tor-hscrypto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f7f57feca263556c41c5d86129b6bd4b1f41dbfc566d070b112bd0bb1b6a21" +dependencies = [ + "data-encoding", + "derive-deftly", + "derive_more", + "digest", + "hex", + "humantime", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-memquota", + "tor-units", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-key-forge" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cea1159867a98160a0629529fdb3615479d3f963b5ab92914dbcc6f0b68bf5c" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "paste", + "rand 0.9.2", + "rsa", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98e14dbf1a37b9efb1df0650cdf5cb0d6da83589f6d42cfc4d2f7762d72594a5" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools 0.14.0", + "rand 0.9.2", + "safelog", + "serde", + "signature", + "ssh-key-fork-arti", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af62be67033640f5553797541720e34c12bf9ddb8569655d6241f32e7deed88f" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools 0.14.0", + "safelog", + "serde", + "serde_with", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc87d3def186b8edc21ec8f3645dd28cf3b13c6259f4e335e71b7ef52c6ea3f2" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest", + "ed25519-dalek", + "educe", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.2", + "hex", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_core 0.6.4", + "rand_core 0.9.5", + "rand_jitter", + "rdrand", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3", + "signature", + "subtle", + "thiserror 2.0.18", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70066c9cf52f6c273243e97bc548c9be6e1d15e1bf5962da4f0be19686c85ade" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.18", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", + "web-time-compat", +] + +[[package]] +name = "tor-memquota" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeeba11704612b6fb277d708d0ff9b471266084d86a6e557f858cdadaa61ca49" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "futures", + "itertools 0.14.0", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.18", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-memquota-cost" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0178ecdfe9a2856232aaa41805dfd75b8512a21e8e6d02a67427d00e221b192b" +dependencies = [ + "derive-deftly", + "itertools 0.14.0", + "paste", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "570b4d45feb00f9a04b429ba1a612b956b263ca01bb7b56a38e01d1223d581b7" +dependencies = [ + "async-trait", + "bitflags 2.11.0", + "derive_more", + "digest", + "futures", + "hex", + "humantime", + "itertools 0.14.0", + "num_enum", + "rand 0.9.2", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "time", + "tor-basic-utils", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", + "web-time-compat", +] + +[[package]] +name = "tor-netdoc" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1e809fc7d54cab30451fcb5f765ebb480ac8ad3e62454001ae875d36d1c27e" +dependencies = [ + "amplify", + "base64ct", + "cipher", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enumset", + "hex", + "humantime", + "itertools 0.14.0", + "memchr", + "paste", + "phf 0.13.1", + "rand 0.9.2", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum 0.28.0", + "subtle", + "thiserror 2.0.18", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-protover", + "tor-units", + "void", + "web-time-compat", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b6d2a9f700c0cbea6dd9b6b3614c47d26fbeffcb0ffdc10caf713a465fb913b" +dependencies = [ + "derive-deftly", + "derive_more", + "filetime", + "fs-mistrust", + "fslock", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", + "web-time-compat", +] + +[[package]] +name = "tor-proto" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "db8556b34a5a24a8ce20cd439ad2081b6097645e165d95387ba8eb8fa17f3520" dependencies = [ + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", + "caret", + "cfg-if", + "cipher", + "coarsetime", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "rand_core 0.9.5", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "subtle", + "sync_wrapper", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "web-time-compat", + "zeroize", ] [[package]] -name = "tokio-macros" -version = "2.6.1" +name = "tor-protover" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "3fb1b457a24d4e6f4ab1981f33a55c7c525c2edcad855cbc4bdc544430914fc1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "caret", + "paste", + "serde_with", + "thiserror 2.0.18", + "tor-basic-utils", + "tor-bytes", ] [[package]] -name = "tokio-rustls" -version = "0.26.4" +name = "tor-relay-crypto" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "7675f5d412acbdf62f8771553d3ca0cfad8afdd78f9b1f7c2befc370ce030bda" dependencies = [ - "rustls", - "tokio", + "derive-deftly", + "derive_more", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", + "web-time-compat", ] [[package]] -name = "toml" -version = "0.5.11" +name = "tor-relay-selection" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +checksum = "3110742f2a9e071c7f6c9278cd37e0bae0995d7171516fe4efb2de675645667e" dependencies = [ + "rand 0.9.2", "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", ] [[package]] -name = "toml" -version = "1.1.0+spec-1.1.0" +name = "tor-rtcompat" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "1714889530e08014a32123bcf947d9564dda61508bc4e7ec5203dcd726985850" dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow 1.0.1", + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_more", + "dyn-clone", + "educe", + "futures", + "futures-rustls", + "hex", + "libc", + "paste", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "web-time-compat", + "zeroize", ] [[package]] -name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +name = "tor-rtmock" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "4cc3bc02ab55161c3bb25b2ae64ea0e7a20b93cd3272f905dcbd8d5882d7a0be" dependencies = [ - "serde_core", + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools 0.14.0", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum 0.28.0", + "thiserror 2.0.18", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", + "web-time-compat", ] [[package]] -name = "toml_parser" -version = "1.1.0+spec-1.1.0" +name = "tor-socksproto" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "36d84b4d4a8680dc865c7a7c44ea5701be049b88276b818f7cd6529af28c2115" dependencies = [ - "winnow 1.0.1", + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.18", + "tor-bytes", + "tor-error", ] [[package]] -name = "toml_writer" -version = "1.1.0+spec-1.1.0" +name = "tor-units" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "2ae5c12f3bba5c4707f3a0a9c508320d6a4827e01e5e7ca0824ae55cbd2a737a" +dependencies = [ + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.18", + "tor-memquota", +] [[package]] name = "tower" @@ -4284,7 +7424,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4319,6 +7459,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -4327,7 +7480,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4379,6 +7532,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4391,12 +7565,31 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode 2.0.1", + "serde", +] + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4469,7 +7662,7 @@ dependencies = [ "glob", "goblin", "heck", - "indexmap", + "indexmap 2.13.0", "once_cell", "serde", "tempfile", @@ -4494,7 +7687,7 @@ dependencies = [ "glob", "goblin", "heck", - "indexmap", + "indexmap 2.13.0", "once_cell", "serde", "tempfile", @@ -4566,10 +7759,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4578,10 +7771,10 @@ version = "0.31.0" source = "git+https://github.com/mozilla/uniffi-rs?branch=main#fc3794f42f001c2728bf80ec55ae9b7552298c14" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4596,7 +7789,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "toml 0.5.11", "uniffi_meta 0.29.5", ] @@ -4612,7 +7805,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "toml 1.1.0+spec-1.1.0", "uniffi_meta 0.31.0", ] @@ -4648,7 +7841,7 @@ checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "tempfile", "uniffi_internal_macros 0.29.5", ] @@ -4660,7 +7853,7 @@ source = "git+https://github.com/mozilla/uniffi-rs?branch=main#fc3794f42f001c272 dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "tempfile", "uniffi_internal_macros 0.31.0", ] @@ -4716,6 +7909,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -4770,6 +7969,23 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "walkdir" version = "2.5.0" @@ -4813,6 +8029,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi", +] + [[package]] name = "wasm-bindgen" version = "0.2.115" @@ -4855,7 +8080,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4885,7 +8110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -4896,12 +8121,18 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + [[package]] name = "web-sys" version = "0.3.92" @@ -4922,6 +8153,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time-compat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59" +dependencies = [ + "web-time", +] + [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -5000,12 +8240,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -5099,6 +8434,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5283,9 +8627,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5301,7 +8645,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5313,8 +8657,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.0", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -5333,7 +8677,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -5358,6 +8702,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xshell" version = "0.2.7" @@ -5425,7 +8781,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5446,7 +8802,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5466,7 +8822,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5487,7 +8843,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5507,6 +8863,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -5520,7 +8877,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5535,6 +8892,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9ea232e6b..391e081aa 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -92,7 +92,7 @@ eyre = "0.6" # database redb = "2.6" -rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] } +rusqlite = { version = "0.36", features = ["bundled-sqlcipher-vendored-openssl"] } # ids nid = "3.0" @@ -104,6 +104,13 @@ dirs = "6.0" derive_more = { version = "2.0" } strum = { version = "0.28", features = ["derive"] } +# built-in tor runtime +arti = { version = "2.2.0", default-features = false, features = ["tokio", "rustls", "compression", "dns-proxy", "experimental-api", "onion-service-client"] } +arti-client = { version = "0.41.0", default-features = false, features = ["tokio", "rustls", "compression", "onion-service-client"] } +tor-config = "0.41.0" +tor-rtcompat = { version = "0.41.0", default-features = false, features = ["tokio", "rustls"] } +tor-config-path = "0.41.0" + # extensions tap = "1.0" @@ -205,6 +212,13 @@ derive_more = { workspace = true, features = [ strum = { workspace = true, features = ["derive"] } +# built-in tor runtime +arti = { workspace = true } +arti-client = { workspace = true } +tor-config = { workspace = true } +tor-rtcompat = { workspace = true } +tor-config-path = { workspace = true } + # helpers / utils / exts tap = "1.0.1" itertools = "0.14" @@ -309,3 +323,7 @@ opt-level = 3 # Optimize for speed [profile.release-smaller] inherits = "release-base" opt-level = 'z' # Optimize for size. + +[patch.crates-io] +bdk_chain = { path = "external/bdk/crates/chain" } +bdk_core = { path = "external/bdk/crates/core" } diff --git a/rust/crates/cove-common/src/logging.rs b/rust/crates/cove-common/src/logging.rs index c70d0da06..606328b62 100644 --- a/rust/crates/cove-common/src/logging.rs +++ b/rust/crates/cove-common/src/logging.rs @@ -1,8 +1,92 @@ -use std::sync::Once; +use std::{ + collections::VecDeque, + sync::{Mutex, Once}, +}; +use once_cell::sync::Lazy; +use tracing::{Event, Subscriber, field::Visit}; use tracing_subscriber::EnvFilter; +use tracing_subscriber::{Layer, layer::Context, registry::LookupSpan}; static INIT: Once = Once::new(); +static TOR_LOG_BUFFER: Lazy>> = Lazy::new(|| Mutex::new(VecDeque::new())); +const MAX_TOR_LOG_LINES: usize = 400; + +#[derive(Default)] +struct TorEventVisitor { + message: Option, + fields: Vec, +} + +impl Visit for TorEventVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + let value = format!("{value:?}"); + if field.name() == "message" { + self.message = Some(value.trim_matches('"').to_string()); + } else { + self.fields.push(format!("{}={value}", field.name())); + } + } +} + +struct TorLogLayer; + +impl Layer for TorLogLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + + let mut visitor = TorEventVisitor::default(); + event.record(&mut visitor); + + let message = visitor.message.unwrap_or_default(); + let fields = if visitor.fields.is_empty() { + String::new() + } else { + format!(" {}", visitor.fields.join(" ")) + }; + let line = format!("[{} {}] {}{}", metadata.level(), metadata.target(), message, fields); + + if !is_tor_related(metadata.target(), &line) { + return; + } + + push_tor_log(line); + } +} + +fn is_tor_related(target: &str, line: &str) -> bool { + if target.starts_with("arti") || target.starts_with("tor_") || target.contains("tor_runtime") { + return true; + } + + let lowercase = line.to_ascii_lowercase(); + lowercase.contains(" tor ") + || lowercase.contains("tor:") + || lowercase.contains("tor-") + || lowercase.contains("onion") + || lowercase.contains("socks") +} + +fn push_tor_log(line: String) { + let mut buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned"); + buffer.push_back(line); + while buffer.len() > MAX_TOR_LOG_LINES { + let _ = buffer.pop_front(); + } +} + +pub fn tor_connection_logs() -> Vec { + let buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned"); + buffer.iter().cloned().collect() +} + +pub fn clear_tor_connection_logs() { + let mut buffer = TOR_LOG_BUFFER.lock().expect("tor log buffer poisoned"); + buffer.clear(); +} pub fn init() { INIT.call_once(|| { @@ -12,9 +96,16 @@ pub fn init() { unsafe { std::env::set_var("RUST_LOG", "cove=debug,info") } } + #[cfg(target_os = "android")] + let fmt_layer = fmt::layer().with_writer(std::io::stderr).with_ansi(false); + + #[cfg(not(target_os = "android"))] + let fmt_layer = fmt::layer().with_writer(std::io::stdout).with_ansi(false); + tracing_subscriber::registry() - .with(fmt::layer().with_writer(std::io::stdout).with_ansi(false)) + .with(fmt_layer) .with(EnvFilter::from_default_env()) + .with(TorLogLayer) .init(); }); } diff --git a/rust/external/bdk b/rust/external/bdk new file mode 160000 index 000000000..d9aa92b35 --- /dev/null +++ b/rust/external/bdk @@ -0,0 +1 @@ +Subproject commit d9aa92b3511d95f6075616d30c146aa5df814495 diff --git a/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch b/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch new file mode 100644 index 000000000..d755d0b63 --- /dev/null +++ b/rust/patches/bdk/0001-bdk-chain-rusqlite-sqlite-int64-conversions.patch @@ -0,0 +1,155 @@ +diff --git a/crates/chain/src/rusqlite_impl.rs b/crates/chain/src/rusqlite_impl.rs +index 7e9e2f6f..d9f6a023 100644 +--- a/crates/chain/src/rusqlite_impl.rs ++++ b/crates/chain/src/rusqlite_impl.rs +@@ -10,6 +10,9 @@ use alloc::{ + sync::Arc, + vec::Vec, + }; ++use core::convert::TryFrom; ++use std::io; ++ + use bitcoin::consensus::{Decodable, Encodable}; + use rusqlite; + use rusqlite::named_params; +@@ -302,9 +305,9 @@ impl tx_graph::ChangeSet { + Ok(( + row.get::<_, Impl>("txid")?, + row.get::<_, Option>>("raw_tx")?, +- row.get::<_, Option>("first_seen")?, +- row.get::<_, Option>("last_seen")?, +- row.get::<_, Option>("last_evicted")?, ++ row.get::<_, Option>("first_seen")?, ++ row.get::<_, Option>("last_seen")?, ++ row.get::<_, Option>("last_evicted")?, + )) + })?; + for row in row_iter { +@@ -313,12 +316,42 @@ impl tx_graph::ChangeSet { + changeset.txs.insert(Arc::new(tx)); + } + if let Some(first_seen) = first_seen { ++ let first_seen = u64::try_from(first_seen).map_err(|_| { ++ rusqlite::Error::FromSqlConversionFailure( ++ 0, ++ rusqlite::types::Type::Integer, ++ Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "first_seen must be non-negative", ++ )), ++ ) ++ })?; + changeset.first_seen.insert(txid, first_seen); + } + if let Some(last_seen) = last_seen { ++ let last_seen = u64::try_from(last_seen).map_err(|_| { ++ rusqlite::Error::FromSqlConversionFailure( ++ 0, ++ rusqlite::types::Type::Integer, ++ Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "last_seen must be non-negative", ++ )), ++ ) ++ })?; + changeset.last_seen.insert(txid, last_seen); + } + if let Some(last_evicted) = last_evicted { ++ let last_evicted = u64::try_from(last_evicted).map_err(|_| { ++ rusqlite::Error::FromSqlConversionFailure( ++ 0, ++ rusqlite::types::Type::Integer, ++ Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "last_evicted must be non-negative", ++ )), ++ ) ++ })?; + changeset.last_evicted.insert(txid, last_evicted); + } + } +@@ -354,12 +387,22 @@ impl tx_graph::ChangeSet { + Ok(( + row.get::<_, Impl>("block_hash")?, + row.get::<_, u32>("block_height")?, +- row.get::<_, u64>("confirmation_time")?, ++ row.get::<_, i64>("confirmation_time")?, + row.get::<_, Impl>("txid")?, + )) + })?; + for row in row_iter { + let (hash, height, confirmation_time, Impl(txid)) = row?; ++ let confirmation_time = u64::try_from(confirmation_time).map_err(|_| { ++ rusqlite::Error::FromSqlConversionFailure( ++ 0, ++ rusqlite::types::Type::Integer, ++ Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "confirmation_time must be non-negative", ++ )), ++ ) ++ })?; + changeset.anchors.insert(( + ConfirmationBlockTime { + block_id: BlockId::from((&height, &hash.0)), +@@ -392,7 +435,12 @@ impl tx_graph::ChangeSet { + Self::TXS_TABLE_NAME, + ))?; + for (&txid, &first_seen) in &self.first_seen { +- let checked_time = first_seen.to_sql()?; ++ let checked_time = i64::try_from(first_seen).map_err(|_| { ++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "first_seen is too large for sqlite INTEGER", ++ ))) ++ })?; + statement.execute(named_params! { + ":txid": Impl(txid), + ":first_seen": Some(checked_time), +@@ -405,7 +453,12 @@ impl tx_graph::ChangeSet { + Self::TXS_TABLE_NAME, + ))?; + for (&txid, &last_seen) in &self.last_seen { +- let checked_time = last_seen.to_sql()?; ++ let checked_time = i64::try_from(last_seen).map_err(|_| { ++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "last_seen is too large for sqlite INTEGER", ++ ))) ++ })?; + statement.execute(named_params! { + ":txid": Impl(txid), + ":last_seen": Some(checked_time), +@@ -418,7 +471,12 @@ impl tx_graph::ChangeSet { + Self::TXS_TABLE_NAME, + ))?; + for (&txid, &last_evicted) in &self.last_evicted { +- let checked_time = last_evicted.to_sql()?; ++ let checked_time = i64::try_from(last_evicted).map_err(|_| { ++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "last_evicted is too large for sqlite INTEGER", ++ ))) ++ })?; + statement.execute(named_params! { + ":txid": Impl(txid), + ":last_evicted": Some(checked_time), +@@ -451,11 +509,17 @@ impl tx_graph::ChangeSet { + statement_txid.execute(named_params! { + ":txid": Impl(*txid) + })?; ++ let checked_confirmation_time = i64::try_from(anchor.confirmation_time).map_err(|_| { ++ rusqlite::Error::ToSqlConversionFailure(Box::new(io::Error::new( ++ io::ErrorKind::InvalidData, ++ "confirmation_time is too large for sqlite INTEGER", ++ ))) ++ })?; + statement.execute(named_params! { + ":txid": Impl(*txid), + ":block_height": anchor_block.height, + ":block_hash": Impl(anchor_block.hash), +- ":confirmation_time": anchor.confirmation_time, ++ ":confirmation_time": checked_confirmation_time, + })?; + } + diff --git a/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch b/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch new file mode 100644 index 000000000..e563586ba --- /dev/null +++ b/rust/patches/bdk/0002-bdk-remove-workspace-lints-in-patched-crates.patch @@ -0,0 +1,56 @@ +diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml +index 62d0f2b3..aebcc029 100644 +--- a/crates/chain/Cargo.toml ++++ b/crates/chain/Cargo.toml +@@ -12,9 +12,6 @@ readme = "README.md" + + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +-[lints] +-workspace = true +- + [dependencies] + bitcoin = { version = "0.32.0", default-features = false } + bdk_core = { path = "../core", version = "0.6.3", default-features = false } +diff --git a/crates/electrum/Cargo.toml b/crates/electrum/Cargo.toml +index 8fdd7823..0761ebfa 100644 +--- a/crates/electrum/Cargo.toml ++++ b/crates/electrum/Cargo.toml +@@ -9,9 +9,6 @@ description = "Fetch data from electrum in the form BDK accepts" + license = "MIT OR Apache-2.0" + readme = "README.md" + +-[lints] +-workspace = true +- + [dependencies] + bdk_core = { path = "../core", version = "0.6.1" } + electrum-client = { version = "0.24.0", features = [ "proxy" ], default-features = false } +diff --git a/crates/esplora/Cargo.toml b/crates/esplora/Cargo.toml +index 1e5906d0..0d99e95e 100644 +--- a/crates/esplora/Cargo.toml ++++ b/crates/esplora/Cargo.toml +@@ -11,9 +11,6 @@ readme = "README.md" + + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +-[lints] +-workspace = true +- + [dependencies] + bdk_core = { path = "../core", version = "0.6.3", default-features = false } + esplora-client = { version = "0.12.3", default-features = false } +diff --git a/crates/file_store/Cargo.toml b/crates/file_store/Cargo.toml +index 714c40e1..f1eb32a8 100644 +--- a/crates/file_store/Cargo.toml ++++ b/crates/file_store/Cargo.toml +@@ -10,9 +10,6 @@ keywords = ["bitcoin", "persist", "persistence", "bdk", "file"] + authors = ["Bitcoin Dev Kit Developers"] + readme = "README.md" + +-[lints] +-workspace = true +- + [dependencies] + bdk_core = { path = "../core", version = "0.6.1", features = ["serde"]} + bincode = { version = "1" } diff --git a/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch b/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch new file mode 100644 index 000000000..d033e63a7 --- /dev/null +++ b/rust/patches/bdk/0003-bdk-chain-align-rusqlite-with-arti.patch @@ -0,0 +1,13 @@ +diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml +index aebcc029..f50eec59 100644 +--- a/crates/chain/Cargo.toml ++++ b/crates/chain/Cargo.toml +@@ -19,7 +19,7 @@ serde = { version = "1", optional = true, features = ["derive", "rc"] } + miniscript = { version = "12.3.1", optional = true, default-features = false } + + # Feature dependencies +-rusqlite = { version = "0.31.0", features = ["bundled"], optional = true } ++rusqlite = { version = "0.36.0", features = ["bundled"], optional = true } + + [dev-dependencies] + rand = "0.8" diff --git a/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch b/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch new file mode 100644 index 000000000..942f251de --- /dev/null +++ b/rust/patches/bdk/0004-bdk-chain-allow-coverage-nightly-cfg.patch @@ -0,0 +1,12 @@ +diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs +index 0cb5b48d..a79bcf5b 100644 +--- a/crates/chain/src/lib.rs ++++ b/crates/chain/src/lib.rs +@@ -25,6 +25,7 @@ + )] + #![no_std] + #![warn(missing_docs)] ++#![allow(unexpected_cfgs)] + #![cfg_attr(coverage_nightly, feature(coverage_attribute))] + + pub use bitcoin; diff --git a/rust/src/database/global_config.rs b/rust/src/database/global_config.rs index a30c53dea..d634cc17a 100644 --- a/rust/src/database/global_config.rs +++ b/rust/src/database/global_config.rs @@ -10,7 +10,7 @@ use crate::{ color_scheme::ColorSchemeSelection, fiat::FiatCurrency, network::Network, - node::Node, + node::{Node, TorMode}, wallet::metadata::{WalletId, WalletMode}, }; @@ -27,6 +27,10 @@ pub enum GlobalConfigKey { SelectedNetwork, SelectedFiatCurrency, SelectedNode(Network), + UseTor, + TorMode, + TorExternalHost, + TorExternalPort, ColorScheme, AuthType, HashedPinCode, @@ -49,6 +53,10 @@ impl From for &'static str { GlobalConfigKey::SelectedNode(Network::Testnet) => "selected_node_testnet", GlobalConfigKey::SelectedNode(Network::Testnet4) => "selected_node_testnet4", GlobalConfigKey::SelectedNode(Network::Signet) => "selected_node_signet", + GlobalConfigKey::UseTor => "use_tor", + GlobalConfigKey::TorMode => "tor_mode", + GlobalConfigKey::TorExternalHost => "tor_external_host", + GlobalConfigKey::TorExternalPort => "tor_external_port", GlobalConfigKey::ColorScheme => "color_scheme", GlobalConfigKey::AuthType => "auth_type", GlobalConfigKey::HashedPinCode => "hashed_pin_code", @@ -111,6 +119,9 @@ impl GlobalConfigTable { Update::FiatCurrencyChanged ); + string_config_accessor!(pub tor_mode, GlobalConfigKey::TorMode, TorMode); + string_config_accessor!(pub tor_external_host, GlobalConfigKey::TorExternalHost, String); + string_config_accessor!(pub wipe_data_pin, GlobalConfigKey::WipeDataPin, String); string_config_accessor!(pub decoy_pin, GlobalConfigKey::DecoyPin, String); string_config_accessor!(priv_hashed_pin_code, GlobalConfigKey::HashedPinCode, String); @@ -247,6 +258,26 @@ impl GlobalConfigTable { serde_json::from_str(&node_json).unwrap_or_else(|_| Node::default(network)) } + pub fn use_tor(&self) -> bool { + self.get(GlobalConfigKey::UseTor).unwrap_or(None).unwrap_or_else(|| "false".to_string()) + == "true" + } + + pub fn set_use_tor(&self, use_tor: bool) -> Result<()> { + self.set(GlobalConfigKey::UseTor, use_tor.to_string()) + } + + pub fn tor_external_port(&self) -> u16 { + self.get(GlobalConfigKey::TorExternalPort) + .unwrap_or(None) + .and_then(|port| port.parse::().ok()) + .unwrap_or(9050) + } + + pub fn set_tor_external_port(&self, port: u16) -> Result<()> { + self.set(GlobalConfigKey::TorExternalPort, port.to_string()) + } + pub fn set_selected_node(&self, node: &Node) -> Result<()> { let network = node.network; let node_json = serde_json::to_string(node) @@ -317,6 +348,12 @@ impl GlobalConfigTable { } pub(crate) fn set(&self, key: GlobalConfigKey, value: String) -> Result<()> { + let should_stop_built_in_tor = match key { + GlobalConfigKey::TorMode => !matches!(value.parse::(), Ok(TorMode::BuiltIn)), + GlobalConfigKey::UseTor => value.eq_ignore_ascii_case("false"), + _ => false, + }; + let write_txn = self.db.begin_write().map_err(|error| Error::DatabaseAccess(error.to_string()))?; @@ -333,6 +370,10 @@ impl GlobalConfigTable { write_txn.commit().map_err(|error| Error::DatabaseAccess(error.to_string()))?; + if should_stop_built_in_tor { + crate::tor_runtime::request_stop_built_in_proxy(); + } + Updater::send_update(Update::DatabaseUpdated); Ok(()) diff --git a/rust/src/database/global_flag.rs b/rust/src/database/global_flag.rs index caf5bce28..673ec4a92 100644 --- a/rust/src/database/global_flag.rs +++ b/rust/src/database/global_flag.rs @@ -17,6 +17,7 @@ pub enum GlobalFlagKey { AcceptedTerms, BetaFeaturesEnabled, BetaImportExportEnabled, + TorSettingsDiscovered, } #[derive(Debug, Clone, uniffi::Object)] diff --git a/rust/src/database/macros.rs b/rust/src/database/macros.rs index de434f582..f89728585 100644 --- a/rust/src/database/macros.rs +++ b/rust/src/database/macros.rs @@ -19,6 +19,7 @@ macro_rules! string_config_accessor { } paste::paste! { + #[allow(dead_code)] $vis fn [](&self, value: $return_type) -> Result<(), Error> { let value_str = value.to_string(); self.set($key, value_str)?; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index aac6c57bc..e5754025e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -62,6 +62,7 @@ mod seed_qr; mod send_flow; mod signed_import; mod tap_card; +mod tor_runtime; mod transaction; mod transaction_watcher; mod ur; @@ -87,6 +88,7 @@ use cove_types::color; use cove_types::color_scheme; use cove_types::network; use cove_types::psbt; +use cove_util::ResultExt as _; use std::path::PathBuf; @@ -97,6 +99,13 @@ pub enum InitError { RootDataDirAlreadySet(String), } +#[derive(Debug, Clone, uniffi::Error, thiserror::Error)] +#[uniffi(flat_error)] +pub enum TorBootstrapError { + #[error("failed to initialize built-in tor proxy: {0}")] + BuiltInTor(String), +} + /// set root data directory before any database access /// required for Android to specify app-specific storage path #[uniffi::export] @@ -104,3 +113,20 @@ fn set_root_data_dir(path: String) -> Result<(), InitError> { cove_common::consts::set_root_data_dir(PathBuf::from(path)) .map_err(InitError::RootDataDirAlreadySet) } + +#[uniffi::export(async_runtime = "tokio")] +async fn ensure_built_in_tor_bootstrap() -> Result { + let endpoint = + tor_runtime::built_in_socks_endpoint().await.map_err_str(TorBootstrapError::BuiltInTor)?; + Ok(endpoint.to_string()) +} + +#[uniffi::export] +fn tor_connection_logs() -> Vec { + logging::tor_connection_logs() +} + +#[uniffi::export] +fn clear_tor_connection_logs() { + logging::clear_tor_connection_logs(); +} diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index b74edf402..74202751e 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -1,13 +1,9 @@ use crate::{ - database::{Database, wallet_data::WalletDataDb}, + database::{Database, global_config::GlobalConfigKey, wallet_data::WalletDataDb}, historical_price_service::HistoricalPriceService, manager::wallet_manager::{Error, SendFlowErrorAlert, WalletManagerError}, mnemonic, - node::{ - Node, - client::{NodeClient, NodeClientOptions}, - client_builder::NodeClientBuilder, - }, + node::{Node, client::NodeClient, client_builder::NodeClientBuilder}, transaction::{ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TxId}, transaction_watcher::TransactionWatcher, wallet::{ @@ -65,6 +61,7 @@ pub struct WalletActor { pub reconciler: Sender, pub wallet: Wallet, pub node_client: Option, + pub node_client_signature: Option, pub db: WalletDataDb, pub state: ActorState, @@ -181,6 +178,7 @@ impl WalletActor { seed, wallet, node_client: None, + node_client_signature: None, last_scan_finished: None, last_height_fetched: None, state: ActorState::Initial, @@ -911,8 +909,7 @@ impl WalletActor { let network = self.wallet.network; let node = Database::global().global_config.selected_node(); - let options = NodeClientOptions { batch_size: 1 }; - let client_builder = NodeClientBuilder { node, options }; + let client_builder = NodeClientBuilder::with_defaults(node, 1); let watcher = TransactionWatcher::new(self.addr.clone(), tx_id, client_builder, network); let addr = spawn_actor(watcher); @@ -1474,18 +1471,38 @@ impl WalletActor { Some(()) } + fn node_client_signature_for(node: &Node) -> String { + let global_config = Database::global().global_config.clone(); + let use_tor = global_config.use_tor(); + let tor_mode = + global_config.get(GlobalConfigKey::TorMode).ok().flatten().unwrap_or_default(); + let tor_external_host = + global_config.get(GlobalConfigKey::TorExternalHost).ok().flatten().unwrap_or_default(); + let tor_external_port = global_config.tor_external_port(); + + format!( + "node={node:?}|use_tor={use_tor}|tor_mode={tor_mode}|tor_external_host={tor_external_host}|tor_external_port={tor_external_port}" + ) + } + async fn node_client(&mut self) -> Result<&NodeClient, Error> { - let node_client = self.node_client.as_ref(); - if node_client.is_none() { - let node = Database::global().global_config.selected_node(); - let node_client = NodeClient::new(&node).await.map_err(|err| { + let selected_node = Database::global().global_config.selected_node(); + let selected_signature = Self::node_client_signature_for(&selected_node); + + let reuse_cached = self.node_client_signature.as_ref() == Some(&selected_signature); + if !reuse_cached { + self.node_client = None; + self.node_client_signature = None; + + let node_client = NodeClient::new(&selected_node).await.map_err(|err| { Error::NodeConnectionFailed(format!("failed to create node client: {err}")) })?; self.node_client = Some(node_client); + self.node_client_signature = Some(selected_signature); } - Ok(self.node_client.as_ref().expect("just checked")) + Ok(self.node_client.as_ref().expect("node client initialized")) } } diff --git a/rust/src/node.rs b/rust/src/node.rs index 1902ebc57..7d8e8c998 100644 --- a/rust/src/node.rs +++ b/rust/src/node.rs @@ -27,6 +27,30 @@ pub enum ApiType { Rpc, } +#[derive( + Debug, + Copy, + Clone, + Hash, + Eq, + PartialEq, + Default, + derive_more::Display, + strum::EnumString, + uniffi::Enum, + serde::Serialize, + serde::Deserialize, +)] +pub enum TorMode { + #[strum(serialize = "BuiltIn", serialize = "BUILT_IN")] + #[default] + BuiltIn, + #[strum(serialize = "Orbot", serialize = "ORBOT")] + Orbot, + #[strum(serialize = "External", serialize = "EXTERNAL")] + External, +} + #[derive( Debug, Clone, Hash, Eq, PartialEq, uniffi::Record, serde::Serialize, serde::Deserialize, )] diff --git a/rust/src/node/client.rs b/rust/src/node/client.rs index b14c1bad8..a48f6d2f0 100644 --- a/rust/src/node/client.rs +++ b/rust/src/node/client.rs @@ -18,9 +18,13 @@ use bdk_wallet::{ }, }; use bitcoin::{Transaction, Txid}; -use tracing::debug; +use tracing::{debug, info, warn}; -use crate::node::Node; +use crate::{ + database::Database, + node::{Node, TorMode}, + tor_runtime, +}; use super::{ApiType, client_builder::NodeClientBuilder}; @@ -79,48 +83,133 @@ pub enum Error { #[error("failed to get transaction: {0}")] ElectrumGetTransaction(electrum_client::Error), + + #[error("failed to resolve tor endpoint: {0}")] + ResolveTorEndpoint(String), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct NodeClientOptions { pub batch_size: usize, + pub use_tor: bool, + pub tor_mode: TorMode, + pub tor_external_host: String, + pub tor_external_port: u16, } -impl NodeClient { - pub async fn new(node: &Node) -> Result { - match node.api_type { - ApiType::Esplora => { - let client = esplora::EsploraClient::new_from_node(node)?; - Ok(Self::Esplora(client)) - } +impl Default for NodeClientOptions { + fn default() -> Self { + Self { + batch_size: ESPLORA_BATCH_SIZE, + use_tor: false, + tor_mode: TorMode::BuiltIn, + tor_external_host: "127.0.0.1".to_string(), + tor_external_port: 9050, + } + } +} - ApiType::Electrum => { - let client = electrum::ElectrumClient::new_from_node(node).await?; - Ok(Self::Electrum(client)) - } +impl NodeClientOptions { + pub async fn resolve_tor_endpoint(mut self) -> Result { + info!( + use_tor = self.use_tor, + tor_mode = ?self.tor_mode, + tor_external_host = %self.tor_external_host, + tor_external_port = self.tor_external_port, + "resolving tor endpoint" + ); + + if !self.use_tor { + info!("tor disabled; skipping tor endpoint resolution"); + return Ok(self); + } - ApiType::Rpc => { - // TODO: implement rpc check, with auth - todo!() + match self.tor_mode { + TorMode::External | TorMode::Orbot => { + if self.tor_external_host.is_empty() { + warn!("tor external host empty; defaulting to 127.0.0.1"); + self.tor_external_host = "127.0.0.1".to_string(); + } + if self.tor_external_port == 0 { + warn!("tor external port missing; defaulting to 9050"); + self.tor_external_port = 9050; + } + info!( + tor_mode = ?self.tor_mode, + tor_external_host = %self.tor_external_host, + tor_external_port = self.tor_external_port, + "using configured tor endpoint" + ); + } + TorMode::BuiltIn => { + info!("tor mode is built-in; requesting Arti socks endpoint"); + match tor_runtime::built_in_socks_endpoint().await { + Ok(endpoint) => { + self.tor_external_host = endpoint.ip().to_string(); + self.tor_external_port = endpoint.port(); + info!(%endpoint, "resolved built-in tor socks endpoint"); + } + Err(error) => { + tracing::error!("failed to initialize built-in tor runtime: {error}"); + return Err(Error::ResolveTorEndpoint(error.to_string())); + } + } } } + + Ok(self) + } +} + +impl NodeClient { + pub async fn new(node: &Node) -> Result { + let db = Database::global(); + let config = db.global_config(); + let tor_external_host = config + .tor_external_host() + .ok() + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "127.0.0.1".to_string()); + + let batch_size = match node.api_type { + ApiType::Electrum => ELECTRUM_BATCH_SIZE, + ApiType::Esplora | ApiType::Rpc => ESPLORA_BATCH_SIZE, + }; + + let options = NodeClientOptions { + batch_size, + use_tor: config.use_tor(), + tor_mode: config.tor_mode().unwrap_or_default(), + tor_external_host, + tor_external_port: config.tor_external_port(), + }; + + info!(node = %node.url, api_type = ?node.api_type, options = ?options, "creating node client with db-backed options"); + + let options = options.resolve_tor_endpoint().await?; + Self::new_with_options(node, options).await } pub async fn try_from_builder(builder: &NodeClientBuilder) -> Result { - let node_client = Self::new_with_options(&builder.node, builder.options).await?; + let node_client = Self::new_with_options(&builder.node, builder.options.clone()).await?; Ok(node_client) } pub async fn new_with_options(node: &Node, options: NodeClientOptions) -> Result { + info!(node = %node.url, api_type = ?node.api_type, options = ?options, "creating node client with explicit options"); + let options = options.resolve_tor_endpoint().await?; + + info!(node = %node.url, api_type = ?node.api_type, resolved_options = ?options, "node client options resolved"); + match node.api_type { ApiType::Esplora => { - let client = esplora::EsploraClient::new_from_node_and_options(node, options)?; + let client = esplora::EsploraClient::new_from_node_and_options(node, &options)?; Ok(Self::Esplora(client)) } ApiType::Electrum => { let client = - electrum::ElectrumClient::new_from_node_and_options(node, options).await?; + electrum::ElectrumClient::new_from_node_and_options(node, &options).await?; Ok(Self::Electrum(client)) } diff --git a/rust/src/node/client/electrum.rs b/rust/src/node/client/electrum.rs index 10abfa4c9..74da69122 100644 --- a/rust/src/node/client/electrum.rs +++ b/rust/src/node/client/electrum.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use bdk_electrum::{ BdkElectrumClient, - electrum_client::{self, Client, ElectrumApi as _, Param}, + electrum_client::{self, Client, Config, ConfigBuilder, ElectrumApi as _, Param, Socks5Config}, }; use bdk_wallet::chain::{ BlockId, ConfirmationBlockTime, TxGraph, @@ -17,13 +17,17 @@ use bitcoin::{Transaction, Txid, consensus::Decodable}; use serde::Deserialize; use serde_json::Value; use tap::TapFallible as _; +use tokio::time::sleep; use tracing::{debug, error, warn}; use super::{ELECTRUM_BATCH_SIZE, Error, NodeClientOptions}; -use crate::node::Node; +use crate::node::{Node, TorMode}; type ElectrumClientInner = BdkElectrumClient; +const BUILT_IN_TOR_CONNECT_RETRY_DELAYS_MS: [u64; 8] = + [500, 1000, 1500, 2000, 3000, 5000, 8000, 12000]; + #[derive(Debug, Deserialize)] struct ElectrumTransactionResponse { hex: String, @@ -49,24 +53,89 @@ impl ElectrumClient { } pub async fn new_from_node(node: &Node) -> Result { - Self::new_from_node_and_options(node, Self::default_options()).await + let options = Self::default_options(); + Self::new_from_node_and_options(node, &options).await } pub async fn new_from_node_and_options( node: &Node, - options: NodeClientOptions, + options: &NodeClientOptions, ) -> Result { let url = node.url.strip_suffix('/').unwrap_or(&node.url).to_string(); + debug!(url = %url, options = ?options, "creating electrum client from node and options"); + let config = Self::connection_config(options); + let retry_delays = if options.use_tor && matches!(options.tor_mode, TorMode::BuiltIn) { + BUILT_IN_TOR_CONNECT_RETRY_DELAYS_MS.as_slice() + } else { + &[] + }; - // use spawn_blocking for the synchronous TCP connection to avoid blocking the async runtime - let inner_client = cove_tokio::unblock::run_blocking(move || Client::new(&url)) - .await - .map_err(Error::CreateElectrumClient)?; + let mut attempt: usize = 0; + let inner_client = loop { + let conn_url = url.clone(); + let config = config.clone(); + + // use spawn_blocking for the synchronous TCP connection to avoid blocking the async runtime + match cove_tokio::unblock::run_blocking(move || Client::from_config(&conn_url, config)) + .await + { + Ok(client) => break client, + Err(error) => { + if Self::is_built_in_tor_bootstrap_socks_failure(&error) + && let Some(delay_ms) = retry_delays.get(attempt).copied() + { + warn!( + node_url = %url, + attempt = attempt + 1, + max_retries = retry_delays.len(), + delay_ms, + "electrum connect failed while built-in tor may still be bootstrapping; retrying" + ); + attempt += 1; + sleep(std::time::Duration::from_millis(delay_ms)).await; + continue; + } + + let built_in_tor_status = + if options.use_tor && matches!(options.tor_mode, TorMode::BuiltIn) { + crate::tor_runtime::built_in_status_summary() + } else { + "n/a".to_string() + }; + + error!( + node_url = %url, + tor_enabled = options.use_tor, + tor_mode = ?options.tor_mode, + tor_host = %options.tor_external_host, + tor_port = options.tor_external_port, + attempt = attempt + 1, + max_retries = retry_delays.len(), + built_in_tor_status = %built_in_tor_status, + "failed to create electrum client" + ); + + if options.use_tor + && matches!(options.tor_mode, TorMode::BuiltIn) + && Self::is_built_in_tor_bootstrap_socks_failure(&error) + { + let enriched = format!( + "{error}; built-in tor status: {built_in_tor_status}; this often means Tor bootstrap is still incomplete or bootstrap connectivity failed" + ); + return Err(Error::CreateElectrumClient(electrum_client::Error::Message( + enriched, + ))); + } + + return Err(Error::CreateElectrumClient(error)); + } + } + }; let bdk_client = BdkElectrumClient::new(inner_client); let client = Arc::new(bdk_client); - Ok(Self::new_with_options(client, options)) + Ok(Self::new_with_options(client, options.clone())) } pub async fn get_height(&self) -> Result { @@ -278,8 +347,30 @@ impl ElectrumClient { Ok(tx_id) } - const fn default_options() -> NodeClientOptions { - NodeClientOptions { batch_size: ELECTRUM_BATCH_SIZE } + fn default_options() -> NodeClientOptions { + NodeClientOptions { batch_size: ELECTRUM_BATCH_SIZE, ..NodeClientOptions::default() } + } + + fn connection_config(options: &NodeClientOptions) -> Config { + let socks5_addr = if options.use_tor { + let endpoint = format!("{}:{}", options.tor_external_host, options.tor_external_port); + debug!(endpoint = %endpoint, tor_mode = ?options.tor_mode, "electrum using socks5 endpoint"); + Some(endpoint) + } else { + debug!("electrum connecting without tor proxy"); + None + }; + + ConfigBuilder::new().socks5(socks5_addr.map(Socks5Config::new)).build() + } + + fn is_built_in_tor_bootstrap_socks_failure(error: &electrum_client::Error) -> bool { + matches!( + error, + electrum_client::Error::IOError(io_error) + if io_error.kind() == std::io::ErrorKind::Other + && io_error.to_string().contains("general SOCKS server failure") + ) } } @@ -292,7 +383,9 @@ impl std::fmt::Debug for ElectrumClient { #[cfg(test)] mod tests { use super::*; - use std::str::FromStr; + use bdk_electrum::electrum_client::{ElectrumApi, Param}; + use std::{str::FromStr, time::Duration}; + use tokio::time::timeout; #[tokio::test] #[ignore] // requires external network connection to blockstream electrum server @@ -318,4 +411,56 @@ mod tests { Err(e) => panic!("Fallback method failed: {e:?}"), } } + + #[tokio::test] + #[ignore] // requires local Tor SOCKS proxy and reachable onion electrum server + async fn test_onion_electrum_via_tor_proxy() { + cove_tokio::init(); + + let node = crate::node::Node { + url: "tcp://xotqmhnei2wy7fk423tekp62ilcxpawnf4aiqmnkfhuutfkimgpqk5qd.onion:50001" + .to_string(), + name: "onion-electrum".to_string(), + api_type: crate::node::ApiType::Electrum, + network: cove_types::network::Network::Bitcoin, + }; + + let options = NodeClientOptions { + batch_size: 10, + use_tor: true, + tor_mode: crate::node::TorMode::External, + tor_external_host: "127.0.0.1".to_string(), + tor_external_port: 9050, + }; + + let client = timeout( + Duration::from_secs(20), + ElectrumClient::new_from_node_and_options(&node, &options), + ) + .await + .expect("timed out creating electrum client") + .expect("failed to create electrum client through tor proxy"); + + let raw_version = timeout(Duration::from_secs(20), async { + cove_tokio::unblock::run_blocking({ + let inner = client.client.clone(); + move || { + inner.inner.raw_call( + "server.version", + [Param::String("cove-test".to_string()), Param::String("1.4".to_string())], + ) + } + }) + .await + }) + .await + .expect("timed out waiting for server.version response") + .expect("server.version call failed via onion electrum through tor proxy"); + + let response = + raw_version.as_array().expect("server.version response should be a JSON array"); + assert!(response.len() >= 2, "server.version response array too short"); + assert!(response[0].is_string(), "server software field must be a string"); + assert!(response[1].is_string(), "protocol version field must be a string"); + } } diff --git a/rust/src/node/client/esplora.rs b/rust/src/node/client/esplora.rs index 2d9a9bfbf..f53607dc4 100644 --- a/rust/src/node/client/esplora.rs +++ b/rust/src/node/client/esplora.rs @@ -24,29 +24,37 @@ pub struct EsploraClient { } impl EsploraClient { - pub const fn new(client: Arc) -> Self { - Self { client, options: NodeClientOptions { batch_size: ESPLORA_BATCH_SIZE } } + pub fn new(client: Arc) -> Self { + Self::new_with_options( + client, + NodeClientOptions { batch_size: ESPLORA_BATCH_SIZE, ..NodeClientOptions::default() }, + ) } pub fn new_from_node(node: &Node) -> Result { - let client = esplora_client::Builder::new(&node.url) - .build_async() - .map_err(Error::CreateEsploraClient)? - .into(); - - Ok(Self::new(client)) + let options = NodeClientOptions::default(); + Self::new_from_node_and_options(node, &options) } pub fn new_from_node_and_options( node: &Node, - options: NodeClientOptions, + options: &NodeClientOptions, ) -> Result { - let client = esplora_client::Builder::new(&node.url) - .build_async() - .map_err(Error::CreateEsploraClient)? - .into(); + debug!(url = %node.url, options = ?options, "creating esplora client from node and options"); + let mut builder = esplora_client::Builder::new(&node.url); + + if options.use_tor { + let proxy = + format!("socks5h://{}:{}", options.tor_external_host, options.tor_external_port); + debug!(proxy = %proxy, tor_mode = ?options.tor_mode, "esplora using socks proxy"); + builder = builder.proxy(&proxy); + } else { + debug!("esplora connecting without tor proxy"); + } + + let client = builder.build_async().map_err(Error::CreateEsploraClient)?.into(); - Ok(Self::new_with_options(client, options)) + Ok(Self::new_with_options(client, options.clone())) } pub const fn new_with_options(client: Arc, options: NodeClientOptions) -> Self { diff --git a/rust/src/node/client_builder.rs b/rust/src/node/client_builder.rs index 55f2f7868..e132c413b 100644 --- a/rust/src/node/client_builder.rs +++ b/rust/src/node/client_builder.rs @@ -1,4 +1,4 @@ -use crate::node::client::Error; +use crate::{database::Database, node::client::Error}; use super::{ Node, @@ -11,6 +11,26 @@ pub struct NodeClientBuilder { pub options: NodeClientOptions, } impl NodeClientBuilder { + pub fn with_defaults(node: Node, batch_size: usize) -> Self { + let db = Database::global(); + let config = db.global_config(); + let tor_external_host = config + .tor_external_host() + .ok() + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "127.0.0.1".into()); + + let options = NodeClientOptions { + batch_size, + use_tor: config.use_tor(), + tor_mode: config.tor_mode().unwrap_or_default(), + tor_external_host, + tor_external_port: config.tor_external_port(), + }; + + Self { node, options } + } + pub async fn build(&self) -> Result { let node_client = NodeClient::try_from_builder(self).await?; Ok(node_client) diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs index 175e14922..c4f9341c9 100644 --- a/rust/src/node_connect.rs +++ b/rust/src/node_connect.rs @@ -1,7 +1,14 @@ -use tracing::error; +use tracing::{error, info, warn}; use url::Url; -use crate::{database::Database, network::Network, node::Node}; +use crate::{ + database::{Database, global_flag::GlobalFlagKey}, + network::Network, + node::{ + ApiType, Node, + client::{NodeClient, NodeClientOptions}, + }, +}; use cove_macros::impl_default_for; use eyre::{Context, eyre}; @@ -124,7 +131,9 @@ impl NodeSelector { #[uniffi::method] pub async fn check_selected_node(&self, node: Node) -> Result<(), Error> { - node.check_url().await.map_err(|error| Error::NodeAccessError(format!("{error:?}")))?; + check_node_with_tor_inference(&node) + .await + .map_err(|error| Error::NodeAccessError(format!("{error:?}")))?; Ok(()) } @@ -138,9 +147,31 @@ impl NodeSelector { entered_name: String, ) -> Result { let node_type = name.to_ascii_lowercase(); + let hinted_api_type = if node_type.contains("electrum") { + ApiType::Electrum + } else if node_type.contains("esplora") { + ApiType::Esplora + } else { + error!("invalid node type: {node_type}"); + return Err(Error::ParseNodeUrlError("invalid node type".to_string())); + }; + let inferred_api_type = infer_api_type_from_url_hint(&url); + let api_type = match inferred_api_type { + Some(inferred) if inferred != hinted_api_type => { + warn!( + requested_type = ?hinted_api_type, + inferred_type = ?inferred, + url = %url, + "custom node type mismatched url; using type inferred from url", + ); + inferred + } + Some(inferred) => inferred, + None => hinted_api_type, + }; - let url = - parse_node_url(&url).map_err(|error| Error::ParseNodeUrlError(error.to_string()))?; + let url = parse_node_url(&url, api_type) + .map_err(|error| Error::ParseNodeUrlError(error.to_string()))?; if !url.domain().unwrap_or_default().contains('.') { return Err(Error::ParseNodeUrlError("invalid url, no domain".to_string())); @@ -154,13 +185,10 @@ impl NodeSelector { entered_name }; - let node = if node_type.contains("electrum") { - Node::new_electrum(name, url_string, self.network) - } else if node_type.contains("esplora") { - Node::new_esplora(name, url_string, self.network) - } else { - error!("invalid node type: {node_type}"); - Node::default(self.network) + let node = match api_type { + ApiType::Electrum => Node::new_electrum(name, url_string, self.network), + ApiType::Esplora => Node::new_esplora(name, url_string, self.network), + ApiType::Rpc => Node::default(self.network), }; Ok(node) @@ -169,16 +197,29 @@ impl NodeSelector { #[uniffi::method] /// Check the node url and set it as selected node if it is valid pub async fn check_and_save_node(&self, node: Node) -> Result<(), Error> { - node.check_url().await.map_err(|error| { + check_node_with_tor_inference(&node).await.map_err(|error| { tracing::warn!("error checking node: {error:?}"); Error::NodeAccessError(error.to_string()) })?; - Database::global() + let database = Database::global(); + + database .global_config .set_selected_node(&node) .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; + if node_implies_tor(&node) { + database + .global_flag + .set_bool_config(GlobalFlagKey::TorSettingsDiscovered, true) + .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; + database + .global_config + .set_use_tor(true) + .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; + } + Ok(()) } } @@ -237,7 +278,92 @@ fn node_list(network: Network) -> Vec { } } -fn parse_node_url(url: &str) -> eyre::Result { +fn node_implies_tor(node: &Node) -> bool { + Url::parse(&node.url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_string)) + .is_some_and(|host| host.ends_with(".onion")) +} + +async fn check_node_with_tor_inference(node: &Node) -> Result<(), crate::node::Error> { + let inferred_tor = node_implies_tor(node); + info!(node = %node.url, api_type = ?node.api_type, inferred_tor, "checking node with tor inference"); + + if !inferred_tor { + info!(node = %node.url, "node does not imply tor; checking directly"); + return node.check_url().await; + } + + let db = Database::global(); + let config = db.global_config(); + let tor_external_host = config + .tor_external_host() + .ok() + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "127.0.0.1".to_string()); + + let batch_size = match node.api_type { + ApiType::Electrum => 10, + ApiType::Esplora | ApiType::Rpc => 1, + }; + + let options = NodeClientOptions { + batch_size, + use_tor: true, + tor_mode: config.tor_mode().unwrap_or_default(), + tor_external_host, + tor_external_port: config.tor_external_port(), + }; + + info!(node = %node.url, options = ?options, "node implies tor; building tor-enabled node client"); + + let options = options.resolve_tor_endpoint().await?; + info!(resolved_options = ?options, "resolved tor endpoint for inferred tor node check"); + + let client = NodeClient::new_with_options(node, options).await?; + info!(node = %node.url, "running node check through tor-capable client"); + client.check_url().await?; + + Ok(()) +} + +fn parse_node_url(url: &str, api_type: ApiType) -> eyre::Result { + match api_type { + ApiType::Electrum => parse_electrum_url(url), + ApiType::Esplora | ApiType::Rpc => parse_http_url(url), + } +} + +fn infer_api_type_from_url_hint(url: &str) -> Option { + let lowered = url.trim().to_ascii_lowercase(); + + if lowered.is_empty() { + return None; + } + + if lowered.starts_with("tcp://") || lowered.starts_with("ssl://") { + return Some(ApiType::Electrum); + } + + if lowered.starts_with("http://") || lowered.starts_with("https://") { + return Some(ApiType::Esplora); + } + + if lowered.contains("://") { + return None; + } + + if lowered.contains('/') { + return Some(ApiType::Esplora); + } + + match lowered.rsplit_once(':') { + Some((_, "50001" | "50002")) => Some(ApiType::Electrum), + _ => None, + } +} + +fn parse_electrum_url(url: &str) -> eyre::Result { let url = url.replace("http://", "tcp://"); let url = url.replace("https://", "ssl://"); @@ -266,6 +392,13 @@ fn parse_node_url(url: &str) -> eyre::Result { _ => {} } + if !matches!(url.scheme(), "ssl" | "tcp") { + return Err(eyre!( + "invalid electrum url scheme `{}`; expected tcp:// or ssl://", + url.scheme(), + )); + } + // set the port to if not set, default to 50002 for ssl and 50001 for tcp match (url.port(), url.scheme()) { (Some(_), _) => {} @@ -279,6 +412,26 @@ fn parse_node_url(url: &str) -> eyre::Result { Ok(url) } +fn parse_http_url(url: &str) -> eyre::Result { + let url = if url.contains("://") { url.to_string() } else { format!("https://{url}") }; + + let mut url = Url::parse(&url)?; + + if !matches!(url.scheme(), "http" | "https") { + return Err(eyre!( + "invalid esplora url scheme `{}`; expected http:// or https://", + url.scheme(), + )); + } + + if url.port().is_none() { + let default_port = if url.scheme() == "http" { 80 } else { 443 }; + url.set_port(Some(default_port)).map_err(|()| eyre!("can't set port"))?; + } + + Ok(url) +} + #[uniffi::export] impl NodeSelection { fn to_node(&self) -> Node { diff --git a/rust/src/tor_runtime.rs b/rust/src/tor_runtime.rs new file mode 100644 index 000000000..0e443e46d --- /dev/null +++ b/rust/src/tor_runtime.rs @@ -0,0 +1,354 @@ +use std::{ + net::{Ipv4Addr, SocketAddr}, + path::PathBuf, +}; + +use tokio::{ + net::TcpStream, + sync::watch, + time::{Duration, sleep}, +}; + +use arti::cfg::ArtiConfig; +use arti::proxy::ListenProtocols; +use arti::run_proxy; +use arti_client::{TorClientConfig, config::TorClientConfigBuilder}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use tor_config::{ConfigurationSources, Listen, load::Buildable}; +use tor_config_path::CfgPath; +use tor_rtcompat::{PreferredRuntime, ToplevelBlockOn}; +use tracing::{debug, error, info, warn}; + +use cove_common::consts::ROOT_DATA_DIR; + +const BUILT_IN_TOR_SOCKS_PORT: u16 = 39050; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("failed to initialize built-in tor proxy: {0}")] + Proxy(String), +} + +#[derive(Debug, Clone)] +struct BuiltInTorState { + endpoint: Option, + launched: bool, + last_error: Option, + shutdown_tx: Option>, +} + +#[derive(Debug, Clone)] +struct BuiltInTorPaths { + state_dir: PathBuf, + cache_dir: PathBuf, + port_info_file: PathBuf, +} + +static BUILT_IN_TOR_STATE: Lazy> = Lazy::new(|| { + Mutex::new(BuiltInTorState { + endpoint: None, + launched: false, + last_error: None, + shutdown_tx: None, + }) +}); + +fn clear_built_in_state(reason: &str) { + let mut state = BUILT_IN_TOR_STATE.lock(); + state.endpoint = None; + state.launched = false; + state.shutdown_tx = None; + warn!(%reason, "cleared built-in tor state"); +} + +fn set_built_in_error(error: String) { + let mut state = BUILT_IN_TOR_STATE.lock(); + state.last_error = Some(error.clone()); + warn!(%error, "recorded built-in tor runtime error"); +} + +fn take_built_in_error() -> Option { + let mut state = BUILT_IN_TOR_STATE.lock(); + state.last_error.take() +} + +pub(crate) fn built_in_status_summary() -> String { + let state = BUILT_IN_TOR_STATE.lock(); + let endpoint = + state.endpoint.map(|value| value.to_string()).unwrap_or_else(|| "none".to_string()); + let last_error = state.last_error.clone().unwrap_or_else(|| "none".to_string()); + + format!("endpoint={endpoint}, launched={}, last_error={last_error}", state.launched) +} + +pub(crate) fn request_stop_built_in_proxy() -> bool { + let shutdown_tx = { + let state = BUILT_IN_TOR_STATE.lock(); + state.shutdown_tx.clone() + }; + + match shutdown_tx { + Some(tx) => { + if tx.send(true).is_err() { + warn!("failed to request built-in tor shutdown: runtime channel closed"); + return false; + } + info!("requested built-in tor shutdown"); + true + } + None => { + debug!("built-in tor shutdown requested but runtime is not active"); + false + } + } +} + +fn built_in_tor_paths() -> BuiltInTorPaths { + let tor_root = ROOT_DATA_DIR.join("tor"); + BuiltInTorPaths { + state_dir: tor_root.join("state"), + cache_dir: tor_root.join("cache"), + port_info_file: tor_root.join("public").join("port_info.json"), + } +} + +fn ensure_built_in_tor_dirs(paths: &BuiltInTorPaths) -> Result<(), Error> { + std::fs::create_dir_all(&paths.state_dir).map_err(|error| { + Error::Proxy(format!( + "failed to create built-in tor state dir {}: {error}", + paths.state_dir.display() + )) + })?; + + std::fs::create_dir_all(&paths.cache_dir).map_err(|error| { + Error::Proxy(format!( + "failed to create built-in tor cache dir {}: {error}", + paths.cache_dir.display() + )) + })?; + + if let Some(parent_dir) = paths.port_info_file.parent() { + std::fs::create_dir_all(parent_dir).map_err(|error| { + Error::Proxy(format!( + "failed to create built-in tor port-info dir {}: {error}", + parent_dir.display() + )) + })?; + } + + Ok(()) +} + +fn configure_built_in_tor_environment(paths: &BuiltInTorPaths) -> Result<(), Error> { + let tor_root = + paths.state_dir.parent().map(PathBuf::from).unwrap_or_else(|| ROOT_DATA_DIR.join("tor")); + let xdg_root = tor_root.join("xdg"); + let xdg_cache_home = xdg_root.join("cache"); + let xdg_data_home = xdg_root.join("data"); + let xdg_state_home = xdg_root.join("state"); + + for dir in [&xdg_cache_home, &xdg_data_home, &xdg_state_home] { + std::fs::create_dir_all(dir).map_err(|error| { + Error::Proxy(format!( + "failed to create built-in tor environment dir {}: {error}", + dir.display() + )) + })?; + } + + let set_env_if_missing = |key: &str, value: &PathBuf| { + if std::env::var_os(key).is_none() { + // SAFETY: we only set process environment entries during single-threaded startup + // of the built-in Tor runtime, before handing control to Arti internals. + unsafe { std::env::set_var(key, value) }; + info!(env = key, value = %value.display(), "configured built-in tor environment path"); + } + }; + + set_env_if_missing("HOME", &ROOT_DATA_DIR); + set_env_if_missing("XDG_CACHE_HOME", &xdg_cache_home); + set_env_if_missing("XDG_DATA_HOME", &xdg_data_home); + set_env_if_missing("XDG_STATE_HOME", &xdg_state_home); + set_env_if_missing("ARTI_CACHE", &paths.cache_dir); + set_env_if_missing("ARTI_LOCAL_DATA", &tor_root); + + Ok(()) +} + +fn build_tor_client_config() -> Result { + let paths = built_in_tor_paths(); + ensure_built_in_tor_dirs(&paths)?; + + info!( + state_dir = %paths.state_dir.display(), + cache_dir = %paths.cache_dir.display(), + port_info_file = %paths.port_info_file.display(), + "configuring built-in tor storage directories" + ); + + // Keep storage locations aligned with Arti's env-expanded defaults. This avoids + // spurious "Cannot change storage.* on a running client" warnings during reloads. + TorClientConfigBuilder::default().build().map_err(|error| { + Error::Proxy(format!("failed to build built-in tor client config: {error}")) + }) +} + +async fn wait_for_socks_listener(endpoint: SocketAddr) -> Result<(), Error> { + const MAX_ATTEMPTS: usize = 40; + const RETRY_DELAY_MS: u64 = 100; + + for attempt in 1..=MAX_ATTEMPTS { + if TcpStream::connect(endpoint).await.is_ok() { + info!(%endpoint, attempt, "built-in tor socks listener is ready"); + return Ok(()); + } + + { + let state = BUILT_IN_TOR_STATE.lock(); + if !state.launched && state.endpoint.is_none() { + let startup_error = state.last_error.clone().unwrap_or_else(|| { + "built-in tor runtime stopped before socks listener became ready".to_string() + }); + return Err(Error::Proxy(startup_error)); + } + } + + sleep(Duration::from_millis(RETRY_DELAY_MS)).await; + } + + if let Some(error) = take_built_in_error() { + return Err(Error::Proxy(error)); + } + + Err(Error::Proxy(format!( + "built-in tor socks listener not ready at {endpoint} after {MAX_ATTEMPTS} attempts" + ))) +} + +pub async fn built_in_socks_endpoint() -> Result { + let cached_endpoint = { + let state = BUILT_IN_TOR_STATE.lock(); + if let Some(endpoint) = state.endpoint { + debug!(%endpoint, launched = state.launched, "built-in tor endpoint already cached"); + Some(endpoint) + } else { + None + } + }; + + if let Some(endpoint) = cached_endpoint { + wait_for_socks_listener(endpoint).await?; + return Ok(endpoint); + } + + info!("built-in tor endpoint requested without cache; launching proxy"); + launch_built_in_proxy().await +} + +async fn launch_built_in_proxy() -> Result { + let endpoint = SocketAddr::from((Ipv4Addr::LOCALHOST, BUILT_IN_TOR_SOCKS_PORT)); + info!( + %endpoint, + configured_port = BUILT_IN_TOR_SOCKS_PORT, + "resolved built-in tor endpoint" + ); + + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + + { + let mut state = BUILT_IN_TOR_STATE.lock(); + if let Some(endpoint) = state.endpoint { + return Ok(endpoint); + } + + if state.launched { + warn!(%endpoint, "built-in tor marked launched but endpoint was missing; reusing configured endpoint"); + state.endpoint = Some(endpoint); + return Ok(endpoint); + } + + state.launched = true; + state.endpoint = Some(endpoint); + state.last_error = None; + state.shutdown_tx = Some(shutdown_tx); + } + + let paths = built_in_tor_paths(); + ensure_built_in_tor_dirs(&paths)?; + configure_built_in_tor_environment(&paths)?; + + let mut arti_builder = ArtiConfig::builder(); + arti_builder.proxy().socks_listen(Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT)); + arti_builder.storage().port_info_file(CfgPath::new_literal(&paths.port_info_file)); + arti_builder.application().watch_configuration(false); + info!(%endpoint, "configuring Arti built-in SOCKS listener"); + + let arti_config = arti_builder.build().map_err(|error| Error::Proxy(error.to_string()))?; + + let client_config = build_tor_client_config()?; + let socks_listen = Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT); + + std::thread::spawn(move || { + info!("starting built-in tor runtime thread"); + + let proxy_runtime = match PreferredRuntime::create() { + Ok(runtime) => { + info!("built-in tor runtime created"); + runtime + } + Err(error) => { + let message = format!("failed to create built-in tor runtime: {error}"); + error!("{message}"); + set_built_in_error(message); + clear_built_in_state("runtime creation failed"); + return; + } + }; + + info!("launching Arti SOCKS proxy task"); + let run = run_proxy( + proxy_runtime.clone(), + socks_listen, + Listen::new_none(), + ListenProtocols::SocksOnly, + ConfigurationSources::new_empty(), + arti_config, + client_config, + ); + + let run_result = proxy_runtime.block_on(async { + tokio::select! { + run_result = run => run_result, + changed = shutdown_rx.changed() => { + match changed { + Ok(()) => { + info!("built-in tor shutdown signal received"); + Ok(()) + } + Err(_) => { + info!("built-in tor shutdown channel closed"); + Ok(()) + } + } + } + } + }); + + if let Err(error) = run_result { + let message = format!("built-in tor proxy exited: {error:?}"); + error!("{message}"); + set_built_in_error(message); + clear_built_in_state("proxy exited with error"); + return; + } + + warn!("built-in tor proxy task returned without error"); + clear_built_in_state("proxy task returned"); + }); + + info!(%endpoint, "built-in tor launch initiated; waiting for socks listener"); + wait_for_socks_listener(endpoint).await?; + info!(%endpoint, "built-in tor endpoint ready"); + Ok(endpoint) +} diff --git a/rust/src/wallet_scanner.rs b/rust/src/wallet_scanner.rs index 8aafe08be..0545f7453 100644 --- a/rust/src/wallet_scanner.rs +++ b/rust/src/wallet_scanner.rs @@ -25,7 +25,7 @@ use crate::{ keychain::Keychain, manager::wallet_manager::{SingleOrMany, WalletManagerReconcileMessage}, mnemonic::MnemonicExt, - node::{client::NodeClientOptions, client_builder::NodeClientBuilder}, + node::client_builder::NodeClientBuilder, wallet::{ WalletAddressType, WalletError, metadata::{DiscoveryState, FoundAddress, FoundJson, WalletId, WalletMetadata}, @@ -162,9 +162,7 @@ impl WalletScanner { } let node = db.global_config().selected_node(); - let options = NodeClientOptions { batch_size: 1 }; - - let client_builder = NodeClientBuilder { node, options }; + let client_builder = NodeClientBuilder::with_defaults(node, 1); Ok(Self::new(metadata.id, client_builder, wallets, scan_source, reconciler)) } From 399c74957a8f80c27fe275221e652efd7d1cf587 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 22 Apr 2026 23:33:56 +0200 Subject: [PATCH 2/8] feat: Add Android Tor settings and status UI (#256) Wire Kotlin settings and wallet flows for Tor mode selection, status display, and supporting Android-side Tor UI/resources. --- android/app/src/main/AndroidManifest.xml | 4 + .../java/org/bitcoinppl/cove/AppManager.kt | 31 + .../java/org/bitcoinppl/cove/MainActivity.kt | 178 +++ .../java/org/bitcoinppl/cove/WalletManager.kt | 4 + .../SelectedWalletContainer.kt | 2 + .../SelectedWalletScreen.kt | 364 ++++- .../SettingsFlow/NetworkSettingsScreen.kt | 1407 +++++++++++++++++ .../flows/SettingsFlow/NodeSettingsScreen.kt | 210 ++- .../flows/SettingsFlow/OrbotPackageHelper.kt | 43 + .../cove/flows/SettingsFlow/TorNodeSupport.kt | 32 + .../cove/flows/SettingsFlow/TorUiState.kt | 35 + .../bitcoinppl/cove/tor/TorStatusSupport.kt | 254 +++ .../org/bitcoinppl/cove/views/SettingsItem.kt | 5 +- .../java/org/bitcoinppl/cove_core/cove.kt | 319 +++- .../src/main/res/drawable/icon_tor_onion.xml | 9 + android/app/src/main/res/values/strings.xml | 71 + 16 files changed, 2931 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt create mode 100644 android/app/src/main/res/drawable/icon_tor_onion.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ebad1a184..0396da315 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,10 @@ + + + + Network->Node navigation + var pendingNodeUrl by mutableStateOf("") + var pendingNodeName by mutableStateOf("") + var pendingNodeTypeName by mutableStateOf("") + var pendingNodeAwaitingTorSetup by mutableStateOf(false) + var pendingNodeTorValidated by mutableStateOf(false) + // prices and fees var prices: PriceResponse? by mutableStateOf(runCatching { rust.prices() }.getOrNull()) private set @@ -99,6 +106,7 @@ class AppManager private constructor() : FfiReconcile { Log.d(tag, "Initializing AppManager") rust.listenForUpdates(this) wallets = runCatching { Database().wallets().all() }.getOrElse { emptyList() } + warmupTorIfConfigured() } /** @@ -533,6 +541,29 @@ class AppManager private constructor() : FfiReconcile { .onFailure { Log.e(tag, "Unable to dispatch app action $action", it) } } + private fun warmupTorIfConfigured() { + val globalConfig = database.globalConfig() + if (!globalConfig.useTor()) { + return + } + + val mode = globalConfig.get(GlobalConfigKey.TorMode) + ?: org.bitcoinppl.cove_core.TorMode.BUILT_IN.name + if (mode != org.bitcoinppl.cove_core.TorMode.BUILT_IN.name) { + Log.d(tag, "Tor enabled with mode=$mode; no built-in warmup needed") + return + } + + mainScope.launch(Dispatchers.IO) { + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> + Log.d(tag, "Built-in Tor warmup started at $endpoint") + }.onFailure { error -> + Log.e(tag, "Failed to warm up built-in Tor on launch: ${error.message}", error) + } + } + } + companion object { @Volatile private var instance: AppManager? = null diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 14ef7be6e..56549ec45 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -81,10 +82,14 @@ import org.bitcoinppl.cove.cloudbackup.CloudBackupPresentationHost import org.bitcoinppl.cove.cloudbackup.ForegroundUiBridge import org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingContainer import org.bitcoinppl.cove.flows.TapSignerFlow.TapSignerContainer +import org.bitcoinppl.cove.flows.SettingsFlow.OrbotPackageHelper +import org.bitcoinppl.cove.flows.SettingsFlow.isOnionNodeUrl +import org.bitcoinppl.cove.flows.SettingsFlow.switchToFirstClearnetPresetNode import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.NfcScanSheet import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer +import org.bitcoinppl.cove.tor.testSocksEndpoint import org.bitcoinppl.cove.ui.theme.CoveTheme import org.bitcoinppl.cove.views.LockView import org.bitcoinppl.cove_core.bootstrap @@ -110,9 +115,11 @@ import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.RouteFactory import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.TapSignerRoute +import org.bitcoinppl.cove_core.TorMode import org.bitcoinppl.cove_core.Wallet import org.bitcoinppl.cove_core.WalletType import org.bitcoinppl.cove_core.CloudBackupStatus +import org.bitcoinppl.cove_core.NodeSelector import org.bitcoinppl.cove_core.types.ColorSchemeSelection internal enum class StartupMode { @@ -383,6 +390,98 @@ class MainActivity : FragmentActivity() { null } } + val context = LocalContext.current + val uiScope = rememberCoroutineScope() + var startupTorUnavailableMode by remember { mutableStateOf(null) } + var startupTorUnavailableEndpoint by remember { mutableStateOf("") } + var startupTorCheckCompleted by remember { mutableStateOf(false) } + + suspend fun fallbackToClearnetAndDisableTor() { + var fallbackNodeName: String? = null + if (isOnionNodeUrl(app.selectedNode.url)) { + val selector = NodeSelector() + try { + val fallbackResult = switchToFirstClearnetPresetNode(selector) + if (fallbackResult.isFailure) { + val reason = + fallbackResult.exceptionOrNull()?.message + ?: "unknown error" + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Fallback failed", + message = "Could not switch to a clearnet node: $reason", + ), + ) + return + } + fallbackNodeName = fallbackResult.getOrThrow().name + } finally { + selector.close() + } + } + + app.pendingNodeAwaitingTorSetup = false + app.pendingNodeTorValidated = false + app.pendingNodeUrl = "" + app.pendingNodeName = "" + app.pendingNodeTypeName = "" + app.database.globalConfig().setUseTor(false) + + val confirmation = + if (fallbackNodeName != null) { + "Switched to clearnet node \"$fallbackNodeName\" and disabled Tor." + } else { + "Disabled Tor." + } + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Tor disabled", + message = confirmation, + ), + ) + } + + LaunchedEffect(app.isTermsAccepted) { + if (!app.isTermsAccepted || startupTorCheckCompleted) { + return@LaunchedEffect + } + startupTorCheckCompleted = true + + val globalConfig = app.database.globalConfig() + val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false) + if (!useTor) { + return@LaunchedEffect + } + + val mode = + runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull() + ?: TorMode.BUILT_IN.name + + when (mode) { + TorMode.ORBOT.name -> { + val reachable = testSocksEndpoint("127.0.0.1", 9050, 1500).isSuccess + if (!reachable) { + startupTorUnavailableMode = TorMode.ORBOT + startupTorUnavailableEndpoint = "127.0.0.1:9050" + } + } + TorMode.EXTERNAL.name -> { + val host = + runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "127.0.0.1" + val port = runCatching { globalConfig.torExternalPort().toInt() }.getOrDefault(9050) + val reachable = testSocksEndpoint(host, port, 1500).isSuccess + if (!reachable) { + startupTorUnavailableMode = TorMode.EXTERNAL + startupTorUnavailableEndpoint = "$host:$port" + } + } + } + } // compute dark theme based on user preference val systemDarkTheme = isSystemInDarkTheme() @@ -457,6 +556,85 @@ class MainActivity : FragmentActivity() { ) } } + + if (startupTorUnavailableMode != null) { + AlertDialog( + onDismissRequest = { + startupTorUnavailableMode = null + startupTorUnavailableEndpoint = "" + }, + title = { + Text( + when (startupTorUnavailableMode) { + TorMode.ORBOT -> "Orbot is not active" + TorMode.EXTERNAL -> "External Tor proxy is unavailable" + else -> "Tor proxy unavailable" + }, + ) + }, + text = { + Text( + when (startupTorUnavailableMode) { + TorMode.ORBOT -> + "Tor mode is set to Orbot, but $startupTorUnavailableEndpoint is unavailable. " + + "Start Orbot or switch to a clearnet node and disable Tor." + TorMode.EXTERNAL -> + "Tor mode is set to external SOCKS5, but $startupTorUnavailableEndpoint is unavailable. " + + "Fix proxy settings or switch to a clearnet node." + else -> "Tor proxy is unavailable." + }, + ) + }, + confirmButton = { + Column(horizontalAlignment = Alignment.End) { + if (startupTorUnavailableMode == TorMode.ORBOT) { + TextButton( + onClick = { + startupTorUnavailableMode = null + startupTorUnavailableEndpoint = "" + val opened = OrbotPackageHelper.openOrbot(context) + if (!opened) { + OrbotPackageHelper.openInstallPage(context) + } + }, + ) { + Text("Open Orbot") + } + } + if (startupTorUnavailableMode == TorMode.EXTERNAL) { + TextButton( + onClick = { + startupTorUnavailableMode = null + startupTorUnavailableEndpoint = "" + app.pushRoute(Route.Settings(SettingsRoute.Network)) + }, + ) { + Text("Open network settings") + } + } + TextButton( + onClick = { + startupTorUnavailableMode = null + startupTorUnavailableEndpoint = "" + uiScope.launch { + fallbackToClearnetAndDisableTor() + } + }, + ) { + Text("Use clearnet node") + } + TextButton( + onClick = { + startupTorUnavailableMode = null + startupTorUnavailableEndpoint = "" + }, + ) { + Text("Ignore") + } + } + }, + ) + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt index 3c37a7cb4..9edf1c203 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/WalletManager.kt @@ -254,6 +254,7 @@ class WalletManager : } } } + errorAlert = null } is WalletManagerReconcileMessage.UpdatedTransactions -> { @@ -264,14 +265,17 @@ class WalletManager : is WalletLoadState.LOADED -> WalletLoadState.LOADED(message.v1) } + errorAlert = null } is WalletManagerReconcileMessage.ScanComplete -> { loadState = WalletLoadState.LOADED(message.v1) + errorAlert = null } is WalletManagerReconcileMessage.WalletBalanceChanged -> { balance = message.v1 + errorAlert = null } is WalletManagerReconcileMessage.UnsignedTransactionsChanged -> { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt index 5ed10714d..bbfad1667 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt @@ -82,6 +82,8 @@ fun SelectedWalletContainer( app.alertState = TaggedItem( AppAlertState.WalletDatabaseCorrupted(walletId = e.`id`, error = e.`error`) ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { android.util.Log.e(tag, "something went very wrong", e) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt index 838b277d3..db0de1951 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt @@ -14,10 +14,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.QrCode2 @@ -49,10 +59,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -70,16 +84,59 @@ import org.bitcoinppl.cove.ui.theme.ForceLightStatusBarIcons import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove.views.BitcoinShieldIcon import org.bitcoinppl.cove_core.FiatOrBtc +import org.bitcoinppl.cove_core.GlobalConfigKey import org.bitcoinppl.cove_core.HotWalletRoute import org.bitcoinppl.cove_core.NewWalletRoute import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.SettingsRoute +import org.bitcoinppl.cove_core.TorMode +import org.bitcoinppl.cove_core.WalletErrorAlert import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType +import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap +import org.bitcoinppl.cove_core.torConnectionLogs import org.bitcoinppl.cove_core.types.WalletId +import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot +import org.bitcoinppl.cove.tor.testSocksEndpoint import java.util.concurrent.atomic.AtomicBoolean +private enum class TorStatusDot { + Green, + Yellow, + Red, + Gray, +} + +private data class TorQuickStatus( + val enabled: Boolean = false, + val overall: TorStatusDot = TorStatusDot.Gray, + val torConnection: TorStatusDot = TorStatusDot.Gray, + val nodeReachable: TorStatusDot = TorStatusDot.Gray, + val nodeSynced: TorStatusDot = TorStatusDot.Gray, + val torMessage: String = "Tor disabled", + val nodeMessage: String = "Node status unavailable", + val syncMessage: String = "Sync status unavailable", + val logs: List = emptyList(), +) + +private fun TorStatusDot.color(): Color = + when (this) { + TorStatusDot.Green -> Color(0xFF34C759) + TorStatusDot.Yellow -> Color(0xFFFFC107) + TorStatusDot.Red -> Color(0xFFFF3B30) + TorStatusDot.Gray -> Color(0xFF9E9E9E) + } + +private fun parseEndpointHostPort(endpoint: String): Pair? { + val host = endpoint.substringBefore(':', "") + val port = endpoint.substringAfter(':', "").toIntOrNull() + if (host.isBlank() || port == null || port <= 0) { + return null + } + return host to port +} + @Preview(showBackground = true, backgroundColor = 0xFF0D1B2A) @Composable private fun SelectedWalletFunctionalPreview( @@ -176,6 +233,154 @@ fun SelectedWalletScreen( var showRenameMenu by remember { mutableStateOf(false) } val isColdWallet = manager.walletMetadata?.walletType == WalletType.COLD val isWatchOnly = manager.walletMetadata?.walletType == WalletType.WATCH_ONLY + val globalConfig = remember(app) { app.database.globalConfig() } + var showTorStatusMenu by remember { mutableStateOf(false) } + var torQuickStatus by remember { mutableStateOf(TorQuickStatus()) } + var builtInWarmupRequested by remember { mutableStateOf(false) } + var builtInEndpoint by remember { mutableStateOf("127.0.0.1:39050") } + val torBusyBlinkTransition = rememberInfiniteTransition(label = "torBusyBlink") + val torBusyBlinkAlpha = + torBusyBlinkTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "torBusyBlinkAlpha", + ) + val torOverallDotAlpha = + if (torQuickStatus.overall == TorStatusDot.Yellow) torBusyBlinkAlpha.value else 1f + + LaunchedEffect(manager, app, globalConfig) { + while (true) { + val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false) + val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull() + val torMode = runCatching { TorMode.valueOf(modeName ?: TorMode.BUILT_IN.name) } + .getOrDefault(TorMode.BUILT_IN) + val torLogs = runCatching { torConnectionLogs() }.getOrDefault(emptyList()) + val bootstrap = deriveBuiltInBootstrapSnapshot(torLogs) + + val torConnection: TorStatusDot + val torMessage: String + if (!useTor) { + torConnection = TorStatusDot.Red + torMessage = "Tor disabled" + } else { + when (torMode) { + TorMode.BUILT_IN -> { + if (!builtInWarmupRequested) { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> builtInEndpoint = endpoint } + } + val (builtInHost, builtInPort) = + parseEndpointHostPort(builtInEndpoint) + ?: ("127.0.0.1" to 39050) + val socksReady = testSocksEndpoint(builtInHost, builtInPort, 1200).isSuccess + torConnection = + when { + bootstrap.isReady && socksReady -> TorStatusDot.Green + bootstrap.hasError -> TorStatusDot.Red + else -> TorStatusDot.Yellow + } + torMessage = + when (torConnection) { + TorStatusDot.Green -> "Built-in Tor ready" + TorStatusDot.Red -> "Built-in Tor error" + else -> "Built-in Tor bootstrapping (${bootstrap.percent}%)" + } + } + TorMode.ORBOT -> { + val socksReady = testSocksEndpoint("127.0.0.1", 9050, 1200).isSuccess + torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Yellow + torMessage = + if (socksReady) { + "Orbot SOCKS endpoint reachable" + } else { + "Waiting for Orbot SOCKS endpoint" + } + } + TorMode.EXTERNAL -> { + val host = + runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "127.0.0.1" + val port = + runCatching { globalConfig.torExternalPort().toInt() } + .getOrDefault(9050) + val socksReady = testSocksEndpoint(host, port, 1200).isSuccess + torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Yellow + torMessage = + if (socksReady) { + "External SOCKS endpoint reachable" + } else { + "Waiting for external SOCKS endpoint" + } + } + } + } + + val nodeReachable = + when { + useTor && torConnection == TorStatusDot.Yellow -> TorStatusDot.Yellow + manager.errorAlert is WalletErrorAlert.NodeConnectionFailed -> TorStatusDot.Red + manager.loadState is WalletLoadState.LOADING -> TorStatusDot.Yellow + else -> TorStatusDot.Green + } + val nodeMessage = + when (nodeReachable) { + TorStatusDot.Green -> "Node reachable" + TorStatusDot.Red -> "Node connection failed" + TorStatusDot.Yellow -> "Checking node connection" + TorStatusDot.Gray -> "Node status unavailable" + } + + val nodeSynced = + when { + useTor && torConnection == TorStatusDot.Yellow -> TorStatusDot.Yellow + manager.loadState is WalletLoadState.LOADED -> TorStatusDot.Green + manager.loadState is WalletLoadState.SCANNING -> TorStatusDot.Yellow + manager.loadState == WalletLoadState.LOADING -> TorStatusDot.Yellow + else -> TorStatusDot.Gray + } + val syncMessage = + when (nodeSynced) { + TorStatusDot.Green -> "Node synced" + TorStatusDot.Yellow -> "Node syncing" + TorStatusDot.Red -> "Node sync failed" + TorStatusDot.Gray -> "Sync status unavailable" + } + + val overall = + when { + listOf(torConnection, nodeReachable, nodeSynced).all { it == TorStatusDot.Green } -> + TorStatusDot.Green + listOf(torConnection, nodeReachable, nodeSynced).any { it == TorStatusDot.Red } -> + TorStatusDot.Red + listOf(torConnection, nodeReachable, nodeSynced).all { it == TorStatusDot.Gray } -> + TorStatusDot.Gray + else -> TorStatusDot.Yellow + } + + torQuickStatus = + TorQuickStatus( + enabled = useTor, + overall = overall, + torConnection = torConnection, + nodeReachable = nodeReachable, + nodeSynced = nodeSynced, + torMessage = torMessage, + nodeMessage = nodeMessage, + syncMessage = syncMessage, + logs = torLogs.takeLast(6), + ) + + delay(2500) + } + } // force white status bar icons for midnight blue background ForceLightStatusBarIcons() @@ -267,7 +472,106 @@ fun SelectedWalletScreen( } }, actions = { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (torQuickStatus.enabled) { + Box { + Row( + modifier = + Modifier + .padding(end = 4.dp) + .clickable { showTorStatusMenu = true } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource(R.drawable.icon_tor_onion), + contentDescription = "Tor", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Icon( + imageVector = Icons.Filled.FiberManualRecord, + contentDescription = "Tor status", + tint = torQuickStatus.overall.color(), + modifier = + Modifier + .size(10.dp) + .alpha(torOverallDotAlpha), + ) + } + DropdownMenu( + expanded = showTorStatusMenu, + onDismissRequest = { showTorStatusMenu = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = + Modifier + .width(280.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Tor Network Status", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TorStatusRow("Tor Connection", torQuickStatus.torConnection, torQuickStatus.torMessage) + TorStatusRow("Node Reachable", torQuickStatus.nodeReachable, torQuickStatus.nodeMessage) + TorStatusRow("Node Synced", torQuickStatus.nodeSynced, torQuickStatus.syncMessage) + } + + if (torQuickStatus.logs.isNotEmpty()) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(CoveColor.midnightBlue.copy(alpha = 0.05f)) + .padding(8.dp), + ) { + Text( + text = "Recent Logs", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp), + ) + torQuickStatus.logs.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + + Text( + text = "Network Settings", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .fillMaxWidth() + .clickable { + showTorStatusMenu = false + app.pushRoute(Route.Settings(SettingsRoute.Network)) + }.padding(vertical = 4.dp), + textAlign = TextAlign.Center, + ) + } + } + } + } IconButton( onClick = onQrCode, modifier = Modifier.size(36.dp), @@ -581,3 +885,61 @@ private fun TransactionsLoadingView( } } } + +@Composable +private fun TorStatusRow( + title: String, + dot: TorStatusDot, + subtitle: String, +) { + val blinkTransition = rememberInfiniteTransition(label = "torRowBlink") + val blinkAlpha = + blinkTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "torRowBlinkAlpha", + ) + val dotAlpha = if (dot == TorStatusDot.Yellow) blinkAlpha.value else 1f + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 0.5.sp, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (dot == TorStatusDot.Green) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = dot.color(), + modifier = Modifier.size(16.dp).alpha(dotAlpha), + ) + } else { + Icon( + imageVector = Icons.Filled.FiberManualRecord, + contentDescription = null, + tint = dot.color(), + modifier = Modifier.size(14.dp).alpha(dotAlpha), + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt index a71fe32f9..da1770eb2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt @@ -1,46 +1,127 @@ package org.bitcoinppl.cove.flows.SettingsFlow +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.Button import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.bitcoinppl.cove.R import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection +import org.bitcoinppl.cove.views.MaterialSettingsItem import org.bitcoinppl.cove.views.SectionHeader +import org.bitcoinppl.cove_core.ApiType import org.bitcoinppl.cove_core.AppAction +import org.bitcoinppl.cove_core.Database +import org.bitcoinppl.cove_core.GlobalConfigKey +import org.bitcoinppl.cove_core.GlobalFlagKey +import org.bitcoinppl.cove_core.Node +import org.bitcoinppl.cove_core.NodeSelector +import org.bitcoinppl.cove_core.NodeSelectorException +import org.bitcoinppl.cove_core.TorMode as CoreTorMode +import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap +import org.bitcoinppl.cove_core.torConnectionLogs import org.bitcoinppl.cove_core.types.Network import org.bitcoinppl.cove_core.types.allNetworks +import org.bitcoinppl.cove.Log +import org.bitcoinppl.cove.ui.theme.CoveColor +import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot +import org.bitcoinppl.cove.tor.testSocksEndpoint +import org.bitcoinppl.cove.tor.testTorApiThroughSocks +import java.net.SocketTimeoutException +import java.time.LocalTime + +private enum class TorTestStepStatus { + Pending, + Running, + Passed, + Failed, +} + +private data class TorTestStep( + val key: String, + val title: String, + val detail: String, + val status: TorTestStepStatus = TorTestStepStatus.Pending, +) + +private data class TorConnectionTestState( + val running: Boolean = false, + val finished: Boolean = false, + val steps: List = emptyList(), + val logs: List = emptyList(), +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,15 +129,604 @@ fun NetworkSettingsScreen( app: org.bitcoinppl.cove.AppManager, modifier: Modifier = Modifier, ) { + val logTag = "NetworkSettingsScreen" + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current val networks = remember { allNetworks() } val selectedNetwork = app.selectedNetwork var pendingNetworkChange by remember { mutableStateOf(null) } + val database = remember { Database() } + val globalConfig = remember { database.globalConfig() } + val globalFlag = remember { database.globalFlag() } + val nodeSelector = remember { NodeSelector() } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val torSettingsDiscovered = remember { + globalFlag.getBoolConfig(GlobalFlagKey.TOR_SETTINGS_DISCOVERED) + } + + val persistedUseTor = remember { globalConfig.useTor() } + val persistedTorMode = remember { parseTorMode(globalConfig.get(GlobalConfigKey.TorMode)) } + val persistedExternalHost = remember { + globalConfig.get(GlobalConfigKey.TorExternalHost) + ?.takeIf { it.isNotBlank() } + ?: "127.0.0.1" + } + val persistedExternalPort = remember { globalConfig.torExternalPort().toString() } + + var uiState by + remember { + mutableStateOf( + TorUiState( + enabled = persistedUseTor, + mode = persistedTorMode, + externalHost = persistedExternalHost, + externalPort = persistedExternalPort, + externalValidationError = validateExternalConfig(persistedExternalHost, persistedExternalPort), + ), + ) + } + var showFullLogDialog by remember { mutableStateOf(false) } + var showModeDialog by remember { mutableStateOf(false) } + var showTorTestDialog by remember { mutableStateOf(false) } + var showDisableTorOnionDialog by remember { mutableStateOf(false) } + var rustTorLogCount by remember { mutableStateOf(0) } + var builtInWarmupRequested by remember { mutableStateOf(false) } + var torTestState by remember { mutableStateOf(TorConnectionTestState()) } + var autoPendingTestKey by remember { mutableStateOf(null) } + + fun appendTorLog(message: String) { + val timestamp = LocalTime.now().withNano(0) + val entry = "[$timestamp] $message" + val merged = (uiState.logLines + entry).takeLast(300) + uiState = uiState.copy(latestLogLine = message, logLines = merged) + } + + fun syncRustTorLogs() { + if (!uiState.enabled || uiState.mode != TorMode.BuiltIn) { + return + } + + runCatching { torConnectionLogs() } + .onSuccess { rustLogs -> + if (rustLogs.size < rustTorLogCount) { + rustTorLogCount = 0 + } + + val newLogs = rustLogs.drop(rustTorLogCount) + rustTorLogCount = rustLogs.size + + val merged = + if (newLogs.isEmpty()) { + uiState.logLines + } else { + (uiState.logLines + newLogs).takeLast(300) + } + val snapshot = deriveBuiltInBootstrapSnapshot(rustLogs) + val status = + when { + !uiState.enabled -> TorStatus.Disabled + snapshot.isReady -> TorStatus.Ready + snapshot.hasError -> TorStatus.Error + else -> TorStatus.Bootstrapping + } + + uiState = + uiState.copy( + status = status, + currentStep = snapshot.step, + latestLogLine = snapshot.lastLine, + logLines = merged, + progressPercent = snapshot.percent, + ) + }.onFailure { error -> + Log.e(logTag, "syncRustTorLogs failed: ${error.message}", error) + } + } + + fun clearPendingOnionDraft() { + app.pendingNodeAwaitingTorSetup = false + app.pendingNodeTorValidated = false + app.pendingNodeUrl = "" + app.pendingNodeName = "" + app.pendingNodeTypeName = "" + autoPendingTestKey = null + } + + fun disableTorState() { + globalConfig.setUseTor(false) + uiState = uiState.copy(enabled = false) + builtInWarmupRequested = false + } + + suspend fun testTorProxy(host: String, port: UShort): Result = + try { + syncRustTorLogs() + appendTorLog("Testing SOCKS endpoint $host:$port") + Log.d(logTag, "testTorProxy start: host=$host, port=$port") + testSocksEndpoint(host, port.toInt()).getOrThrow() + Log.d(logTag, "testTorProxy success: host=$host, port=$port") + appendTorLog("SOCKS endpoint reachable: $host:$port") + syncRustTorLogs() + Result.success(Unit) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.e(logTag, "testTorProxy failed: host=$host, port=$port, reason=${error.message}", error) + appendTorLog("SOCKS endpoint failed: $host:$port (${error.message ?: "unknown error"})") + syncRustTorLogs() + Result.failure(error) + } + + suspend fun resolveNodeForTorTest(): Node { + val (node, logMessage) = + withContext(Dispatchers.IO) { + if (app.pendingNodeAwaitingTorSetup && app.pendingNodeUrl.isNotBlank()) { + val typeName = app.pendingNodeTypeName.ifBlank { "Custom Electrum" } + Log.d(logTag, "resolveNodeForTorTest using pending node: url=${app.pendingNodeUrl}, type=$typeName, name=${app.pendingNodeName}") + val parsed = nodeSelector.parseCustomNode(app.pendingNodeUrl, typeName, app.pendingNodeName) + parsed to "Using pending node for Tor test: ${app.pendingNodeUrl}" + } else { + val selected = app.selectedNode + Log.d(logTag, "resolveNodeForTorTest using selected node: name=${selected.name}, apiType=${selected.apiType}, url=${selected.url}") + selected to "Using selected node for Tor test: ${selected.url}" + } + } + + appendTorLog(logMessage) + return node + } + + suspend fun runNodeTorTest(node: Node): Result = + try { + syncRustTorLogs() + appendTorLog("Checking node via Tor: ${node.url}") + Log.d(logTag, "runNodeTorTest start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") + withContext(Dispatchers.IO) { + nodeSelector.checkSelectedNode(node) + } + Log.d(logTag, "runNodeTorTest success: name=${node.name}, url=${node.url}") + appendTorLog("Node check passed: ${node.url}") + syncRustTorLogs() + Result.success(Unit) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.e(logTag, "runNodeTorTest failed: name=${node.name}, url=${node.url}, reason=${error.message}", error) + appendTorLog("Node check failed: ${node.url} (${error.message ?: "unknown error"})") + syncRustTorLogs() + Result.failure(error) + } + + fun appendTorTestLog(message: String) { + torTestState = torTestState.copy(logs = (torTestState.logs + message).takeLast(150)) + } + + fun updateTorTestStep( + stepKey: String, + status: TorTestStepStatus, + detail: String? = null, + ) { + torTestState = + torTestState.copy( + steps = + torTestState.steps.map { step -> + if (step.key != stepKey) { + step + } else { + step.copy( + status = status, + detail = detail ?: step.detail, + ) + } + }, + ) + } + + fun parseEndpointHostPort(endpoint: String): Pair? { + val host = endpoint.substringBefore(':', "") + val port = endpoint.substringAfter(':', "").toIntOrNull() + if (host.isBlank() || port == null || port !in 1..65535) { + return null + } + return host to port.toUShort() + } + + suspend fun runTorApiTestWithRetries( + host: String, + port: Int, + ): Result { + val maxAttempts = 5 + var timeoutMs = 8000 + var lastError: Throwable? = null + + repeat(maxAttempts) { index -> + val attempt = index + 1 + appendTorTestLog("Tor API check attempt $attempt/$maxAttempts (timeout=${timeoutMs}ms)") + + val result = testTorApiThroughSocks(host, port, timeoutMs) + if (result.isSuccess) { + return result + } + + val error = result.exceptionOrNull() + lastError = error + val timeoutLike = + error is SocketTimeoutException || + (error?.message?.lowercase()?.contains("timeout") == true) || + (error?.message?.lowercase()?.contains("timed out") == true) + + if (!timeoutLike || attempt >= maxAttempts) { + return Result.failure(error ?: IllegalStateException("unknown tor api error")) + } + + val snapshot = deriveBuiltInBootstrapSnapshot(runCatching { torConnectionLogs() }.getOrDefault(emptyList())) + appendTorTestLog( + "Tor API timed out; retrying while Tor bootstraps (${snapshot.percent}% ${snapshot.step.lowercase()})", + ) + syncRustTorLogs() + delay((attempt * 1200L).coerceAtMost(6000L)) + timeoutMs = (timeoutMs + 4000).coerceAtMost(30000) + } + + return Result.failure(lastError ?: IllegalStateException("unknown tor api error")) + } + + suspend fun runProgressiveTorTest() { + val endpoint: Pair = + try { + when (uiState.mode) { + TorMode.BuiltIn -> { + val endpointValue = ensureBuiltInTorBootstrap() + parseEndpointHostPort(endpointValue) + ?: ("127.0.0.1" to 39050u.toUShort()) + } + TorMode.Orbot -> "127.0.0.1" to 9050u.toUShort() + TorMode.External -> { + val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort) + if (validationError != null) { + uiState = uiState.copy(externalValidationError = validationError) + snackbarHostState.showSnackbar(validationError) + return + } + uiState.externalHost to (uiState.externalPort.toUShortOrNull() ?: 9050u.toUShort()) + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + appendTorLog("Failed to prepare Tor endpoint: ${error.message ?: "unknown error"}") + snackbarHostState.showSnackbar("Failed to prepare Tor endpoint: ${error.message ?: "unknown error"}") + return + } + + val (host, port) = endpoint + showTorTestDialog = true + torTestState = + TorConnectionTestState( + running = true, + finished = false, + steps = + listOf( + TorTestStep( + key = "proxy", + title = "Tor proxy reachable", + detail = "Checking SOCKS endpoint $host:$port", + ), + TorTestStep( + key = "api", + title = "Tor API reports Tor exit", + detail = "Checking torproject API over SOCKS", + ), + TorTestStep( + key = "node", + title = "Node reachable via Tor", + detail = "Checking selected node over Tor", + ), + ), + logs = listOf("Starting Tor connection test (${uiState.mode})"), + ) + + globalConfig.setUseTor(true) + when (uiState.mode) { + TorMode.BuiltIn -> { + globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.BUILT_IN.name) + if (!builtInWarmupRequested) { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpointValue -> + appendTorTestLog("Built-in Tor warmup requested at $endpointValue") + }.onFailure { error -> + appendTorTestLog("Built-in Tor warmup failed: ${error.message}") + } + } + } + TorMode.Orbot -> globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.ORBOT.name) + TorMode.External -> { + globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.EXTERNAL.name) + globalConfig.set(GlobalConfigKey.TorExternalHost, host) + globalConfig.setTorExternalPort(port) + } + } + + updateTorTestStep("proxy", TorTestStepStatus.Running) + val proxyResult = testTorProxy(host, port) + if (proxyResult.isFailure) { + val message = proxyResult.exceptionOrNull()?.message ?: "unknown error" + updateTorTestStep("proxy", TorTestStepStatus.Failed, "SOCKS check failed: $message") + appendTorTestLog("SOCKS check failed: $message") + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + torTestState = torTestState.copy(running = false, finished = true) + snackbarHostState.showSnackbar("Tor proxy unavailable: $message") + return + } + updateTorTestStep("proxy", TorTestStepStatus.Passed, "SOCKS endpoint reachable") + appendTorTestLog("SOCKS endpoint reachable") + + updateTorTestStep("api", TorTestStepStatus.Running) + val torApiResult = runTorApiTestWithRetries(host, port.toInt()) + if (torApiResult.isFailure) { + val message = torApiResult.exceptionOrNull()?.message ?: "unknown error" + updateTorTestStep("api", TorTestStepStatus.Failed, "Tor API request failed: $message") + appendTorTestLog("Tor API request failed: $message") + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + torTestState = torTestState.copy(running = false, finished = true) + snackbarHostState.showSnackbar("Tor API check failed: $message") + return + } + + val apiSnapshot = torApiResult.getOrThrow() + if (!apiSnapshot.isTor) { + updateTorTestStep("api", TorTestStepStatus.Failed, "Tor API response did not confirm Tor routing") + appendTorTestLog("Tor API did not confirm Tor routing: ${apiSnapshot.raw}") + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + torTestState = torTestState.copy(running = false, finished = true) + snackbarHostState.showSnackbar("Tor API check failed: traffic is not exiting through Tor") + return + } + updateTorTestStep("api", TorTestStepStatus.Passed, "Tor API confirmed Tor routing${apiSnapshot.ip?.let { " ($it)" } ?: ""}") + appendTorTestLog("Tor API confirmed Tor routing${apiSnapshot.ip?.let { " via $it" } ?: ""}") + + updateTorTestStep("node", TorTestStepStatus.Running) + val node = resolveNodeForTorTest() + val nodeResult = runNodeTorTest(node) + if (nodeResult.isFailure) { + val errorText = (nodeResult.exceptionOrNull() as? NodeSelectorException.NodeAccessException)?.v1 + ?: (nodeResult.exceptionOrNull()?.message ?: "unknown error") + updateTorTestStep("node", TorTestStepStatus.Failed, "Node check failed: $errorText") + appendTorTestLog("Node check failed: $errorText") + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + torTestState = torTestState.copy(running = false, finished = true) + snackbarHostState.showSnackbar("Node test failed: $errorText") + return + } + + updateTorTestStep("node", TorTestStepStatus.Passed, "Node reachable via Tor") + appendTorTestLog("Node reachable via Tor") + app.pendingNodeTorValidated = true + if (uiState.mode == TorMode.BuiltIn) { + syncRustTorLogs() + } else { + uiState = uiState.copy(status = TorStatus.Ready, progressPercent = 100) + } + appendTorLog( + when (uiState.mode) { + TorMode.BuiltIn -> "Built-in Tor validation passed" + TorMode.External -> "External Tor validation passed" + TorMode.Orbot -> "Orbot Tor validation passed" + }, + ) + if (app.pendingNodeAwaitingTorSetup) { + appendTorTestLog("Pending onion node validated; applying configuration") + appendTorLog("Pending onion node validated; applying configuration") + app.popRoute() + } + torTestState = torTestState.copy(running = false, finished = true) + snackbarHostState.showSnackbar("Tor connection test passed") + } + + val statusDisabledText = stringResource(R.string.tor_status_disabled) + val logDisabledText = stringResource(R.string.tor_log_disabled) + val torStatusTesting = stringResource(R.string.tor_status_testing) + val torStatusConfigured = stringResource(R.string.tor_status_configured) + val torStatusInstallOrbot = stringResource(R.string.tor_status_install_orbot) + val torStatusActionRequired = stringResource(R.string.tor_status_action_required) + val torActionSaveConfig = stringResource(R.string.tor_action_save_config) + val torActionTestConnection = stringResource(R.string.tor_action_test_connection) + val torActionOpenOrbot = stringResource(R.string.tor_action_open_orbot) + val torActionInstallOrbot = stringResource(R.string.tor_action_install_orbot) + val torErrorConfigInvalid = stringResource(R.string.tor_error_config_invalid) + val torSuccessConfigurationSaved = stringResource(R.string.tor_success_configuration_saved) + val torSuccessConnectionValid = stringResource(R.string.tor_success_connection_valid) + val torDisableOnionDialogTitle = stringResource(R.string.tor_disable_onion_dialog_title) + val torDisableOnionDialogBody = stringResource(R.string.tor_disable_onion_dialog_body) + val torDisableOnionDialogConfirm = stringResource(R.string.tor_disable_onion_dialog_confirm) + val torDisableOnionDialogCancel = stringResource(R.string.tor_disable_onion_dialog_cancel) + val torFallbackFailed = stringResource(R.string.tor_fallback_failed) + val torFallbackApplied = stringResource(R.string.tor_fallback_applied) + val copiedText = stringResource(R.string.btn_copied) + + LaunchedEffect(Unit) { + rustTorLogCount = 0 + appendTorLog("Opened Tor connection logs") + val (status, version) = OrbotPackageHelper.detect(context) + uiState = uiState.copy(orbotStatus = status, orbotVersion = version) + appendTorLog( + when (status) { + OrbotStatus.Detected -> "Orbot detected${version?.let { " ($it)" } ?: ""}" + OrbotStatus.NotDetected -> "Orbot not detected" + OrbotStatus.Checking -> "Checking Orbot status" + }, + ) + if (uiState.enabled && uiState.mode == TorMode.BuiltIn && !builtInWarmupRequested) { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> + appendTorLog("Built-in Tor bootstrap started at $endpoint") + }.onFailure { error -> + appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}") + } + } + syncRustTorLogs() + } + + LaunchedEffect(uiState.enabled, uiState.mode) { + if (!uiState.enabled || uiState.mode != TorMode.BuiltIn) { + return@LaunchedEffect + } + + if (!builtInWarmupRequested) { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> + appendTorLog("Built-in Tor bootstrap started at $endpoint") + }.onFailure { error -> + appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}") + } + } + + while (uiState.enabled && uiState.mode == TorMode.BuiltIn) { + syncRustTorLogs() + delay(1000) + } + } + + LaunchedEffect(uiState.enabled, uiState.mode, uiState.orbotStatus) { + if (!uiState.enabled) { + uiState = + uiState.copy( + status = TorStatus.Disabled, + progressPercent = 0, + currentStep = statusDisabledText, + latestLogLine = logDisabledText, + ) + appendTorLog("Tor disabled") + return@LaunchedEffect + } + + val awaitingValidation = !app.pendingNodeTorValidated + + when (uiState.mode) { + TorMode.BuiltIn -> { + syncRustTorLogs() + } + + TorMode.External -> { + val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort) + uiState = + uiState.copy( + status = if (validationError == null) TorStatus.Bootstrapping else TorStatus.Error, + progressPercent = if (validationError == null) 50 else 0, + currentStep = if (validationError == null) torActionSaveConfig else torErrorConfigInvalid, + latestLogLine = + if (validationError == null) { + "${uiState.externalHost}:${uiState.externalPort}" + } else { + validationError + }, + ) + appendTorLog( + if (validationError == null) { + "External Tor selected: ${uiState.externalHost}:${uiState.externalPort}" + } else { + "External Tor config invalid: $validationError" + }, + ) + } + + TorMode.Orbot -> { + if (uiState.orbotStatus == OrbotStatus.Detected) { + uiState = + uiState.copy( + status = if (awaitingValidation) TorStatus.Bootstrapping else TorStatus.Ready, + progressPercent = if (awaitingValidation) 50 else 100, + currentStep = if (awaitingValidation) torActionOpenOrbot else torStatusConfigured, + latestLogLine = if (awaitingValidation) torActionTestConnection else torSuccessConnectionValid, + ) + appendTorLog( + if (awaitingValidation) { + "Orbot mode selected, waiting for connection test" + } else { + "Orbot mode validated" + }, + ) + } else { + uiState = + uiState.copy( + status = TorStatus.Error, + progressPercent = 0, + currentStep = torStatusInstallOrbot, + latestLogLine = torActionInstallOrbot, + ) + appendTorLog("Orbot mode selected but Orbot is not installed") + } + } + } + } + + LaunchedEffect( + uiState.enabled, + uiState.mode, + uiState.status, + uiState.orbotStatus, + uiState.externalHost, + uiState.externalPort, + app.pendingNodeAwaitingTorSetup, + app.pendingNodeUrl, + app.pendingNodeTorValidated, + torTestState.running, + ) { + if (!app.pendingNodeAwaitingTorSetup || app.pendingNodeUrl.isBlank()) { + autoPendingTestKey = null + return@LaunchedEffect + } + if (!uiState.enabled || app.pendingNodeTorValidated || torTestState.running) { + return@LaunchedEffect + } + + val flowKey = "${uiState.mode}|${app.pendingNodeUrl}|${uiState.externalHost}:${uiState.externalPort}" + if (autoPendingTestKey == flowKey) { + return@LaunchedEffect + } + + val readyForAutoTest = + when (uiState.mode) { + TorMode.BuiltIn -> uiState.status == TorStatus.Ready + TorMode.Orbot -> + uiState.orbotStatus == OrbotStatus.Detected && + testSocksEndpoint("127.0.0.1", 9050, 1200).isSuccess + TorMode.External -> { + val externalPort = uiState.externalPort.toIntOrNull() + validateExternalConfig(uiState.externalHost, uiState.externalPort) == null && + externalPort != null && + testSocksEndpoint(uiState.externalHost, externalPort, 1200).isSuccess + } + } + + if (!readyForAutoTest) { + return@LaunchedEffect + } + + autoPendingTestKey = flowKey + appendTorLog("Pending onion node detected; starting automatic Tor validation") + scope.launch { + runProgressiveTorTest() + } + } + Scaffold( modifier = modifier .fillMaxSize() .padding(WindowInsets.safeDrawing.asPaddingValues()), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = @Composable { TopAppBar( title = { @@ -103,10 +773,477 @@ fun NetworkSettingsScreen( } } } + + if (torSettingsDiscovered) { + SectionHeader(stringResource(R.string.tor_section_privacy), showDivider = false) + MaterialSection { + Column { + MaterialSettingsItem( + title = stringResource(R.string.tor_use_tor_title), + subtitle = stringResource(R.string.tor_use_tor_subtitle), + icon = Icons.Default.Lock, + isSwitch = true, + switchCheckedState = uiState.enabled, + onCheckChanged = { enabled -> + Log.d(logTag, "toggle useTor: enabled=$enabled") + if (!enabled) { + val activeNodeIsOnion = isOnionNodeUrl(app.selectedNode.url) + val pendingOnionExists = + app.pendingNodeAwaitingTorSetup && + app.pendingNodeUrl.isNotBlank() && + isOnionNodeUrl(app.pendingNodeUrl) + if (activeNodeIsOnion || pendingOnionExists) { + showDisableTorOnionDialog = true + return@MaterialSettingsItem + } + + clearPendingOnionDraft() + disableTorState() + appendTorLog("Tor disabled") + return@MaterialSettingsItem + } + + globalConfig.setUseTor(true) + uiState = uiState.copy(enabled = true) + if (uiState.mode == TorMode.BuiltIn && !builtInWarmupRequested) { + scope.launch { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> + appendTorLog("Built-in Tor bootstrap started at $endpoint") + }.onFailure { error -> + appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}") + } + } + } + }, + ) + MaterialDivider() + val modeEnabled = uiState.enabled + val modeSubtitle = when (uiState.mode) { + TorMode.BuiltIn -> stringResource(R.string.tor_mode_builtin) + TorMode.Orbot -> stringResource(R.string.tor_mode_orbot) + TorMode.External -> stringResource(R.string.tor_mode_external) + } + MaterialSettingsItem( + title = stringResource(R.string.tor_mode_title), + subtitle = modeSubtitle, + icon = Icons.Default.Public, + modifier = Modifier.alpha(if (modeEnabled) 1f else 0.5f), + onClick = if (modeEnabled) { { showModeDialog = true } } else null, + ) + MaterialDivider() + val statusLabel = when (uiState.status) { + TorStatus.Disabled -> stringResource(R.string.tor_status_disabled) + TorStatus.Bootstrapping -> torStatusTesting + TorStatus.Ready -> torStatusConfigured + TorStatus.Error -> torStatusActionRequired + } + MaterialSettingsItem( + title = stringResource(R.string.tor_status_title), + subtitle = statusLabel, + leadingContent = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + }, + modifier = Modifier.alpha(if (modeEnabled) 1f else 0.5f), + trailingContent = { + if (uiState.enabled && uiState.status == TorStatus.Ready) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } + + if (torSettingsDiscovered && uiState.enabled) { + if (uiState.mode == TorMode.BuiltIn) { + SectionHeader(stringResource(R.string.tor_section_bootstrap), showDivider = false) + MaterialSection { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tor_progress_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "${uiState.progressPercent}%", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + LinearProgressIndicator( + progress = { uiState.progressPercent / 100f }, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + Text( + text = "${stringResource(R.string.tor_current_step_prefix)} ${uiState.currentStep}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 12.dp), + ) + Text( + text = "${stringResource(R.string.tor_latest_log_prefix)} ${uiState.latestLogLine}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + scope.launch { + appendTorLog("Built-in Tor connection test requested") + Log.d(logTag, "built-in test connection tapped: pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}") + runProgressiveTorTest() + } + }, + modifier = Modifier.weight(1f), + ) { + Text(torActionTestConnection) + } + } + } + } + + MaterialSection { + Column { + MaterialSettingsItem( + title = stringResource(R.string.tor_view_full_log_title), + subtitle = stringResource(R.string.tor_view_full_log_subtitle), + icon = Icons.Default.Terminal, + onClick = { showFullLogDialog = true }, + ) + } + } + } + + if (uiState.mode == TorMode.External) { + SectionHeader(stringResource(R.string.tor_section_external), showDivider = false) + MaterialSection { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + text = stringResource(R.string.tor_external_config_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + OutlinedTextField( + value = uiState.externalHost, + onValueChange = { value -> + uiState = + uiState.copy( + externalHost = value, + externalValidationError = validateExternalConfig(value, uiState.externalPort), + ) + }, + label = { Text(stringResource(R.string.tor_external_host_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.externalPort, + onValueChange = { value -> + uiState = + uiState.copy( + externalPort = value, + externalValidationError = validateExternalConfig(uiState.externalHost, value), + ) + }, + label = { Text(stringResource(R.string.tor_external_port_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = uiState.externalValidationError != null, + supportingText = { + if (uiState.externalValidationError != null) { + Text(uiState.externalValidationError!!) + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort) + if (validationError != null) { + uiState = uiState.copy(externalValidationError = validationError) + scope.launch { snackbarHostState.showSnackbar(validationError) } + return@Button + } + + Log.d(logTag, "save external tor config: host=${uiState.externalHost}, port=${uiState.externalPort}") + globalConfig.set(GlobalConfigKey.TorExternalHost, uiState.externalHost) + uiState.externalPort.toUShortOrNull()?.let { globalConfig.setTorExternalPort(it) } + app.pendingNodeTorValidated = false + appendTorLog("Saved external Tor config: ${uiState.externalHost}:${uiState.externalPort}") + scope.launch { snackbarHostState.showSnackbar(torSuccessConfigurationSaved) } + }, + modifier = Modifier.weight(1f), + ) { + Text(torActionSaveConfig) + } + + Button( + onClick = { + val validationError = validateExternalConfig(uiState.externalHost, uiState.externalPort) + if (validationError != null) { + uiState = uiState.copy(externalValidationError = validationError) + scope.launch { snackbarHostState.showSnackbar(validationError) } + return@Button + } + + scope.launch { + appendTorLog("External Tor connection test requested for ${uiState.externalHost}:${uiState.externalPort}") + Log.d(logTag, "external test connection tapped: host=${uiState.externalHost}, port=${uiState.externalPort}, pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}") + runProgressiveTorTest() + } + }, + modifier = Modifier.weight(1f), + ) { + Text(torActionTestConnection) + } + } + } + } + } + + if (uiState.mode == TorMode.Orbot) { + SectionHeader(stringResource(R.string.tor_orbot_status_title), showDivider = false) + MaterialSection { + Column { + val orbotTitle = when (uiState.orbotStatus) { + OrbotStatus.Checking -> stringResource(R.string.tor_orbot_checking) + OrbotStatus.Detected -> { + val version = uiState.orbotVersion + if (version.isNullOrBlank()) { + stringResource(R.string.tor_orbot_detected) + } else { + stringResource(R.string.tor_orbot_detected_version, version) + } + } + OrbotStatus.NotDetected -> stringResource(R.string.tor_orbot_not_detected) + } + val orbotSubtitle = if (uiState.orbotStatus == OrbotStatus.Detected) { + stringResource(R.string.tor_open_orbot_subtitle) + } else { + stringResource(R.string.tor_install_orbot_subtitle) + } + MaterialSettingsItem( + title = orbotTitle, + subtitle = orbotSubtitle, + icon = Icons.Default.Settings, + onClick = { + if (uiState.orbotStatus == OrbotStatus.Detected) { + OrbotPackageHelper.openOrbot(context) + } else { + OrbotPackageHelper.openInstallPage(context) + } + }, + ) + MaterialDivider() + MaterialSettingsItem( + title = stringResource(R.string.tor_detect_orbot_title), + subtitle = stringResource(R.string.tor_detect_orbot_subtitle), + icon = Icons.Default.Refresh, + onClick = { + val (status, version) = OrbotPackageHelper.detect(context) + uiState = uiState.copy(orbotStatus = status, orbotVersion = version) + appendTorLog( + when (status) { + OrbotStatus.Detected -> "Detected Orbot${version?.let { " ($it)" } ?: ""}" + OrbotStatus.NotDetected -> "Orbot not detected" + OrbotStatus.Checking -> "Checking Orbot status" + }, + ) + }, + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (uiState.orbotStatus == OrbotStatus.Detected) { + OrbotPackageHelper.openOrbot(context) + } else { + OrbotPackageHelper.openInstallPage(context) + } + }, + modifier = Modifier.weight(1f), + ) { + Text(if (uiState.orbotStatus == OrbotStatus.Detected) torActionOpenOrbot else torActionInstallOrbot) + } + + Button( + onClick = { + if (uiState.orbotStatus != OrbotStatus.Detected) { + scope.launch { snackbarHostState.showSnackbar(torStatusInstallOrbot) } + return@Button + } + + scope.launch { + appendTorLog("Orbot Tor connection test requested") + Log.d(logTag, "orbot test connection tapped: pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}") + runProgressiveTorTest() + } + }, + modifier = Modifier.weight(1f), + ) { + Text(torActionTestConnection) + } + } + } + } + } + } } }, ) + if (showModeDialog) { + AlertDialog( + onDismissRequest = { showModeDialog = false }, + title = { Text(stringResource(R.string.tor_mode_title)) }, + text = { + Column { + TorModeOption( + title = stringResource(R.string.tor_mode_builtin), + selected = uiState.mode == TorMode.BuiltIn, + onClick = { + Log.d(logTag, "set tor mode: BUILT_IN") + globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.BUILT_IN.name) + app.pendingNodeTorValidated = false + autoPendingTestKey = null + uiState = uiState.copy(mode = TorMode.BuiltIn) + appendTorLog("Switched Tor mode to built-in") + builtInWarmupRequested = false + if (uiState.enabled) { + scope.launch { + builtInWarmupRequested = true + runCatching { ensureBuiltInTorBootstrap() } + .onSuccess { endpoint -> + appendTorLog("Built-in Tor bootstrap started at $endpoint") + }.onFailure { error -> + appendTorLog("Built-in Tor bootstrap failed: ${error.message ?: "unknown error"}") + } + } + } + showModeDialog = false + }, + ) + TorModeOption( + title = stringResource(R.string.tor_mode_orbot), + selected = uiState.mode == TorMode.Orbot, + onClick = { + Log.d(logTag, "set tor mode: ORBOT") + globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.ORBOT.name) + app.pendingNodeTorValidated = false + autoPendingTestKey = null + uiState = uiState.copy(mode = TorMode.Orbot) + appendTorLog("Switched Tor mode to Orbot") + showModeDialog = false + }, + ) + TorModeOption( + title = stringResource(R.string.tor_mode_external), + selected = uiState.mode == TorMode.External, + onClick = { + Log.d(logTag, "set tor mode: EXTERNAL") + globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.EXTERNAL.name) + app.pendingNodeTorValidated = false + autoPendingTestKey = null + uiState = uiState.copy(mode = TorMode.External) + appendTorLog("Switched Tor mode to external proxy") + showModeDialog = false + }, + ) + } + }, + confirmButton = { + TextButton(onClick = { showModeDialog = false }) { + Text(stringResource(R.string.btn_cancel)) + } + }, + ) + } + + if (showDisableTorOnionDialog) { + AlertDialog( + onDismissRequest = { showDisableTorOnionDialog = false }, + title = { Text(torDisableOnionDialogTitle) }, + text = { Text(torDisableOnionDialogBody) }, + confirmButton = { + TextButton( + onClick = { + showDisableTorOnionDialog = false + scope.launch { + if (isOnionNodeUrl(app.selectedNode.url)) { + val fallbackResult = switchToFirstClearnetPresetNode(nodeSelector) + if (fallbackResult.isFailure) { + val reason = + fallbackResult.exceptionOrNull()?.message + ?: "unknown error" + appendTorLog("Unable to disable Tor: clearnet fallback failed ($reason)") + snackbarHostState.showSnackbar("$torFallbackFailed: $reason") + return@launch + } + + val fallbackNode = fallbackResult.getOrThrow() + appendTorLog("Switched active node to ${fallbackNode.url} before disabling Tor") + snackbarHostState.showSnackbar( + torFallbackApplied.format(fallbackNode.name), + ) + } else if ( + app.pendingNodeAwaitingTorSetup && + app.pendingNodeUrl.isNotBlank() && + isOnionNodeUrl(app.pendingNodeUrl) + ) { + appendTorLog("Discarded pending onion node draft before disabling Tor") + } + + clearPendingOnionDraft() + disableTorState() + appendTorLog("Tor disabled") + } + }, + ) { + Text(torDisableOnionDialogConfirm) + } + }, + dismissButton = { + TextButton(onClick = { showDisableTorOnionDialog = false }) { + Text(torDisableOnionDialogCancel) + } + }, + ) + } + // network change confirmation dialog pendingNetworkChange?.let { network -> AlertDialog( @@ -134,6 +1271,253 @@ fun NetworkSettingsScreen( }, ) } + + if (showFullLogDialog) { + val fullLogText = uiState.logLines.joinToString(separator = "\n") + AlertDialog( + onDismissRequest = { showFullLogDialog = false }, + title = { Text(stringResource(R.string.tor_full_log_title)) }, + text = { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small) + .padding(8.dp), + ) { + uiState.logLines.forEach { line -> + Text( + text = "> $line", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp), + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showFullLogDialog = false }) { + Text(stringResource(R.string.btn_done)) + } + }, + dismissButton = { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(fullLogText)) + scope.launch { snackbarHostState.showSnackbar(copiedText) } + }, + ) { + Text(stringResource(R.string.btn_copy)) + } + }, + ) + } + + if (showTorTestDialog) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { + if (!torTestState.running) { + showTorTestDialog = false + } + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + TorTestSheetContent( + state = torTestState, + onDismiss = { showTorTestDialog = false } + ) + } + } +} + +@Composable +private fun TorTestSheetContent( + state: TorConnectionTestState, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Connection Test", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 16.dp) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.steps.forEach { step -> + TorTestStepProgressRow(step = step) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(CoveColor.midnightBlue) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + Text( + text = "LIVE TEST LOGS", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.7f), + letterSpacing = 0.5.sp + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + val scrollState = rememberScrollState() + LaunchedEffect(state.logs.size) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .height(120.dp) + .verticalScroll(scrollState) + ) { + state.logs.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = Color.White.copy(alpha = 0.9f), + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onDismiss, + enabled = !state.running, + modifier = + Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = if (state.running) "Running Test..." else "Done", + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +private fun TorTestStepProgressRow(step: TorTestStep) { + val statusColor = when (step.status) { + TorTestStepStatus.Passed -> Color(0xFF34C759) + TorTestStepStatus.Failed -> Color(0xFFFF3B30) + TorTestStepStatus.Running -> MaterialTheme.colorScheme.primary + TorTestStepStatus.Pending -> MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + when (step.status) { + TorTestStepStatus.Pending -> { + androidx.compose.foundation.Canvas(modifier = Modifier.size(12.dp)) { + drawCircle(color = statusColor, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())) + } + } + TorTestStepStatus.Running -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = statusColor + ) + } + TorTestStepStatus.Passed -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(24.dp) + ) + } + TorTestStepStatus.Failed -> { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(24.dp) + ) + } + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = step.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = if (step.status == TorTestStepStatus.Pending) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurface + ) + if (step.status == TorTestStepStatus.Running || step.status == TorTestStepStatus.Failed || step.status == TorTestStepStatus.Passed) { + Text( + text = step.detail, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private fun parseTorMode(mode: String?): TorMode { + return when (mode) { + CoreTorMode.ORBOT.name -> TorMode.Orbot + CoreTorMode.EXTERNAL.name -> TorMode.External + else -> TorMode.BuiltIn + } +} + +private fun validateExternalConfig(host: String, port: String): String? { + if (host.isBlank()) return "Host is required" + val parsed = port.toIntOrNull() ?: return "Port must be a number" + if (parsed !in 1..65535) return "Port must be between 1 and 65535" + return null } @Composable @@ -165,3 +1549,26 @@ private fun NetworkRow( } } } + +@Composable +private fun TorModeOption( + title: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onClick) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt index 054cf4822..7be60d473 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt @@ -1,5 +1,6 @@ package org.bitcoinppl.cove.flows.SettingsFlow +import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,9 +58,13 @@ import org.bitcoinppl.cove.views.MaterialDivider import org.bitcoinppl.cove.views.MaterialSection import org.bitcoinppl.cove.views.SectionHeader import org.bitcoinppl.cove_core.ApiType +import org.bitcoinppl.cove_core.Database +import org.bitcoinppl.cove_core.GlobalFlagKey import org.bitcoinppl.cove_core.NodeSelection import org.bitcoinppl.cove_core.NodeSelector import org.bitcoinppl.cove_core.NodeSelectorException +import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.SettingsRoute @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,7 +72,11 @@ fun NodeSettingsScreen( app: org.bitcoinppl.cove.AppManager, modifier: Modifier = Modifier, ) { + val logTag = "NodeSettingsScreen" val nodeSelector = remember { NodeSelector() } + val database = remember { Database() } + val globalConfig = remember { database.globalConfig() } + val globalFlag = remember { database.globalFlag() } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -79,6 +88,7 @@ fun NodeSettingsScreen( var customUrl by remember { mutableStateOf("") } var customNodeName by remember { mutableStateOf("") } + var suppressCustomDraftActions by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } @@ -97,15 +107,80 @@ fun NodeSettingsScreen( val errorUnknown = stringResource(R.string.node_error_unknown) val errorUrlEmpty = stringResource(R.string.node_error_url_empty) val errorParseTitle = stringResource(R.string.node_error_parse_title) + val torRedirectMessage = stringResource(R.string.node_tor_redirect_message) + val nodeSavedViaTorNotice = stringResource(R.string.node_saved_via_tor_notice) val showCustomFields = selectedNodeSelection is NodeSelection.Custom || selectedNodeName == customElectrum || selectedNodeName == customEsplora + // restore pending onion draft and auto-attempt save after Tor setup + LaunchedEffect(Unit) { + if (app.pendingNodeAwaitingTorSetup && app.pendingNodeUrl.isNotBlank()) { + customUrl = app.pendingNodeUrl + customNodeName = app.pendingNodeName + selectedNodeName = app.pendingNodeTypeName + Log.d(logTag, "restored pending onion draft: type=$selectedNodeName, url=$customUrl") + + if (globalConfig.useTor() && app.pendingNodeTorValidated) { + isLoading = true + try { + val pendingTypeName = app.pendingNodeTypeName.ifBlank { customElectrum } + val node = + withContext(Dispatchers.IO) { + nodeSelector.parseCustomNode( + app.pendingNodeUrl, + pendingTypeName, + app.pendingNodeName, + ) + } + + withContext(Dispatchers.IO) { + nodeSelector.checkAndSaveNode(node) + } + + selectedNodeSelection = NodeSelection.Custom(node) + selectedNodeName = + when (node.apiType) { + ApiType.ELECTRUM -> customElectrum + ApiType.ESPLORA -> customEsplora + else -> node.name + } + + app.pendingNodeAwaitingTorSetup = false + app.pendingNodeTorValidated = false + app.pendingNodeUrl = "" + app.pendingNodeName = "" + app.pendingNodeTypeName = "" + suppressCustomDraftActions = true + customUrl = "" + customNodeName = "" + + scope.launch { + snackbarHostState.showSnackbar(successSaved) + } + app.popRoute() + } catch (e: NodeSelectorException.NodeAccessException) { + Log.e(logTag, "pending onion save failed: NodeAccess reason=${e.v1}", e) + errorTitle = errorConnectionFailed + errorMessage = errorConnectionMessage.format(e.v1) + showErrorDialog = true + } catch (e: Exception) { + Log.e(logTag, "pending onion save failed: unexpected reason=${e.message}", e) + errorTitle = errorTitleDefault + errorMessage = errorUnknown.format(e.message ?: "") + showErrorDialog = true + } finally { + isLoading = false + } + } + } + } + // pre-fill custom fields if a custom node was previously saved LaunchedEffect(showCustomFields, selectedNodeSelection) { - if (showCustomFields && customUrl.isEmpty()) { + if (showCustomFields && customUrl.isEmpty() && !suppressCustomDraftActions) { val savedNode = selectedNodeSelection if (savedNode is NodeSelection.Custom) { val node = savedNode.toNode() @@ -124,6 +199,8 @@ fun NodeSettingsScreen( } fun selectPresetNode(nodeName: String) { + Log.d(logTag, "selectPresetNode: nodeName=$nodeName") + suppressCustomDraftActions = false selectedNodeName = nodeName customUrl = "" customNodeName = "" @@ -133,11 +210,15 @@ fun NodeSettingsScreen( try { val node = withContext(Dispatchers.IO) { - nodeSelector.selectPresetNode(nodeName) + val selected = nodeSelector.selectPresetNode(nodeName) + Log.d(logTag, "selectPresetNode resolved: name=${selected.name}, apiType=${selected.apiType}, url=${selected.url}") + selected } withContext(Dispatchers.IO) { + Log.d(logTag, "checkSelectedNode start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") nodeSelector.checkSelectedNode(node) + Log.d(logTag, "checkSelectedNode success: name=${node.name}, url=${node.url}") } selectedNodeSelection = NodeSelection.Preset(node) @@ -148,14 +229,17 @@ fun NodeSettingsScreen( ) } } catch (e: NodeSelectorException.NodeNotFound) { + Log.e(logTag, "selectPresetNode failed: NodeNotFound name=$nodeName, reason=${e.v1}", e) errorTitle = errorTitleDefault errorMessage = errorNotFound.format(e.v1) showErrorDialog = true } catch (e: NodeSelectorException.NodeAccessException) { + Log.e(logTag, "selectPresetNode failed: NodeAccess name=$nodeName, reason=${e.v1}", e) errorTitle = errorConnectionFailed errorMessage = errorConnectionMessage.format(e.v1) showErrorDialog = true } catch (e: Exception) { + Log.e(logTag, "selectPresetNode failed: unexpected name=$nodeName, reason=${e.message}", e) errorTitle = errorTitleDefault errorMessage = errorUnknown.format(e.message ?: "") showErrorDialog = true @@ -166,7 +250,9 @@ fun NodeSettingsScreen( } fun checkAndSaveCustomNode() { + Log.d(logTag, "checkAndSaveCustomNode: selectedNodeName=$selectedNodeName, customUrl=$customUrl, customNodeName=$customNodeName") if (customUrl.isEmpty()) { + Log.e(logTag, "checkAndSaveCustomNode aborted: empty customUrl") errorTitle = errorTitleDefault errorMessage = errorUrlEmpty showErrorDialog = true @@ -176,34 +262,114 @@ fun NodeSettingsScreen( scope.launch { isLoading = true try { + val customNodeTypeName = + when { + selectedNodeName == customElectrum || selectedNodeName == customEsplora -> + selectedNodeName + + selectedNodeSelection is NodeSelection.Custom -> + when (selectedNodeSelection.toNode().apiType) { + ApiType.ELECTRUM -> customElectrum + ApiType.ESPLORA -> customEsplora + else -> selectedNodeName + } + + else -> selectedNodeName + } + Log.d(logTag, "checkAndSaveCustomNode type inference: selectedNodeName=$selectedNodeName, customNodeTypeName=$customNodeTypeName, selectedApiType=${selectedNodeSelection.toNode().apiType}") + val node = withContext(Dispatchers.IO) { - nodeSelector.parseCustomNode(customUrl, selectedNodeName, customNodeName) + Log.d(logTag, "parseCustomNode start: url=$customUrl, typeName=$customNodeTypeName, enteredName=$customNodeName") + val parsed = nodeSelector.parseCustomNode(customUrl, customNodeTypeName, customNodeName) + Log.d(logTag, "parseCustomNode success: parsedName=${parsed.name}, apiType=${parsed.apiType}, parsedUrl=${parsed.url}") + parsed } // update fields with parsed values customUrl = node.url customNodeName = node.name + val isOnionNode = isOnionNodeUrl(node.url) + if (isOnionNode) { + if (globalConfig.useTor()) { + Log.d(logTag, "onion node detected with Tor already enabled; saving directly: url=${node.url}") + withContext(Dispatchers.IO) { + nodeSelector.checkAndSaveNode(node) + } + selectedNodeSelection = NodeSelection.Custom(node) + selectedNodeName = + when (node.apiType) { + ApiType.ELECTRUM -> customElectrum + ApiType.ESPLORA -> customEsplora + else -> node.name + } + app.pendingNodeAwaitingTorSetup = false + app.pendingNodeTorValidated = false + app.pendingNodeUrl = "" + app.pendingNodeName = "" + app.pendingNodeTypeName = "" + suppressCustomDraftActions = false + scope.launch { + snackbarHostState.showSnackbar(successSaved) + } + return@launch + } + + Log.d(logTag, "onion node detected, redirecting to network settings: url=${node.url}") + globalFlag.set(GlobalFlagKey.TOR_SETTINGS_DISCOVERED, true) + globalConfig.setUseTor(true) + selectedNodeSelection = NodeSelection.Custom(node) + selectedNodeName = + when (node.apiType) { + ApiType.ELECTRUM -> customElectrum + ApiType.ESPLORA -> customEsplora + else -> node.name + } + + app.pendingNodeUrl = node.url + app.pendingNodeName = node.name + app.pendingNodeTypeName = selectedNodeName + app.pendingNodeAwaitingTorSetup = true + app.pendingNodeTorValidated = false + + scope.launch { + snackbarHostState.showSnackbar(torRedirectMessage) + } + app.pushRoute(Route.Settings(SettingsRoute.Network)) + return@launch + } + withContext(Dispatchers.IO) { + Log.d(logTag, "checkAndSaveNode start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") nodeSelector.checkAndSaveNode(node) + Log.d(logTag, "checkAndSaveNode success: name=${node.name}, apiType=${node.apiType}, url=${node.url}") } selectedNodeSelection = NodeSelection.Custom(node) - selectedNodeName = node.name + selectedNodeName = + when (node.apiType) { + ApiType.ELECTRUM -> customElectrum + ApiType.ESPLORA -> customEsplora + else -> node.name + } + Log.d(logTag, "custom node saved: selectedNodeName=$selectedNodeName, persistedName=${node.name}, apiType=${node.apiType}, url=${node.url}") // launch snackbar in separate coroutine so it doesn't block finally scope.launch { snackbarHostState.showSnackbar(successSaved) } } catch (e: NodeSelectorException.ParseNodeUrlException) { + Log.e(logTag, "checkAndSaveCustomNode failed: ParseNodeUrl url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) errorTitle = errorParseTitle errorMessage = e.v1 showErrorDialog = true } catch (e: NodeSelectorException.NodeAccessException) { + Log.e(logTag, "checkAndSaveCustomNode failed: NodeAccess url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) errorTitle = errorConnectionFailed errorMessage = errorConnectionMessage.format(e.v1) showErrorDialog = true } catch (e: Exception) { + Log.e(logTag, "checkAndSaveCustomNode failed: unexpected url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.message}", e) errorTitle = errorTitleDefault errorMessage = errorUnknown.format(e.message ?: "") showErrorDialog = true @@ -287,6 +453,7 @@ fun NodeSettingsScreen( nodeName = customElectrum, isSelected = selectedNodeName == customElectrum, onClick = { + suppressCustomDraftActions = false selectedNodeName = customElectrum }, ) @@ -298,6 +465,7 @@ fun NodeSettingsScreen( nodeName = customEsplora, isSelected = selectedNodeName == customEsplora, onClick = { + suppressCustomDraftActions = false selectedNodeName = customEsplora }, ) @@ -305,8 +473,8 @@ fun NodeSettingsScreen( } // custom node input fields - if (showCustomFields) { - Spacer(modifier = Modifier.height(MaterialSpacing.medium)) + if (showCustomFields) { + Spacer(modifier = Modifier.height(MaterialSpacing.medium)) SectionHeader("Custom node") MaterialSection { @@ -319,7 +487,10 @@ fun NodeSettingsScreen( ) { OutlinedTextField( value = customUrl, - onValueChange = { customUrl = it }, + onValueChange = { + suppressCustomDraftActions = false + customUrl = it + }, label = { Text(stringResource(R.string.node_url_label)) }, placeholder = { Text(stringResource(R.string.node_url_placeholder)) }, keyboardOptions = @@ -333,7 +504,10 @@ fun NodeSettingsScreen( OutlinedTextField( value = customNodeName, - onValueChange = { customNodeName = it }, + onValueChange = { + suppressCustomDraftActions = false + customNodeName = it + }, label = { Text(stringResource(R.string.node_name_label)) }, placeholder = { Text(stringResource(R.string.node_name_placeholder)) }, keyboardOptions = @@ -344,12 +518,20 @@ fun NodeSettingsScreen( modifier = Modifier.fillMaxWidth(), ) - Button( - onClick = { checkAndSaveCustomNode() }, - enabled = customUrl.isNotEmpty() && !isLoading, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.node_save_button)) + if (suppressCustomDraftActions) { + Text( + text = nodeSavedViaTorNotice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Button( + onClick = { checkAndSaveCustomNode() }, + enabled = customUrl.isNotEmpty() && !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.node_save_button)) + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt new file mode 100644 index 000000000..6a4c95cac --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/OrbotPackageHelper.kt @@ -0,0 +1,43 @@ +package org.bitcoinppl.cove.flows.SettingsFlow + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri + +private const val ORBOT_PACKAGE = "org.torproject.android" + +object OrbotPackageHelper { + fun detect(context: Context): Pair = + runCatching { + val info = + context.packageManager.getPackageInfo( + ORBOT_PACKAGE, + PackageManager.PackageInfoFlags.of(0), + ) + OrbotStatus.Detected to info.versionName + }.getOrElse { + OrbotStatus.NotDetected to null + } + + fun openOrbot(context: Context): Boolean { + val launchIntent = context.packageManager.getLaunchIntentForPackage(ORBOT_PACKAGE) ?: return false + context.startActivity(launchIntent) + return true + } + + fun openInstallPage(context: Context) { + val marketIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$ORBOT_PACKAGE")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + val webIntent = + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$ORBOT_PACKAGE"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + runCatching { context.startActivity(marketIntent) } + .recover { context.startActivity(webIntent) } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt new file mode 100644 index 000000000..48b2c878c --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt @@ -0,0 +1,32 @@ +package org.bitcoinppl.cove.flows.SettingsFlow + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bitcoinppl.cove_core.Node +import org.bitcoinppl.cove_core.NodeSelection +import org.bitcoinppl.cove_core.NodeSelector +import java.net.URI + +fun isOnionNodeUrl(url: String): Boolean { + return try { + URI(url).host?.endsWith(".onion", ignoreCase = true) == true + } catch (_: Exception) { + false + } +} + +suspend fun switchToFirstClearnetPresetNode(nodeSelector: NodeSelector): Result = + runCatching { + withContext(Dispatchers.IO) { + val fallbackNode = + nodeSelector + .nodeList() + .asSequence() + .mapNotNull { selection -> (selection as? NodeSelection.Preset)?.v1 } + .firstOrNull { node -> !isOnionNodeUrl(node.url) } + ?: throw IllegalStateException("No clearnet preset node available for fallback") + + nodeSelector.checkAndSaveNode(fallbackNode) + fallbackNode + } + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt new file mode 100644 index 000000000..7bec96e4e --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorUiState.kt @@ -0,0 +1,35 @@ +package org.bitcoinppl.cove.flows.SettingsFlow + +enum class TorStatus { + Disabled, + Bootstrapping, + Ready, + Error, +} + +enum class TorMode { + BuiltIn, + Orbot, + External, +} + +enum class OrbotStatus { + Checking, + Detected, + NotDetected, +} + +data class TorUiState( + val enabled: Boolean = false, + val mode: TorMode = TorMode.BuiltIn, + val status: TorStatus = TorStatus.Disabled, + val progressPercent: Int = 0, + val currentStep: String = "Disabled", + val latestLogLine: String = "Tor is off", + val logLines: List = listOf("Tor is off"), + val externalHost: String = "127.0.0.1", + val externalPort: String = "9050", + val externalValidationError: String? = null, + val orbotStatus: OrbotStatus = OrbotStatus.Checking, + val orbotVersion: String? = null, +) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt new file mode 100644 index 000000000..328d109a3 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt @@ -0,0 +1,254 @@ +package org.bitcoinppl.cove.tor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.Socket +import java.net.URL + +data class TorBootstrapSnapshot( + val percent: Int, + val step: String, + val isReady: Boolean, + val hasError: Boolean, + val lastLine: String, +) + +data class TorApiSnapshot( + val isTor: Boolean, + val ip: String?, + val raw: String, +) + +private val bootstrapPercentRegex = Regex("""\b(100|[0-9]{1,2})%\b""") +private val missingCountRegex = Regex("""missing\s+(\d+)""") +private val missingFractionRegex = Regex("""missing\s+(\d+)\s*/\s*(\d+)""") + +private fun isRustTorLog(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith("[INFO ") || + trimmed.startsWith("[WARN ") || + trimmed.startsWith("[ERROR ") || + trimmed.startsWith("[DEBUG ") +} + +fun deriveBuiltInBootstrapSnapshot(logLines: List): TorBootstrapSnapshot { + if (logLines.isEmpty()) { + return TorBootstrapSnapshot( + percent = 0, + step = "Waiting for Tor runtime", + isReady = false, + hasError = false, + lastLine = "No Tor logs yet", + ) + } + + val rustLogs = logLines.filter(::isRustTorLog) + if (rustLogs.isEmpty()) { + return TorBootstrapSnapshot( + percent = 0, + step = "Waiting for Tor runtime", + isReady = false, + hasError = false, + lastLine = logLines.last(), + ) + } + + val restartMarkers = + listOf( + "built-in tor endpoint requested without cache; launching proxy", + "built-in tor launch initiated", + "starting built-in tor runtime thread", + ) + val restartIndex = + rustLogs.indexOfLast { line -> + restartMarkers.any { marker -> line.contains(marker, ignoreCase = true) } + } + val scopedLogs = if (restartIndex >= 0) rustLogs.drop(restartIndex) else rustLogs + + var percent = 0 + var step = "Starting Tor" + var ready = false + var hasError = false + var initialMissingMicrodescriptors: Int? = null + + scopedLogs.forEach { line -> + val lowered = line.lowercase() + bootstrapPercentRegex.find(line)?.groupValues?.get(1)?.toIntOrNull()?.let { found -> + if (found > percent) { + percent = found + } + } + + when { + "built-in tor launch initiated" in lowered || + "starting built-in tor runtime thread" in lowered -> { + percent = maxOf(percent, 3) + step = "Launching runtime" + } + "built-in tor runtime created" in lowered || + "launching arti socks proxy task" in lowered -> { + percent = maxOf(percent, 8) + step = "Starting SOCKS proxy" + } + "listening on" in lowered && + ("127.0.0.1:" in lowered || "[::1]:" in lowered) -> { + percent = maxOf(percent, 15) + step = "SOCKS listener ready" + } + "looking for a consensus" in lowered -> { + percent = maxOf(percent, 22) + step = "Looking for consensus" + } + "downloading certificates for consensus" in lowered -> { + percent = maxOf(percent, 35) + step = "Downloading consensus certificates" + } + "downloading microdescriptors" in lowered -> { + step = "Downloading microdescriptors" + val missingFraction = + missingFractionRegex.find(lowered) + ?.groupValues + ?.drop(1) + ?.mapNotNull { value -> value.toIntOrNull() } + val missing = + (missingFraction?.getOrNull(0)) + ?: missingCountRegex.find(lowered) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + if (missing != null) { + val baselineMissing = initialMissingMicrodescriptors + val baseline = + if (missingFraction?.getOrNull(1) != null && missingFraction[1] > 0) { + val total = missingFraction[1] + if (baselineMissing == null || total > baselineMissing) { + initialMissingMicrodescriptors = total + total + } else { + baselineMissing + } + } else if (baselineMissing == null || missing > baselineMissing) { + initialMissingMicrodescriptors = missing + missing + } else { + baselineMissing + }.coerceAtLeast(1) + val completedRatio = + ((baseline - missing).coerceAtLeast(0)).toDouble() / baseline.toDouble() + val dynamicPercent = (45 + (completedRatio * 46.0).toInt()).coerceIn(45, 91) + percent = maxOf(percent, dynamicPercent) + } else { + percent = maxOf(percent, 45) + } + } + "marked consensus usable" in lowered -> { + percent = maxOf(percent, 93) + step = "Building circuits" + } + "enough information to build circuits" in lowered -> { + percent = maxOf(percent, 96) + step = "Building circuits" + } + "directory is complete" in lowered -> { + percent = 100 + step = "Tor ready" + ready = true + } + "sufficiently bootstrapped; proxy now functional" in lowered -> { + percent = maxOf(percent, 97) + step = "Circuits available, finishing directory" + } + } + + val benignReloadXdgWarning = + "arti::reload_cfg" in lowered && + ("xdg project directories" in lowered || + "unable to determine home directory" in lowered || + "cache_dir" in lowered) + + val fatalBootstrapSignals = + listOf( + "built-in tor bootstrap failed", + "built-in tor proxy exited", + "failed to initialize built-in tor runtime", + "failed to create built-in tor runtime", + "built-in tor socks listener not ready", + "can't find path for port_info_file", + "operation not supported because arti feature disabled", + ) + + if (!benignReloadXdgWarning && + fatalBootstrapSignals.any { signal -> signal in lowered } + ) { + hasError = true + } + } + + if (ready) { + hasError = false + } + + if (!ready && percent >= 100) { + percent = 99 + } + + val lastLine = scopedLogs.lastOrNull() ?: rustLogs.last() + return TorBootstrapSnapshot( + percent = percent.coerceIn(0, 100), + step = step, + isReady = ready, + hasError = hasError, + lastLine = lastLine, + ) +} + +suspend fun testSocksEndpoint( + host: String, + port: Int, + timeoutMs: Int = 3000, +): Result = + try { + withContext(Dispatchers.IO) { + Socket().use { socket -> + socket.connect(InetSocketAddress(host, port), timeoutMs) + } + } + Result.success(Unit) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) + } + +suspend fun testTorApiThroughSocks( + host: String, + port: Int, + timeoutMs: Int = 8000, +): Result = + try { + val snapshot = + withContext(Dispatchers.IO) { + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port)) + val connection = URL("https://check.torproject.org/api/ip").openConnection(proxy) + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + + val raw = + connection.getInputStream().bufferedReader().use { reader -> + reader.readText() + } + val normalized = raw.lowercase() + val isTor = "\"istor\":true" in normalized || "\"is_tor\":true" in normalized + val ip = Regex(""""IP"\s*:\s*"([^"]+)"""").find(raw)?.groupValues?.getOrNull(1) + + TorApiSnapshot(isTor = isTor, ip = ip, raw = raw) + } + Result.success(snapshot) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) + } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt index d19062a47..20b551745 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/SettingsItem.kt @@ -57,11 +57,13 @@ fun MaterialSettingsItem( isSwitch: Boolean = false, switchCheckedState: Boolean = false, onCheckChanged: ((Boolean) -> Unit)? = null, + modifier: Modifier = Modifier, ) { MaterialSettingsItem( title = title, onClick = onClick, subtitle = subtitle, + modifier = modifier, leadingContent = { Icon( imageVector = icon, @@ -99,6 +101,7 @@ fun MaterialSettingsItem( leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, titleColor: Color? = null, + modifier: Modifier = Modifier, ) { ListItem( headlineContent = { @@ -121,7 +124,7 @@ fun MaterialSettingsItem( leadingContent = leadingContent, trailingContent = trailingContent, modifier = - Modifier + modifier .then( if (onClick != null) { Modifier.clickable(onClick = onClick) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 3c576663b..a27fa60f3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1032,8 +1032,14 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + external fun uniffi_cove_checksum_func_clear_tor_connection_logs( + ): Short + external fun uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap( + ): Short external fun uniffi_cove_checksum_func_set_root_data_dir( ): Short + external fun uniffi_cove_checksum_func_tor_connection_logs( + ): Short external fun uniffi_cove_checksum_func_initialize_app( ): Short external fun uniffi_cove_checksum_func_bootstrap( @@ -1276,6 +1282,14 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_globalconfigtable_set_selected_node( ): Short + external fun uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port( + ): Short + external fun uniffi_cove_checksum_method_globalconfigtable_set_use_tor( + ): Short + external fun uniffi_cove_checksum_method_globalconfigtable_tor_external_port( + ): Short + external fun uniffi_cove_checksum_method_globalconfigtable_use_tor( + ): Short external fun uniffi_cove_checksum_method_globalconfigtable_wallet_mode( ): Short external fun uniffi_cove_checksum_method_globalflagtable_get( @@ -2232,6 +2246,14 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_method_globalconfigtable_set_selected_node(`ptr`: Long,`node`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_cove_fn_method_globalconfigtable_set_tor_external_port(`ptr`: Long,`port`: Short,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_method_globalconfigtable_set_use_tor(`ptr`: Long,`useTor`: Byte,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_method_globalconfigtable_tor_external_port(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + external fun uniffi_cove_fn_method_globalconfigtable_use_tor(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte external fun uniffi_cove_fn_method_globalconfigtable_wallet_mode(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_clone_globalflagtable(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3300,8 +3322,14 @@ internal object UniffiLib { ): Byte external fun uniffi_cove_fn_method_walletmetadata_uniffi_trait_hash(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long + external fun uniffi_cove_fn_func_clear_tor_connection_logs(uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_func_ensure_built_in_tor_bootstrap( + ): Long external fun uniffi_cove_fn_func_set_root_data_dir(`path`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_cove_fn_func_tor_connection_logs(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_func_initialize_app(uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_func_bootstrap( @@ -3515,9 +3543,18 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if (lib.uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap() != 8592.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_func_set_root_data_dir() != 56109.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_func_tor_connection_logs() != 59010.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_func_initialize_app() != 18498.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3881,6 +3918,18 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_globalconfigtable_set_selected_node() != 44882.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port() != 2190.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_globalconfigtable_set_use_tor() != 3178.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_globalconfigtable_tor_external_port() != 48622.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_globalconfigtable_use_tor() != 22520.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_globalconfigtable_wallet_mode() != 27720.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -12019,6 +12068,14 @@ public interface GlobalConfigTableInterface { fun `setSelectedNode`(`node`: Node) + fun `setTorExternalPort`(`port`: kotlin.UShort) + + fun `setUseTor`(`useTor`: kotlin.Boolean) + + fun `torExternalPort`(): kotlin.UShort + + fun `useTor`(): kotlin.Boolean + fun `walletMode`(): WalletMode companion object @@ -12374,6 +12431,58 @@ open class GlobalConfigTable: Disposable, AutoCloseable, GlobalConfigTableInterf + + @Throws(DatabaseException::class)override fun `setTorExternalPort`(`port`: kotlin.UShort) + = + callWithHandle { + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_globalconfigtable_set_tor_external_port( + it, + FfiConverterUShort.lower(`port`),_status) +} + } + + + + + @Throws(DatabaseException::class)override fun `setUseTor`(`useTor`: kotlin.Boolean) + = + callWithHandle { + uniffiRustCallWithError(DatabaseException) { _status -> + UniffiLib.uniffi_cove_fn_method_globalconfigtable_set_use_tor( + it, + FfiConverterBoolean.lower(`useTor`),_status) +} + } + + + + override fun `torExternalPort`(): kotlin.UShort { + return FfiConverterUShort.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_globalconfigtable_tor_external_port( + it, + _status) +} + } + ) + } + + + override fun `useTor`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_globalconfigtable_use_tor( + it, + _status) +} + } + ) + } + + override fun `walletMode`(): WalletMode { return FfiConverterTypeWalletMode.lift( callWithHandle { @@ -37897,6 +38006,18 @@ sealed class GlobalConfigKey { companion object } + object UseTor : GlobalConfigKey() + + + object TorMode : GlobalConfigKey() + + + object TorExternalHost : GlobalConfigKey() + + + object TorExternalPort : GlobalConfigKey() + + object ColorScheme : GlobalConfigKey() @@ -37949,16 +38070,20 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer GlobalConfigKey.SelectedNode( FfiConverterTypeNetwork.read(buf), ) - 5 -> GlobalConfigKey.ColorScheme - 6 -> GlobalConfigKey.AuthType - 7 -> GlobalConfigKey.HashedPinCode - 8 -> GlobalConfigKey.WipeDataPin - 9 -> GlobalConfigKey.DecoyPin - 10 -> GlobalConfigKey.InDecoyMode - 11 -> GlobalConfigKey.MainSelectedWalletId - 12 -> GlobalConfigKey.DecoySelectedWalletId - 13 -> GlobalConfigKey.LockedAt - 14 -> GlobalConfigKey.OnboardingProgress + 5 -> GlobalConfigKey.UseTor + 6 -> GlobalConfigKey.TorMode + 7 -> GlobalConfigKey.TorExternalHost + 8 -> GlobalConfigKey.TorExternalPort + 9 -> GlobalConfigKey.ColorScheme + 10 -> GlobalConfigKey.AuthType + 11 -> GlobalConfigKey.HashedPinCode + 12 -> GlobalConfigKey.WipeDataPin + 13 -> GlobalConfigKey.DecoyPin + 14 -> GlobalConfigKey.InDecoyMode + 15 -> GlobalConfigKey.MainSelectedWalletId + 16 -> GlobalConfigKey.DecoySelectedWalletId + 17 -> GlobalConfigKey.LockedAt + 18 -> GlobalConfigKey.OnboardingProgress else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -37989,6 +38114,30 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is GlobalConfigKey.TorMode -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is GlobalConfigKey.TorExternalHost -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is GlobalConfigKey.TorExternalPort -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } is GlobalConfigKey.ColorScheme -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -38070,46 +38219,62 @@ public object FfiConverterTypeGlobalConfigKey : FfiConverterRustBuffer { + is GlobalConfigKey.UseTor -> { buf.putInt(5) Unit } - is GlobalConfigKey.AuthType -> { + is GlobalConfigKey.TorMode -> { buf.putInt(6) Unit } - is GlobalConfigKey.HashedPinCode -> { + is GlobalConfigKey.TorExternalHost -> { buf.putInt(7) Unit } - is GlobalConfigKey.WipeDataPin -> { + is GlobalConfigKey.TorExternalPort -> { buf.putInt(8) Unit } - is GlobalConfigKey.DecoyPin -> { + is GlobalConfigKey.ColorScheme -> { buf.putInt(9) Unit } - is GlobalConfigKey.InDecoyMode -> { + is GlobalConfigKey.AuthType -> { buf.putInt(10) Unit } - is GlobalConfigKey.MainSelectedWalletId -> { + is GlobalConfigKey.HashedPinCode -> { buf.putInt(11) Unit } - is GlobalConfigKey.DecoySelectedWalletId -> { + is GlobalConfigKey.WipeDataPin -> { buf.putInt(12) Unit } - is GlobalConfigKey.LockedAt -> { + is GlobalConfigKey.DecoyPin -> { buf.putInt(13) Unit } - is GlobalConfigKey.OnboardingProgress -> { + is GlobalConfigKey.InDecoyMode -> { buf.putInt(14) Unit } + is GlobalConfigKey.MainSelectedWalletId -> { + buf.putInt(15) + Unit + } + is GlobalConfigKey.DecoySelectedWalletId -> { + buf.putInt(16) + Unit + } + is GlobalConfigKey.LockedAt -> { + buf.putInt(17) + Unit + } + is GlobalConfigKey.OnboardingProgress -> { + buf.putInt(18) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -38231,7 +38396,8 @@ enum class GlobalFlagKey { COMPLETED_ONBOARDING, ACCEPTED_TERMS, BETA_FEATURES_ENABLED, - BETA_IMPORT_EXPORT_ENABLED; + BETA_IMPORT_EXPORT_ENABLED, + TOR_SETTINGS_DISCOVERED; @@ -47628,6 +47794,83 @@ public object FfiConverterTypeTapSignerRoute : FfiConverterRustBuffer { + override fun lift(error_buf: RustBuffer.ByValue): TorBootstrapException = FfiConverterTypeTorBootstrapError.lift(error_buf) + } +} + +/** + * @suppress + */ +public object FfiConverterTypeTorBootstrapError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): TorBootstrapException { + + return when(buf.getInt()) { + 1 -> TorBootstrapException.BuiltInTor(FfiConverterString.read(buf)) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + + } + + override fun allocationSize(value: TorBootstrapException): ULong { + return 4UL + } + + override fun write(value: TorBootstrapException, buf: ByteBuffer) { + when(value) { + is TorBootstrapException.BuiltInTor -> { + buf.putInt(1) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + +enum class TorMode { + + BUILT_IN, + ORBOT, + EXTERNAL; + + + + + companion object +} + + +/** + * @suppress + */ +public object FfiConverterTypeTorMode: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + TorMode.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: TorMode) = 4UL + + override fun write(value: TorMode, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + sealed class Transaction: Disposable { data class Confirmed( @@ -55007,6 +55250,30 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler + UniffiLib.uniffi_cove_fn_func_clear_tor_connection_logs( + + _status) +} + + + + @Throws(TorBootstrapException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + suspend fun `ensureBuiltInTorBootstrap`() : kotlin.String { + return uniffiRustCallAsync( + UniffiLib.uniffi_cove_fn_func_ensure_built_in_tor_bootstrap(), + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterString.lift(it) }, + // Error FFI converter + TorBootstrapException.ErrorHandler, + ) + } /** * set root data directory before any database access @@ -55021,6 +55288,16 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler { + return FfiConverterSequenceString.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_func_tor_connection_logs( + + _status) +} + ) + } + /** * Initialize the global App instance (Updater, router, state) diff --git a/android/app/src/main/res/drawable/icon_tor_onion.xml b/android/app/src/main/res/drawable/icon_tor_onion.xml new file mode 100644 index 000000000..22a24433d --- /dev/null +++ b/android/app/src/main/res/drawable/icon_tor_onion.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ddd2440fe..2c1ca3171 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -174,6 +174,8 @@ Unknown error: %1$s URL cannot be empty Unable to parse URL + Onion node detected. Configure Tor in Network settings, then save again. + Node already saved through Tor validation. Edit URL or name to save changes. Back Selected @@ -188,4 +190,73 @@ Manage UTXOs Wallet Settings Address unavailable + + + Tor Network + Connection Status + Custom Proxy Settings + Use Tor + Route traffic through the Tor network for enhanced privacy + Connection Mode + Built-in + Custom SOCKS5 Proxy + Orbot (External App) + Tor Status + Disabled + Connecting… + Connected + Connection Error + Not configured + Needs testing + Configured + Install Orbot first + Action required + Bootstrap Progress + Status: + Log: + Connection Logs + View detailed Tor bootstrap logs + Tor Logs + SOCKS Host + SOCKS Port + Configure your custom Tor proxy or SOCKS5 bridge + Orbot Integration + Checking Orbot… + Orbot Detected + Orbot Detected (v%1$s) + Orbot Not Found + Refresh Status + Check if Orbot is installed + Open Orbot + Manage Tor via Orbot app + Get Orbot + Install Orbot from the Play Store + Save config + Test connection + Use this configuration + Refresh Orbot status + Open Orbot + Install Orbot + External proxy config is invalid + No pending onion node found. Add one from Node settings first. + Node test failed: %1$s + Tor proxy unavailable: %1$s + Tor proxy configuration saved + Tor connection test passed + Onion node saved successfully + Disable Tor? + Your active or pending node uses an onion address. Disable Tor only if Cove first switches to a clearnet node. + Disable and switch node + Cancel + Clearnet fallback failed + Switched to %1$s and disabled Tor + Initializing Tor runtime + Loading network directory + Building circuit + Preparing local proxy + Tor is ready + Custom proxy configured + Tor is currently disabled + Routing via custom proxy + TOR From 9ea18c12f58d3d89fb3fb95a75c0d6e71d5760ec Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 22 Apr 2026 23:34:12 +0200 Subject: [PATCH 3/8] feat: Add iOS Tor settings and wallet UI flows (#256) Integrate SwiftUI Tor mode controls and status surfaces across settings and wallet views, including generated bindings and Tor support helpers. --- ios/Cove/AppManager.swift | 32 + .../iconTorOnion.imageset/Contents.json | 16 + .../iconTorOnion.imageset/onion.svg | 6 + ios/Cove/CoveMainView.swift | 156 +++ ios/Cove/Extention/Result+Ext.swift | 10 + .../SelectedWalletScreen.swift | 292 +++++ .../SettingsFlow/NetworkSettingsView.swift | 1056 +++++++++++++++++ .../SettingsFlow/SettingsContainer.swift | 2 +- .../SettingsScreen/NodeSelectionView.swift | 359 ++++-- ios/Cove/TorSupport.swift | 678 +++++++++++ ios/Cove/WalletManager.swift | 4 + .../Sources/CoveCore/generated/cove.swift | 309 ++++- 12 files changed, 2791 insertions(+), 129 deletions(-) create mode 100644 ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json create mode 100644 ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg create mode 100644 ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift create mode 100644 ios/Cove/TorSupport.swift diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index ef43990db..ced1ccdc8 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -34,6 +34,12 @@ private let sidebarNavigationDelayMs = 250 var selectedNode = Database().globalConfig().selectedNode() var selectedFiatCurrency = Database().globalConfig().selectedFiatCurrency() + var pendingNodeUrl = "" + var pendingNodeName = "" + var pendingNodeTypeName = "" + var pendingNodeAwaitingTorSetup = false + var pendingNodeTorValidated = false + var nfcReader = NFCReader() var nfcWriter = NFCWriter() var tapSignerNfc: TapSignerNFC? @@ -96,6 +102,7 @@ private let sidebarNavigationDelayMs = 250 fees = try? rust.fees() self.rust.listenForUpdates(updater: self) + warmupTorIfConfigured() } public func getWalletManager(id: WalletId) throws -> WalletManager { @@ -138,6 +145,29 @@ private let sidebarNavigationDelayMs = 250 walletManager = vm } + public func clearPendingNodeTorDraft() { + pendingNodeUrl = "" + pendingNodeName = "" + pendingNodeTypeName = "" + pendingNodeAwaitingTorSetup = false + pendingNodeTorValidated = false + } + + private func warmupTorIfConfigured() { + guard database.globalConfig().useTor() else { return } + let mode = TorMode.fromConfig(try? database.globalConfig().get(key: .torMode)) + guard mode == .builtIn else { return } + + Task.detached { + do { + let endpoint = try await ensureBuiltInTorBootstrap() + Log.debug("Built-in Tor warmup started at \(endpoint)") + } catch { + Log.warn("Built-in Tor warmup failed: \(error.localizedDescription)") + } + } + } + public func findTapSignerWallet(_ ts: TapSigner) -> WalletMetadata? { rust.findTapSignerWallet(tapSigner: ts) } @@ -161,6 +191,8 @@ private let sidebarNavigationDelayMs = 250 let state = rust.state() router = state.router + self.rust.listenForUpdates(updater: self) + warmupTorIfConfigured() } /// Reload wallets from database (e.g. after cloud restore) diff --git a/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json new file mode 100644 index 000000000..e291434a2 --- /dev/null +++ b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "onion.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg new file mode 100644 index 000000000..1f3ff08c7 --- /dev/null +++ b/ios/Cove/Assets.xcassets/iconTorOnion.imageset/onion.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index 51371c86b..1fa051dd2 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -7,6 +7,64 @@ import SwiftUI +private struct StartupTorUnavailable: Equatable { + let mode: TorMode + let endpoint: String + let error: String + + var title: String { + switch mode { + case .orbot: + "Orbot is not active" + case .external: + "Tor proxy unavailable" + case .builtIn: + "Built-in Tor unavailable" + } + } + + var message: String { + switch mode { + case .orbot: + "Cove is configured to use Orbot, but the SOCKS endpoint at \(endpoint) is not reachable. Start Orbot, fix Tor settings, or switch to a clearnet node." + case .external: + "Cove is configured to use a custom SOCKS5 proxy at \(endpoint), but it is not reachable. Fix the proxy or switch to a clearnet node." + case .builtIn: + "Built-in Tor is configured, but startup failed: \(error)" + } + } +} + +private struct StartupTorWarningModifier: ViewModifier { + @Binding var warning: StartupTorUnavailable? + let openOrbot: () -> Void + let openNetworkSettings: () -> Void + let useClearnetNode: () -> Void + + func body(content: Content) -> some View { + content.alert( + warning?.title ?? "Tor unavailable", + isPresented: Binding( + get: { warning != nil }, + set: { if !$0 { warning = nil } } + ), + presenting: warning + ) { warning in + if warning.mode == .orbot { + Button("Open Orbot", action: openOrbot) + } + + Button("Network Settings", action: openNetworkSettings) + Button("Use clearnet node", role: .destructive, action: useClearnetNode) + Button("Ignore", role: .cancel) { + self.warning = nil + } + } message: { warning in + Text(warning.message) + } + } +} + struct CoveMainView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.scenePhase) private var phase @@ -18,6 +76,8 @@ struct CoveMainView: View { @State var showCover: Bool = true @State var scannedCode: TaggedItem? = .none @State var coverClearTask: Task? + @State private var startupTorUnavailable: StartupTorUnavailable? + @State private var startupTorCheckSignature: String? @ViewBuilder private func alertMessage(alert: TaggedItem) -> some View { @@ -331,6 +391,10 @@ struct CoveMainView: View { } .onChange(of: auth.lockState) { old, new in Log.warn("AUTH LOCK STATE CHANGED: \(old) --> \(new)") + + if new == .unlocked { + Task { await checkStartupTorAvailabilityIfNeeded() } + } } .environment(app) .environment(auth) @@ -376,6 +440,7 @@ struct CoveMainView: View { guard app.asyncRuntimeReady else { return } app.dispatch(action: AppAction.updateFees) app.dispatch(action: AppAction.updateFiatPrices) + Task { await checkStartupTorAvailabilityIfNeeded() } } // PIN auth active, no biometrics, leaving app @@ -462,6 +527,86 @@ struct CoveMainView: View { } } + @MainActor + private func checkStartupTorAvailabilityIfNeeded() async { + guard phase == .active else { return } + guard app.isTermsAccepted else { return } + guard auth.lockState == .unlocked || !auth.isAuthEnabled else { return } + + let config = Database().globalConfig() + guard config.useTor() else { + startupTorCheckSignature = nil + return + } + + let mode = TorMode.fromConfig(try? config.get(key: .torMode)) + guard mode != .builtIn else { return } + + let endpoint: (host: String, port: Int) + switch mode { + case .builtIn: + return + case .orbot: + endpoint = ("127.0.0.1", 9050) + case .external: + let host = (try? config.get(key: .torExternalHost))? + .trimmingCharacters(in: .whitespacesAndNewlines) + endpoint = (host?.isEmpty == false ? host! : "127.0.0.1", Int(config.torExternalPort())) + } + + let endpointDescription = "\(endpoint.host):\(endpoint.port)" + let signature = "\(mode.persistedValue)|\(endpointDescription)|\(app.selectedNode.url)" + guard startupTorCheckSignature != signature else { return } + startupTorCheckSignature = signature + + let result = await testSocksEndpoint(host: endpoint.host, port: endpoint.port, timeout: 2) + if result.isSuccess() { + startupTorUnavailable = nil + return + } + + guard let error = result.failureValue else { return } + + startupTorUnavailable = StartupTorUnavailable( + mode: mode, + endpoint: endpointDescription, + error: error.localizedDescription + ) + } + + @MainActor + private func useClearnetAfterStartupTorFailure() async { + let config = Database().globalConfig() + + do { + var message = "Tor has been disabled." + if isOnionNodeUrl(app.selectedNode.url) { + let fallback = try await switchToFirstClearnetPresetNode(NodeSelector()) + app.selectedNode = fallback + message = "Switched to \(fallback.name) and disabled Tor." + } + + app.clearPendingNodeTorDraft() + try? config.setUseTor(useTor: false) + startupTorUnavailable = nil + startupTorCheckSignature = nil + app.alertState = .init(.general(title: "Tor disabled", message: message)) + } catch { + app.alertState = .init( + .general( + title: "Unable to switch node", + message: "Cove could not switch to a clearnet node: \(error.localizedDescription)" + ) + ) + } + } + + private func openOrbotFromStartupWarning() { + startupTorUnavailable = nil + guard let url = URL(string: "orbot://") else { return } + UIApplication.shared.open(url) + } + var body: some View { CloudBackupPresentationHost(app: app, auth: auth, isCoverPresented: showCover) { BodyView @@ -486,5 +631,16 @@ struct CoveMainView: View { .onOpenURL(perform: ScanManager.shared.handleFileOpen) .onChange(of: phase, initial: true, handleScenePhaseChange) } + .modifier(StartupTorWarningModifier( + warning: $startupTorUnavailable, + openOrbot: openOrbotFromStartupWarning, + openNetworkSettings: { + startupTorUnavailable = nil + app.pushRoute(.settings(.network)) + }, + useClearnetNode: { + Task { await useClearnetAfterStartupTorFailure() } + } + )) } } diff --git a/ios/Cove/Extention/Result+Ext.swift b/ios/Cove/Extention/Result+Ext.swift index 826adc96d..c904c9c5b 100644 --- a/ios/Cove/Extention/Result+Ext.swift +++ b/ios/Cove/Extention/Result+Ext.swift @@ -18,6 +18,16 @@ public extension Result where Failure == Swift.Error { } public extension Result { + var successValue: Success? { + guard case let .success(value) = self else { return nil } + return value + } + + var failureValue: Failure? { + guard case let .failure(error) = self else { return nil } + return error + } + func isSuccess() -> Bool { switch self { case .success: true diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 73db614fb..59adde24f 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -53,6 +53,8 @@ struct SelectedWalletScreen: View { /// private @State private var runPostRefresh = false + @State private var torQuickStatus = TorQuickStatus() + @State private var showTorQuickStatus = false var metadata: WalletMetadata { manager.walletMetadata @@ -221,6 +223,146 @@ struct SelectedWalletScreen: View { return .white } + @MainActor + private func pollTorQuickStatus() async { + while !Task.isCancelled { + await refreshTorQuickStatus() + try? await Task.sleep(for: .seconds(3)) + } + } + + @MainActor + private func refreshTorQuickStatus() async { + let config = Database().globalConfig() + guard config.useTor() else { + torQuickStatus = TorQuickStatus() + return + } + + let mode = TorMode.fromConfig(try? config.get(key: .torMode)) + var quick = TorQuickStatus(enabled: true) + + switch mode { + case .builtIn: + await refreshBuiltInQuickStatus(quick: &quick) + case .orbot: + await refreshProxyQuickStatus( + host: "127.0.0.1", + port: 9050, + modeTitle: "Orbot", + quick: &quick + ) + case .external: + let host = (try? config.get(key: .torExternalHost))? + .trimmingCharacters(in: .whitespacesAndNewlines) + await refreshProxyQuickStatus( + host: host?.isEmpty == false ? host! : "127.0.0.1", + port: Int(config.torExternalPort()), + modeTitle: "Custom SOCKS5", + quick: &quick + ) + } + + applyWalletQuickStatus(to: &quick) + quick.overall = overallTorQuickDot(quick) + torQuickStatus = quick + } + + @MainActor + private func refreshBuiltInQuickStatus(quick: inout TorQuickStatus) async { + do { + _ = try await ensureBuiltInTorBootstrap() + } catch { + quick.torConnection = .red + quick.torMessage = "Built-in Tor failed: \(error.localizedDescription)" + quick.logs = recentTorLogs(torConnectionLogs()) + return + } + + let logs = torConnectionLogs() + let snapshot = deriveBuiltInBootstrapSnapshot(logs) + quick.logs = recentTorLogs(logs) + + if snapshot.isReady { + quick.torConnection = .green + quick.torMessage = "Built-in Tor ready" + } else if snapshot.hasError { + quick.torConnection = .red + quick.torMessage = snapshot.step + } else { + quick.torConnection = .yellow + quick.torMessage = "\(snapshot.percent)% \(snapshot.step)" + } + } + + @MainActor + private func refreshProxyQuickStatus( + host: String, + port: Int, + modeTitle: String, + quick: inout TorQuickStatus + ) async { + let result = await testSocksEndpoint(host: host, port: port, timeout: 1.5) + switch result { + case .success: + quick.torConnection = .green + quick.torMessage = "\(modeTitle) proxy reachable at \(host):\(port)" + case let .failure(error): + quick.torConnection = .red + quick.torMessage = "\(modeTitle) proxy unavailable: \(error.localizedDescription)" + } + } + + private func applyWalletQuickStatus(to quick: inout TorQuickStatus) { + if quick.torConnection == .yellow { + quick.nodeReachable = .yellow + quick.nodeMessage = "Waiting for Tor" + quick.nodeSynced = .yellow + quick.syncMessage = "Waiting for Tor" + return + } + + if quick.torConnection == .red { + quick.nodeReachable = .red + quick.nodeMessage = "Tor unavailable" + quick.nodeSynced = .red + quick.syncMessage = "Tor unavailable" + return + } + + if case .nodeConnectionFailed = manager.errorAlert { + quick.nodeReachable = .red + quick.nodeMessage = "Node connection failed" + } else { + quick.nodeReachable = .green + quick.nodeMessage = "Node reachable" + } + + switch manager.loadState { + case .loading: + quick.nodeSynced = .yellow + quick.syncMessage = "Wallet loading" + case .scanning: + quick.nodeSynced = .yellow + quick.syncMessage = "Wallet syncing" + case .loaded: + quick.nodeSynced = .green + quick.syncMessage = "Wallet synced" + } + } + + private func overallTorQuickDot(_ status: TorQuickStatus) -> TorStatusDot { + let dots = [status.torConnection, status.nodeReachable, status.nodeSynced] + if dots.allSatisfy({ $0 == .green }) { return .green } + if dots.contains(.red) { return .red } + if dots.contains(.yellow) { return .yellow } + return .gray + } + + private func recentTorLogs(_ logs: [String]) -> [String] { + Array(logs.dropFirst(max(logs.count - 6, 0))) + } + var titleContent: some View { HStack(spacing: 10) { if case .cold = metadata.walletType { @@ -252,6 +394,31 @@ struct SelectedWalletScreen: View { var MainToolBar: some ToolbarContent { ToolbarItemGroup(placement: .navigationBarTrailing) { HStack(spacing: 5) { + if torQuickStatus.enabled { + Button(action: { showTorQuickStatus.toggle() }) { + HStack(spacing: 2) { + Image("iconTorOnion") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + + BlinkingTorStatusDot(dot: torQuickStatus.overall, size: 10) + } + .adaptiveToolbarItemStyle(isPastHeader: shouldShowNavBar) + } + .popover(isPresented: $showTorQuickStatus) { + TorQuickStatusPopover( + status: torQuickStatus, + openNetworkSettings: { + showTorQuickStatus = false + app.pushRoute(.settings(.network)) + } + ) + .presentationCompactAdaptation(.popover) + } + } + Button(action: { app.sheetState = .init(.qr) }) { @@ -399,6 +566,9 @@ struct SelectedWalletScreen: View { .padding(.top, 10) } .onChange(of: scannedLabels, initial: false, onChangeOfScannedLabels) + .task(id: manager.id) { + await pollTorQuickStatus() + } } func handleScrollToTransaction(proxy: ScrollViewProxy) { @@ -567,6 +737,128 @@ struct VerifyReminder: View { } } +private struct TorQuickStatusPopover: View { + let status: TorQuickStatus + let openNetworkSettings: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Tor Network Status") + .font(.headline.weight(.bold)) + + VStack(spacing: 12) { + TorQuickStatusRow( + title: "Tor connection", + detail: status.torMessage, + dot: status.torConnection + ) + TorQuickStatusRow( + title: "Node reachable", + detail: status.nodeMessage, + dot: status.nodeReachable + ) + TorQuickStatusRow( + title: "Node synced", + detail: status.syncMessage, + dot: status.nodeSynced + ) + } + + if !status.logs.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Recent logs") + .font(.caption.weight(.bold)) + .foregroundStyle(.blue) + + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(status.logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.midnightBlue.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + + Button(action: openNetworkSettings) { + Text("Network Settings") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + } + .padding(18) + .frame(width: 280) + } +} + +private struct TorQuickStatusRow: View { + let title: String + let detail: String + let dot: TorStatusDot + + var body: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 1) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.secondary.opacity(0.8)) + + Text(detail) + .font(.system(size: 13, weight: .semibold)) + } + + Spacer() + + if dot == .green { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(dot.color) + .font(.system(size: 16)) + } else { + Circle() + .fill(dot.color) + .frame(width: 12, height: 12) + } + } + } +} + +private struct BlinkingTorStatusDot: View { + let dot: TorStatusDot + let size: CGFloat + + @State private var pulse = false + + var body: some View { + Circle() + .fill(dot.color) + .frame(width: size, height: size) + .opacity(dot == .yellow ? (pulse ? 1.0 : 0.28) : 1.0) + .onAppear(perform: startPulseIfNeeded) + .onChange(of: dot) { _, _ in + startPulseIfNeeded() + } + } + + private func startPulseIfNeeded() { + guard dot == .yellow else { + pulse = false + return + } + + pulse = false + withAnimation(.easeInOut(duration: 0.95).repeatForever(autoreverses: true)) { + pulse = true + } + } +} + #Preview { AsyncPreview { NavigationStack { diff --git a/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift new file mode 100644 index 000000000..f6625e1a4 --- /dev/null +++ b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift @@ -0,0 +1,1056 @@ +import SwiftUI +import UIKit + +private enum TorTestStepStatus: Equatable { + case pending + case running + case passed + case failed +} + +private struct TorTestStep: Equatable, Identifiable { + var id: String { + key + } + + let key: String + var title: String + var detail: String + var status: TorTestStepStatus = .pending +} + +private struct TorConnectionTestState: Equatable { + var running = false + var finished = false + var steps: [TorTestStep] = [] + var logs: [String] = [] +} + +struct NetworkSettingsView: View { + @Binding var selection: Network + + @Environment(AppManager.self) private var app + + private let db = Database() + private let nodeSelector = NodeSelector() + + @State private var pendingNetwork: Network? + @State private var torSettingsDiscovered = false + @State private var uiState = TorUiState() + @State private var showModeDialog = false + @State private var showFullLogSheet = false + @State private var showTorTestSheet = false + @State private var showDisableTorOnionAlert = false + @State private var rustTorLogCount = 0 + @State private var builtInWarmupRequested = false + @State private var torTestState = TorConnectionTestState() + @State private var autoPendingTestKey: String? + @State private var noticeMessage: String? + + private var globalConfig: GlobalConfigTable { + db.globalConfig() + } + + private var globalFlag: GlobalFlagTable { + db.globalFlag() + } + + var body: some View { + Form { + networkSection + + if torSettingsDiscovered { + torMainSection + + if uiState.enabled { + switch uiState.mode { + case .builtIn: + builtInSection + case .external: + externalSection + case .orbot: + orbotSection + } + } + } + } + .scrollContentBackground(.hidden) + .navigationTitle("Network") + .onAppear(perform: loadPersistedTorState) + .task { + await refreshInitialTorState() + } + .task(id: "\(uiState.enabled)-\(uiState.mode.persistedValue)") { + await pollBuiltInTorLogsIfNeeded() + } + .onChange(of: uiState.status) { _, _ in + Task { await maybeRunPendingOnionValidation() } + } + .alert("Change Network?", isPresented: Binding( + get: { pendingNetwork != nil }, + set: { if !$0 { pendingNetwork = nil } } + )) { + Button("Yes, Change Network") { + if let network = pendingNetwork { + app.dispatch(action: .changeNetwork(network: network)) + app.rust.selectLatestOrNewWallet() + selection = network + app.popRoute() + } + pendingNetwork = nil + } + Button("Cancel", role: .cancel) { + pendingNetwork = nil + } + } message: { + if let network = pendingNetwork { + Text("Switching to \(network.displayName) will take you to a wallet on that network.") + } + } + .alert("Disable Tor?", isPresented: $showDisableTorOnionAlert) { + Button("Disable and switch node") { + Task { await disableTorWithClearnetFallback() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Your active or pending node uses an onion address. Disable Tor only if Cove first switches to a clearnet node.") + } + .alert("Network", isPresented: Binding( + get: { noticeMessage != nil }, + set: { if !$0 { noticeMessage = nil } } + )) { + Button("OK") { noticeMessage = nil } + } message: { + Text(noticeMessage ?? "") + } + .confirmationDialog("Connection Mode", isPresented: $showModeDialog, titleVisibility: .visible) { + ForEach(TorMode.uiCases, id: \.self) { mode in + Button(mode.title) { + setTorMode(mode) + } + } + Button("Cancel", role: .cancel) {} + } + .sheet(isPresented: $showFullLogSheet) { + TorFullLogSheet(logLines: uiState.logLines) + } + .sheet(isPresented: $showTorTestSheet) { + TorTestSheetContent(state: torTestState) { + if !torTestState.running { + showTorTestSheet = false + } + } + .interactiveDismissDisabled(torTestState.running) + .presentationDetents([.height(560), .large]) + } + } + + private var networkSection: some View { + Section { + ForEach(Network.allCases, id: \.self) { item in + HStack { + if !item.symbol.isEmpty { + Image(systemName: item.symbol) + } + + Text(item.displayName) + .font(.subheadline) + + Spacer() + + if selection == item { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + .font(.footnote) + .fontWeight(.semibold) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if item != selection { + pendingNetwork = item + } + } + } + } + } + + private var torMainSection: some View { + Section("Tor Network") { + Toggle(isOn: Binding( + get: { uiState.enabled }, + set: handleUseTorToggle + )) { + VStack(alignment: .leading, spacing: 2) { + Text("Use Tor") + Text("Route traffic through the Tor network for enhanced privacy") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Menu { + ForEach(TorMode.uiCases, id: \.self) { mode in + Button(mode.title) { + setTorMode(mode) + } + } + } label: { + HStack { + Label("Connection Mode", systemImage: "network") + Spacer() + Text(uiState.mode.shortTitle) + .foregroundStyle(.blue) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .disabled(!uiState.enabled) + .opacity(uiState.enabled ? 1 : 0.5) + + HStack { + Label("Tor Status", systemImage: "info.circle") + Spacer() + Text(statusLabel) + .foregroundStyle(.secondary) + if uiState.enabled, uiState.status == .ready { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + .opacity(uiState.enabled ? 1 : 0.5) + } + } + + private var builtInSection: some View { + Group { + Section("Connection Status") { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Bootstrap Progress") + Spacer() + Text("\(uiState.progressPercent)%") + .foregroundStyle(.blue) + } + + ProgressView(value: Double(uiState.progressPercent), total: 100) + + Text("Status: \(uiState.currentStep)") + .font(.subheadline) + + Text("Log: \(uiState.latestLogLine)") + .font(.caption) + .foregroundStyle(.secondary) + + Button("Test connection") { + Task { await runProgressiveTorTest() } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 4) + } + + Section { + Button { + appendTorLog("Opened Tor connection logs") + syncRustTorLogs() + showFullLogSheet = true + } label: { + Label("Connection Logs", systemImage: "terminal") + } + } footer: { + Text("View detailed Tor bootstrap logs") + } + } + } + + private var externalSection: some View { + Section("Custom Proxy Settings") { + Text("Configure your custom Tor proxy or SOCKS5 bridge") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("SOCKS Host", text: Binding( + get: { uiState.externalHost }, + set: { value in + uiState.externalHost = value + uiState.externalValidationError = validateTorExternalConfig( + host: value, + port: uiState.externalPort + ) + } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("SOCKS Port", text: Binding( + get: { uiState.externalPort }, + set: { value in + uiState.externalPort = value + uiState.externalValidationError = validateTorExternalConfig( + host: uiState.externalHost, + port: value + ) + } + )) + .keyboardType(.numberPad) + + if let error = uiState.externalValidationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Save config") { + saveExternalConfig() + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + + Button("Test connection") { + Task { await runProgressiveTorTest() } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + } + } + } + + private var orbotSection: some View { + Section { + HStack { + Label(orbotTitle, systemImage: "gearshape") + Spacer() + if uiState.orbotStatus == .checking { + ProgressView() + } + } + + Button { + Task { await refreshOrbotStatus() } + } label: { + Label("Refresh Status", systemImage: "arrow.clockwise") + } + + HStack { + Button("Open Orbot") { + openOrbotBestEffort() + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + + Button("Test connection") { + guard uiState.orbotStatus == .detected else { + noticeMessage = "Orbot SOCKS endpoint is not reachable at 127.0.0.1:9050." + return + } + Task { await runProgressiveTorTest() } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + } + } header: { + Text("Orbot Integration") + } footer: { + Text("On iOS, Orbot mode is validated by checking the local SOCKS endpoint at 127.0.0.1:9050.") + } + } + + private var statusLabel: String { + switch uiState.status { + case .disabled: + "Disabled" + case .bootstrapping: + "Needs testing" + case .ready: + "Configured" + case .error: + "Action required" + } + } + + private var orbotTitle: String { + switch uiState.orbotStatus { + case .checking: + "Checking Orbot..." + case .detected: + "Orbot SOCKS Endpoint Reachable" + case .notDetected: + "Orbot SOCKS Endpoint Not Reachable" + } + } + + private func loadPersistedTorState() { + let persistedUseTor = globalConfig.useTor() + let persistedMode = TorMode.fromConfig(try? globalConfig.get(key: .torMode)) + let host = (try? globalConfig.get(key: .torExternalHost))? + .trimmingCharacters(in: .whitespacesAndNewlines) + let externalHost = (host?.isEmpty == false) ? host! : "127.0.0.1" + let externalPort = String(globalConfig.torExternalPort()) + + torSettingsDiscovered = globalFlag.getBoolConfig(key: .torSettingsDiscovered) || persistedUseTor + uiState = TorUiState( + enabled: persistedUseTor, + mode: persistedMode, + status: persistedUseTor ? .bootstrapping : .disabled, + progressPercent: persistedUseTor ? 1 : 0, + currentStep: persistedUseTor ? "Loading Tor status" : "Disabled", + latestLogLine: persistedUseTor ? "Loading Tor status" : "Tor is off", + logLines: persistedUseTor ? ["[\(torTimestamp())] Loading Tor status"] : ["Tor is off"], + externalHost: externalHost, + externalPort: externalPort, + externalValidationError: validateTorExternalConfig(host: externalHost, port: externalPort), + orbotStatus: .checking + ) + } + + private func refreshInitialTorState() async { + appendTorLog("Opened Tor connection logs") + if uiState.mode == .orbot { + await refreshOrbotStatus() + } + + if uiState.enabled, uiState.mode == .builtIn { + await ensureBuiltInWarmup() + syncRustTorLogs() + } + + await updateNonBuiltInStatus() + await maybeRunPendingOnionValidation() + } + + private func handleUseTorToggle(_ enabled: Bool) { + if !enabled { + let activeNodeIsOnion = isOnionNodeUrl(app.selectedNode.url) + let pendingOnionExists = app.pendingNodeAwaitingTorSetup + && !app.pendingNodeUrl.isEmpty + && isOnionNodeUrl(app.pendingNodeUrl) + + if activeNodeIsOnion || pendingOnionExists { + showDisableTorOnionAlert = true + return + } + + clearPendingOnionDraft() + disableTorState() + appendTorLog("Tor disabled") + return + } + + try? globalConfig.setUseTor(useTor: true) + uiState.enabled = true + uiState.status = .bootstrapping + uiState.currentStep = "Preparing Tor" + appendTorLog("Tor enabled") + + if uiState.mode == .builtIn { + Task { await ensureBuiltInWarmup() } + } + } + + private func setTorMode(_ mode: TorMode) { + try? globalConfig.set(key: .torMode, value: mode.persistedValue) + app.pendingNodeTorValidated = false + autoPendingTestKey = nil + uiState.mode = mode + uiState.status = mode == .builtIn ? .bootstrapping : .bootstrapping + uiState.progressPercent = mode == .builtIn ? 1 : 50 + appendTorLog("Switched Tor mode to \(mode.shortTitle)") + + if mode == .builtIn, uiState.enabled { + builtInWarmupRequested = false + Task { await ensureBuiltInWarmup() } + } else if mode == .orbot { + Task { await refreshOrbotStatus() } + } + } + + private func saveExternalConfig() { + let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) + if let validationError { + uiState.externalValidationError = validationError + noticeMessage = validationError + return + } + + try? globalConfig.set(key: .torExternalHost, value: uiState.externalHost) + if let port = UInt16(uiState.externalPort) { + try? globalConfig.setTorExternalPort(port: port) + } + app.pendingNodeTorValidated = false + appendTorLog("Saved external Tor config: \(uiState.externalHost):\(uiState.externalPort)") + noticeMessage = "Tor proxy configuration saved." + } + + private func disableTorState() { + try? globalConfig.setUseTor(useTor: false) + uiState.enabled = false + uiState.status = .disabled + uiState.progressPercent = 0 + uiState.currentStep = "Disabled" + uiState.latestLogLine = "Tor is off" + builtInWarmupRequested = false + } + + private func disableTorWithClearnetFallback() async { + if isOnionNodeUrl(app.selectedNode.url) { + do { + let fallback = try await switchToFirstClearnetPresetNode(nodeSelector) + appendTorLog("Switched active node to \(fallback.url) before disabling Tor") + noticeMessage = "Switched to \(fallback.name) and disabled Tor." + } catch { + appendTorLog("Unable to disable Tor: clearnet fallback failed (\(error.localizedDescription))") + noticeMessage = "Could not switch to a clearnet node: \(error.localizedDescription)" + return + } + } else if app.pendingNodeAwaitingTorSetup, + !app.pendingNodeUrl.isEmpty, + isOnionNodeUrl(app.pendingNodeUrl) + { + appendTorLog("Discarded pending onion node draft before disabling Tor") + } + + clearPendingOnionDraft() + disableTorState() + appendTorLog("Tor disabled") + } + + private func clearPendingOnionDraft() { + app.clearPendingNodeTorDraft() + autoPendingTestKey = nil + } + + private func appendTorLog(_ message: String) { + let entry = "[\(torTimestamp())] \(message)" + let merged = (uiState.logLines + [entry]).suffix(300) + uiState.logLines = Array(merged) + uiState.latestLogLine = message + } + + private func appendTorTestLog(_ message: String) { + torTestState.logs = Array((torTestState.logs + [message]).suffix(150)) + } + + private func syncRustTorLogs() { + guard uiState.enabled, uiState.mode == .builtIn else { return } + + let rustLogs = torConnectionLogs() + if rustLogs.count < rustTorLogCount { + rustTorLogCount = 0 + } + + let newLogs = rustLogs.dropFirst(rustTorLogCount) + rustTorLogCount = rustLogs.count + if !newLogs.isEmpty { + uiState.logLines = Array((uiState.logLines + Array(newLogs)).suffix(300)) + } + + let snapshot = deriveBuiltInBootstrapSnapshot(rustLogs) + uiState.progressPercent = snapshot.percent + uiState.currentStep = snapshot.step + uiState.latestLogLine = snapshot.lastLine + uiState.status = snapshot.isReady ? .ready : snapshot.hasError ? .error : .bootstrapping + } + + private func ensureBuiltInWarmup() async { + guard !builtInWarmupRequested else { return } + builtInWarmupRequested = true + + do { + let endpoint = try await ensureBuiltInTorBootstrap() + appendTorLog("Built-in Tor bootstrap started at \(endpoint)") + syncRustTorLogs() + } catch { + uiState.status = .error + appendTorLog("Built-in Tor bootstrap failed: \(error.localizedDescription)") + } + } + + private func pollBuiltInTorLogsIfNeeded() async { + guard uiState.enabled, uiState.mode == .builtIn else { return } + + await ensureBuiltInWarmup() + while !Task.isCancelled, uiState.enabled, uiState.mode == .builtIn { + syncRustTorLogs() + await maybeRunPendingOnionValidation() + try? await Task.sleep(for: .seconds(1)) + } + } + + private func refreshOrbotStatus() async { + uiState.orbotStatus = .checking + let reachable = await (testSocksEndpoint(host: "127.0.0.1", port: 9050, timeout: 1.5)).isSuccess() + uiState.orbotStatus = reachable ? .detected : .notDetected + appendTorLog(reachable ? "Orbot SOCKS endpoint reachable" : "Orbot SOCKS endpoint not reachable") + await updateNonBuiltInStatus() + } + + private func updateNonBuiltInStatus() async { + guard uiState.enabled else { + uiState.status = .disabled + uiState.progressPercent = 0 + uiState.currentStep = "Disabled" + uiState.latestLogLine = "Tor is off" + return + } + + switch uiState.mode { + case .builtIn: + syncRustTorLogs() + case .external: + let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) + uiState.externalValidationError = validationError + uiState.status = validationError == nil ? .bootstrapping : .error + uiState.progressPercent = validationError == nil ? 50 : 0 + uiState.currentStep = validationError == nil ? "Save and test custom proxy" : "External proxy config invalid" + uiState.latestLogLine = validationError ?? "\(uiState.externalHost):\(uiState.externalPort)" + case .orbot: + uiState.status = uiState.orbotStatus == .detected ? .bootstrapping : .error + uiState.progressPercent = uiState.orbotStatus == .detected ? 50 : 0 + uiState.currentStep = uiState.orbotStatus == .detected ? "Test Orbot connection" : "Start Orbot first" + uiState.latestLogLine = uiState.orbotStatus == .detected + ? "Orbot endpoint reachable" + : "Orbot endpoint unavailable" + } + } + + private func testTorProxy(host: String, port: Int) async -> Result { + syncRustTorLogs() + appendTorLog("Testing SOCKS endpoint \(host):\(port)") + let result = await testSocksEndpoint(host: host, port: port) + switch result { + case .success: + appendTorLog("SOCKS endpoint reachable: \(host):\(port)") + case let .failure(error): + appendTorLog("SOCKS endpoint failed: \(host):\(port) (\(error.localizedDescription))") + } + syncRustTorLogs() + return result + } + + private func resolveNodeForTorTest() async throws -> Node { + if app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty { + let typeName = app.pendingNodeTypeName.isEmpty ? "Custom Electrum" : app.pendingNodeTypeName + let node = try nodeSelector.parseCustomNode( + url: app.pendingNodeUrl, + name: typeName, + enteredName: app.pendingNodeName + ) + appendTorLog("Using pending node for Tor test: \(app.pendingNodeUrl)") + return node + } + + appendTorLog("Using selected node for Tor test: \(app.selectedNode.url)") + return app.selectedNode + } + + private func runNodeTorTest(_ node: Node) async -> Result { + syncRustTorLogs() + appendTorLog("Checking node via Tor: \(node.url)") + do { + try await nodeSelector.checkSelectedNode(node: node) + appendTorLog("Node check passed: \(node.url)") + syncRustTorLogs() + return .success(()) + } catch { + appendTorLog("Node check failed: \(node.url) (\(error.localizedDescription))") + syncRustTorLogs() + return .failure(error) + } + } + + private func updateTorTestStep(_ key: String, status: TorTestStepStatus, detail: String? = nil) { + torTestState.steps = torTestState.steps.map { step in + guard step.key == key else { return step } + var updated = step + updated.status = status + if let detail { + updated.detail = detail + } + return updated + } + } + + private func runTorApiTestWithRetries(host: String, port: Int) async -> Result { + let maxAttempts = 5 + var timeout: TimeInterval = 8 + var lastError: Error? + + for attempt in 1 ... maxAttempts { + appendTorTestLog("Tor API check attempt \(attempt)/\(maxAttempts) (timeout=\(Int(timeout * 1000))ms)") + let result = await testTorApiThroughSocks(host: host, port: port, timeout: timeout) + if result.isSuccess() { + return result + } + + let error = result.failureValue + lastError = error + let lowerMessage = error?.localizedDescription.lowercased() ?? "" + let timeoutLike = lowerMessage.contains("timeout") || lowerMessage.contains("timed out") + if !timeoutLike || attempt == maxAttempts { + return .failure(error ?? TorSupportError.timeout) + } + + let snapshot = deriveBuiltInBootstrapSnapshot(torConnectionLogs()) + appendTorTestLog("Tor API timed out; retrying while Tor bootstraps (\(snapshot.percent)% \(snapshot.step.lowercased()))") + syncRustTorLogs() + try? await Task.sleep(for: .milliseconds(min(attempt * 1200, 6000))) + timeout = min(timeout + 4, 30) + } + + return .failure(lastError ?? TorSupportError.timeout) + } + + private func runProgressiveTorTest() async { + let endpoint: (String, Int) + do { + switch uiState.mode { + case .builtIn: + let endpointValue = try await ensureBuiltInTorBootstrap() + endpoint = parseEndpointHostPort(endpointValue) ?? ("127.0.0.1", 39050) + case .orbot: + endpoint = ("127.0.0.1", 9050) + case .external: + if let validationError = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) { + uiState.externalValidationError = validationError + noticeMessage = validationError + return + } + endpoint = (uiState.externalHost, Int(uiState.externalPort) ?? 9050) + } + } catch { + app.pendingNodeTorValidated = false + uiState.status = .error + appendTorLog("Failed to prepare Tor endpoint: \(error.localizedDescription)") + noticeMessage = "Failed to prepare Tor endpoint: \(error.localizedDescription)" + return + } + + let (host, port) = endpoint + showTorTestSheet = true + torTestState = TorConnectionTestState( + running: true, + finished: false, + steps: [ + TorTestStep(key: "proxy", title: "Tor proxy reachable", detail: "Checking SOCKS endpoint \(host):\(port)"), + TorTestStep(key: "api", title: "Tor API reports Tor exit", detail: "Checking torproject API over SOCKS"), + TorTestStep(key: "node", title: "Node reachable via Tor", detail: "Checking selected node over Tor"), + ], + logs: ["Starting Tor connection test (\(uiState.mode.shortTitle))"] + ) + + try? globalConfig.setUseTor(useTor: true) + try? globalConfig.set(key: .torMode, value: uiState.mode.persistedValue) + if uiState.mode == .external { + try? globalConfig.set(key: .torExternalHost, value: host) + try? globalConfig.setTorExternalPort(port: UInt16(port)) + } + + updateTorTestStep("proxy", status: .running) + let proxyResult = await testTorProxy(host: host, port: port) + if let error = proxyResult.failureValue { + updateTorTestStep("proxy", status: .failed, detail: "SOCKS check failed: \(error.localizedDescription)") + appendTorTestLog("SOCKS check failed: \(error.localizedDescription)") + app.pendingNodeTorValidated = false + uiState.status = .error + torTestState.running = false + torTestState.finished = true + noticeMessage = "Tor proxy unavailable: \(error.localizedDescription)" + return + } + updateTorTestStep("proxy", status: .passed, detail: "SOCKS endpoint reachable") + appendTorTestLog("SOCKS endpoint reachable") + + updateTorTestStep("api", status: .running) + let torApiResult = await runTorApiTestWithRetries(host: host, port: port) + if let error = torApiResult.failureValue { + updateTorTestStep("api", status: .failed, detail: "Tor API request failed: \(error.localizedDescription)") + appendTorTestLog("Tor API request failed: \(error.localizedDescription)") + app.pendingNodeTorValidated = false + uiState.status = .error + torTestState.running = false + torTestState.finished = true + noticeMessage = "Tor API check failed: \(error.localizedDescription)" + return + } + + guard let apiSnapshot = torApiResult.successValue, apiSnapshot.isTor else { + let raw = torApiResult.successValue?.raw ?? "missing response" + updateTorTestStep("api", status: .failed, detail: "Tor API response did not confirm Tor routing") + appendTorTestLog("Tor API did not confirm Tor routing: \(raw)") + app.pendingNodeTorValidated = false + uiState.status = .error + torTestState.running = false + torTestState.finished = true + noticeMessage = "Tor API check failed: traffic is not exiting through Tor." + return + } + let apiDetail = "Tor API confirmed Tor routing\(apiSnapshot.ip.map { " (\($0))" } ?? "")" + updateTorTestStep("api", status: .passed, detail: apiDetail) + appendTorTestLog(apiDetail) + + updateTorTestStep("node", status: .running) + do { + let node = try await resolveNodeForTorTest() + let nodeResult = await runNodeTorTest(node) + if let error = nodeResult.failureValue { + updateTorTestStep("node", status: .failed, detail: "Node check failed: \(error.localizedDescription)") + appendTorTestLog("Node check failed: \(error.localizedDescription)") + app.pendingNodeTorValidated = false + uiState.status = .error + torTestState.running = false + torTestState.finished = true + noticeMessage = "Node test failed: \(error.localizedDescription)" + return + } + } catch { + updateTorTestStep("node", status: .failed, detail: "Node parse failed: \(error.localizedDescription)") + appendTorTestLog("Node parse failed: \(error.localizedDescription)") + app.pendingNodeTorValidated = false + uiState.status = .error + torTestState.running = false + torTestState.finished = true + noticeMessage = "Node test failed: \(error.localizedDescription)" + return + } + + updateTorTestStep("node", status: .passed, detail: "Node reachable via Tor") + appendTorTestLog("Node reachable via Tor") + app.pendingNodeTorValidated = true + if uiState.mode == .builtIn { + syncRustTorLogs() + } else { + uiState.status = .ready + uiState.progressPercent = 100 + } + appendTorLog("\(uiState.mode.shortTitle) validation passed") + + if app.pendingNodeAwaitingTorSetup { + appendTorTestLog("Pending onion node validated; applying configuration") + appendTorLog("Pending onion node validated; applying configuration") + app.popRoute() + } + + torTestState.running = false + torTestState.finished = true + noticeMessage = "Tor connection test passed." + } + + private func maybeRunPendingOnionValidation() async { + guard app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty else { + autoPendingTestKey = nil + return + } + guard uiState.enabled, !app.pendingNodeTorValidated, !torTestState.running else { return } + + let flowKey = "\(uiState.mode.persistedValue)|\(app.pendingNodeUrl)|\(uiState.externalHost):\(uiState.externalPort)" + guard autoPendingTestKey != flowKey else { return } + + let readyForAutoTest: Bool + switch uiState.mode { + case .builtIn: + readyForAutoTest = uiState.status == .ready + case .orbot: + let socksReady = await (testSocksEndpoint(host: "127.0.0.1", port: 9050, timeout: 1.2)).isSuccess() + readyForAutoTest = socksReady + case .external: + let valid = validateTorExternalConfig(host: uiState.externalHost, port: uiState.externalPort) == nil + let socksReady = await (testSocksEndpoint( + host: uiState.externalHost, + port: Int(uiState.externalPort) ?? 0, + timeout: 1.2 + )).isSuccess() + readyForAutoTest = valid && socksReady + } + + guard readyForAutoTest else { return } + autoPendingTestKey = flowKey + appendTorLog("Pending onion node detected; starting automatic Tor validation") + await runProgressiveTorTest() + } + + private func openOrbotBestEffort() { + if let url = URL(string: "orbot://") { + UIApplication.shared.open(url) { success in + if !success { + appendTorLog("Open Orbot requested; iOS could not open orbot://") + noticeMessage = "Open Orbot manually, then return to Cove and refresh status." + } + } + return + } + + appendTorLog("Open Orbot requested; no iOS URL scheme available") + noticeMessage = "Open Orbot manually, then return to Cove and refresh status." + } +} + +private struct TorFullLogSheet: View { + @Environment(\.dismiss) private var dismiss + let logLines: [String] + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(logLines.enumerated()), id: \.offset) { _, line in + Text("> \(line)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color(.secondarySystemBackground)) + .navigationTitle("Tor Logs") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button("Copy") { + UIPasteboard.general.string = logLines.joined(separator: "\n") + } + } + } + } + } +} + +private struct TorTestSheetContent: View { + let state: TorConnectionTestState + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 0) { + Text("Connection Test") + .font(.title2.weight(.bold)) + .padding(.top, 20) + .padding(.bottom, 18) + + VStack(spacing: 16) { + ForEach(state.steps) { step in + TorTestStepProgressRow(step: step) + } + } + .padding(.horizontal, 24) + + Spacer().frame(height: 28) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "terminal") + Text("LIVE TEST LOGS") + .font(.caption.weight(.bold)) + .tracking(0.5) + } + .foregroundStyle(.white.opacity(0.7)) + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(state.logs.enumerated()), id: \.offset) { index, line in + Text(line) + .id(index) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(height: 120) + .onChange(of: state.logs.count, initial: true) { + if let last = state.logs.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } + } + } + } + .padding(16) + .background(Color.midnightBlue) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 24) + .padding(.top, 8) + + Spacer().frame(height: 28) + + Button(state.running ? "Running Test..." : "Done", action: onDismiss) + .disabled(state.running) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(state.running ? Color.gray.opacity(0.35) : Color.midnightBtn) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 24) + .padding(.bottom, 28) + } + .frame(maxWidth: .infinity) + } +} + +private struct TorTestStepProgressRow: View { + let step: TorTestStep + + private var statusColor: Color { + switch step.status { + case .passed: + TorStatusDot.green.color + case .failed: + TorStatusDot.red.color + case .running: + .blue + case .pending: + .gray.opacity(0.45) + } + } + + var body: some View { + HStack(alignment: .top, spacing: 16) { + Group { + switch step.status { + case .pending: + Circle() + .stroke(statusColor, lineWidth: 2) + .frame(width: 14, height: 14) + .padding(5) + case .running: + ProgressView() + .frame(width: 24, height: 24) + case .passed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(statusColor) + .font(.title3) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(statusColor) + .font(.title3) + } + } + .frame(width: 24) + + VStack(alignment: .leading, spacing: 3) { + Text(step.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(step.status == .pending ? .secondary : .primary) + + if step.status != .pending { + Text(step.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + } +} diff --git a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift index 6895f5fd0..4b685aaff 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift @@ -52,7 +52,7 @@ struct SettingsContainer: View { case .main: MainSettingsScreen() case .network: - SettingsPicker(selection: selectedNetwork) + NetworkSettingsView(selection: selectedNetwork) case .appearance: AppearancePicker case .node: diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift index dccd4371b..3aab63e44 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift @@ -11,13 +11,17 @@ import SwiftUI struct NodeSelectionView: View { /// private private let nodeSelector = NodeSelector() + private let db = Database() + @Environment(AppManager.self) private var app + + @State private var selectedNodeSelection: NodeSelection @State private var selectedNodeName: String private var nodeList: [NodeSelection] - @State private var nodeIsChecking = false - @State private var customNodeName: String = "" - @State private var customUrl: String = "" + @State private var customNodeName = "" + @State private var customUrl = "" + @State private var suppressCustomDraftActions = false @State private var showParseUrlAlert = false @State private var parseUrlMessage = "" @@ -25,18 +29,32 @@ struct NodeSelectionView: View { @State private var checkUrlTask: Task? init() { - selectedNodeName = nodeSelector.selectedNode().name + let selected = nodeSelector.selectedNode() + selectedNodeSelection = selected + selectedNodeName = selected.name nodeList = nodeSelector.nodeList() } + private var customElectrum: String { + "Custom Electrum" + } + + private var customEsplora: String { + "Custom Esplora" + } + var showCustomUrlField: Bool { - selectedNodeName.hasPrefix("Custom") + if selectedNodeName == customElectrum || selectedNodeName == customEsplora { + return true + } + if case .custom = selectedNodeSelection { + return true + } + return false } func cancelCheckUrlTask() { - if let checkUrlTask { - checkUrlTask.cancel() - } + checkUrlTask?.cancel() } private func showLoadingPopup() { @@ -59,7 +77,8 @@ struct NodeSelectionView: View { 7 case .success: 2 - default: 0 + default: + 0 } try? await Task.sleep(for: .seconds(1)) @@ -72,14 +91,21 @@ struct NodeSelectionView: View { @ViewBuilder var CustomFields: some View { if showCustomUrlField { - Section(selectedNodeName) { + Section("Custom node") { HStack { Text("URL") .frame(width: 60, alignment: .leading) - TextField("Enter URL", text: $customUrl) - .keyboardType(.URL) - .textInputAutocapitalization(.never) + TextField("Enter URL", text: Binding( + get: { customUrl }, + set: { value in + suppressCustomDraftActions = false + customUrl = value + } + )) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } .font(.subheadline) @@ -87,24 +113,92 @@ struct NodeSelectionView: View { Text("Name") .frame(width: 60, alignment: .leading) - TextField("Node Name (optional)", text: $customNodeName) - .textInputAutocapitalization(.never) + TextField("Node Name (optional)", text: Binding( + get: { customNodeName }, + set: { value in + suppressCustomDraftActions = false + customNodeName = value + } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } .font(.subheadline) - Button("Save Custom Node", action: checkAndSaveNode) - .disabled(customUrl.isEmpty) + if suppressCustomDraftActions { + Text("Node already saved through Tor validation. Edit URL or name to save changes.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Save Custom Node", action: checkAndSaveCustomNode) + .disabled(customUrl.isEmpty) + } } } } - func checkAndSaveNode() { - var node: Node? = nil + private func customTypeName() -> String { + if selectedNodeName == customElectrum || selectedNodeName == customEsplora { + return selectedNodeName + } + + if case let .custom(node) = selectedNodeSelection { + return node.apiType == .electrum ? customElectrum : customEsplora + } + + return selectedNodeName + } + + private func setCustomSelection(for node: Node) { + selectedNodeSelection = .custom(node) + selectedNodeName = node.apiType == .electrum ? customElectrum : customEsplora + } + + private func clearPendingNodeDraft() { + app.clearPendingNodeTorDraft() + } + + private func restorePendingNodeIfNeeded() { + guard app.pendingNodeAwaitingTorSetup, !app.pendingNodeUrl.isEmpty else { return } + + customUrl = app.pendingNodeUrl + customNodeName = app.pendingNodeName + selectedNodeName = app.pendingNodeTypeName.isEmpty ? customElectrum : app.pendingNodeTypeName + + guard db.globalConfig().useTor(), app.pendingNodeTorValidated else { return } + + Task { + showLoadingPopup() + do { + let node = try nodeSelector.parseCustomNode( + url: app.pendingNodeUrl, + name: selectedNodeName, + enteredName: app.pendingNodeName + ) + try await nodeSelector.checkAndSaveNode(node: node) + setCustomSelection(for: node) + clearPendingNodeDraft() + suppressCustomDraftActions = true + customUrl = "" + customNodeName = "" + completeLoading(.success("Connected to node successfully")) + app.popRoute() + } catch { + completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) + } + } + } + private func parseCustomNodeOrShowError() -> Node? { do { - node = try nodeSelector.parseCustomNode(url: customUrl, name: selectedNodeName, enteredName: customNodeName) - customUrl = node?.url ?? customUrl - customNodeName = node?.name ?? customNodeName + let node = try nodeSelector.parseCustomNode( + url: customUrl, + name: customTypeName(), + enteredName: customNodeName + ) + customUrl = node.url + customNodeName = node.name + return node } catch { showParseUrlAlert = true switch error { @@ -113,118 +207,143 @@ struct NodeSelectionView: View { default: parseUrlMessage = "Unknown error \(error.localizedDescription)" } + return nil } + } - if let node { - Task { - showLoadingPopup() - let result = await Result { try await nodeSelector.checkAndSaveNode(node: node) } + func checkAndSaveCustomNode() { + guard !customUrl.isEmpty else { return } + guard let node = parseCustomNodeOrShowError() else { return } + + if isOnionNodeUrl(node.url) { + if db.globalConfig().useTor() { + Task { + showLoadingPopup() + do { + try await nodeSelector.checkAndSaveNode(node: node) + setCustomSelection(for: node) + clearPendingNodeDraft() + suppressCustomDraftActions = false + completeLoading(.success("Connected to node successfully")) + } catch { + completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) + } + } + return + } - switch result { - case .success: completeLoading(.success("Connected to node successfully")) - case let .failure(error): - let errorMessage = "Failed to connect to node\n \(error.localizedDescription)" - let formattedMessage = errorMessage.replacingOccurrences(of: "\\n", with: "\n") + try? db.globalFlag().set(key: .torSettingsDiscovered, value: true) + try? db.globalConfig().setUseTor(useTor: true) + setCustomSelection(for: node) + app.pendingNodeUrl = node.url + app.pendingNodeName = node.name + app.pendingNodeTypeName = node.apiType == .electrum ? customElectrum : customEsplora + app.pendingNodeAwaitingTorSetup = true + app.pendingNodeTorValidated = false + app.pushRoute(.settings(.network)) + return + } - completeLoading(.failure(formattedMessage)) - } + Task { + showLoadingPopup() + do { + try await nodeSelector.checkAndSaveNode(node: node) + setCustomSelection(for: node) + completeLoading(.success("Connected to node successfully")) + } catch { + completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) } } } + private func selectPresetNode(_ nodeSelection: NodeSelection) { + let node = nodeSelection.toNode() + selectedNodeSelection = nodeSelection + selectedNodeName = node.name + suppressCustomDraftActions = false + + if case .custom = nodeSelection { + customUrl = node.url + customNodeName = node.name + selectedNodeName = node.apiType == .electrum ? customElectrum : customEsplora + return + } + + customUrl = "" + customNodeName = "" + showLoadingPopup() + let task = Task { + do { + let selected = try nodeSelector.selectPresetNode(name: node.name) + try await nodeSelector.checkSelectedNode(node: selected) + selectedNodeSelection = .preset(selected) + selectedNodeName = selected.name + completeLoading(.success("Succesfully connected to \(selected.url)")) + } catch { + completeLoading(.failure("Failed to connect to \(node.url), reason: \(error.localizedDescription)")) + } + } + checkUrlTask = task + } + var body: some View { Form { Section { - ForEach(nodeList, id: \.name) { (node: NodeSelection) in - HStack { - Text(node.name) - .font(.subheadline) - - Spacer() - - if selectedNodeName == node.name { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - .font(.footnote) - .fontWeight(.semibold) - } - } - .contentShape(Rectangle()) - .onTapGesture { selectedNodeName = node.name } + ForEach(nodeList, id: \.name) { nodeSelection in + NodeRow( + nodeName: nodeSelection.name, + isSelected: selectedNodeSelection == nodeSelection || selectedNodeName == nodeSelection.name, + onTap: { selectPresetNode(nodeSelection) } + ) } - HStack { - Text("Custom Electrum") - .font(.subheadline) - - Spacer() - - if selectedNodeName == "Custom Electrum" { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - .font(.footnote) - .fontWeight(.semibold) + NodeRow( + nodeName: customElectrum, + isSelected: selectedNodeName == customElectrum, + onTap: { + suppressCustomDraftActions = false + selectedNodeName = customElectrum + if case let .custom(node) = selectedNodeSelection, node.apiType == .electrum { + customUrl = node.url + customNodeName = node.name + } } - } - .contentShape(Rectangle()) - .onTapGesture { selectedNodeName = "Custom Electrum" } - - HStack { - Text("Custom Esplora") - .font(.subheadline) - - Spacer() - - if selectedNodeName == "Custom Esplora" { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - .font(.footnote) - .fontWeight(.semibold) + ) + + NodeRow( + nodeName: customEsplora, + isSelected: selectedNodeName == customEsplora, + onTap: { + suppressCustomDraftActions = false + selectedNodeName = customEsplora + if case let .custom(node) = selectedNodeSelection, node.apiType == .esplora { + customUrl = node.url + customNodeName = node.name + } } - } - .contentShape(Rectangle()) - .onTapGesture { selectedNodeName = "Custom Esplora" } + ) } CustomFields } .scrollContentBackground(.hidden) - .onChange(of: selectedNodeName) { _, newSelectedNodeName in - if selectedNodeName.hasPrefix("Custom") { - if case let .custom(savedSelectedNode) = nodeSelector.selectedNode() { - if savedSelectedNode.apiType == .electrum, selectedNodeName.contains("Electrum") { - customUrl = savedSelectedNode.url - customNodeName = savedSelectedNode.name - } - - if savedSelectedNode.apiType == .esplora, selectedNodeName.contains("Esplora") { - customUrl = savedSelectedNode.url - customNodeName = savedSelectedNode.name - } + .onAppear(perform: restorePendingNodeIfNeeded) + .onChange(of: selectedNodeName) { _, _ in + guard showCustomUrlField, customUrl.isEmpty, !suppressCustomDraftActions else { return } + if case let .custom(savedSelectedNode) = nodeSelector.selectedNode() { + if savedSelectedNode.apiType == .electrum, selectedNodeName == customElectrum { + customUrl = savedSelectedNode.url + customNodeName = savedSelectedNode.name } - return - } - - guard let node = try? nodeSelector.selectPresetNode(name: newSelectedNodeName) else { return } - - showLoadingPopup() - let task = Task { - do { - try await nodeSelector.checkSelectedNode(node: node) - completeLoading(.success("Succesfully connected to \(node.url)")) - } catch { - completeLoading(.failure("Failed to connect to \(node.url), reason: \(error.localizedDescription)")) + if savedSelectedNode.apiType == .esplora, selectedNodeName == customEsplora { + customUrl = savedSelectedNode.url + customNodeName = savedSelectedNode.name } } - checkUrlTask = task - } - .onChange(of: nodeList) { _, _ in - selectedNodeName = nodeSelector.selectedNode().name } .onDisappear { - // custom esplora or electrum is selected - if showCustomUrlField { checkAndSaveNode() } + cancelCheckUrlTask() } .alert(isPresented: $showParseUrlAlert) { Alert( @@ -240,6 +359,30 @@ struct NodeSelectionView: View { } } +private struct NodeRow: View { + let nodeName: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + HStack { + Text(nodeName) + .font(.subheadline) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + .font(.footnote) + .fontWeight(.semibold) + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: onTap) + } +} + #Preview { SettingsContainer(route: .node) .environment(AppManager.shared) diff --git a/ios/Cove/TorSupport.swift b/ios/Cove/TorSupport.swift new file mode 100644 index 000000000..4a0955806 --- /dev/null +++ b/ios/Cove/TorSupport.swift @@ -0,0 +1,678 @@ +import Foundation +import Network +import SwiftUI + +enum TorStatus { + case disabled + case bootstrapping + case ready + case error +} + +enum OrbotStatus { + case checking + case detected + case notDetected +} + +struct TorUiState: Equatable { + var enabled = false + var mode: TorMode = .builtIn + var status: TorStatus = .disabled + var progressPercent = 0 + var currentStep = "Disabled" + var latestLogLine = "Tor is off" + var logLines: [String] = ["Tor is off"] + var externalHost = "127.0.0.1" + var externalPort = "9050" + var externalValidationError: String? + var orbotStatus: OrbotStatus = .checking + var orbotVersion: String? +} + +struct TorBootstrapSnapshot: Equatable { + var percent: Int + var step: String + var isReady: Bool + var hasError: Bool + var lastLine: String +} + +struct TorApiSnapshot: Equatable { + var isTor: Bool + var ip: String? + var raw: String +} + +enum TorStatusDot: Equatable { + case green + case yellow + case red + case gray + + var color: Color { + switch self { + case .green: Color(red: 0.20, green: 0.78, blue: 0.35) + case .yellow: Color(red: 1.00, green: 0.76, blue: 0.03) + case .red: Color(red: 1.00, green: 0.23, blue: 0.19) + case .gray: Color(red: 0.62, green: 0.62, blue: 0.62) + } + } +} + +struct TorQuickStatus: Equatable { + var enabled = false + var overall: TorStatusDot = .gray + var torConnection: TorStatusDot = .gray + var nodeReachable: TorStatusDot = .gray + var nodeSynced: TorStatusDot = .gray + var torMessage = "Tor disabled" + var nodeMessage = "Node status unavailable" + var syncMessage = "Sync status unavailable" + var logs: [String] = [] +} + +enum TorSupportError: LocalizedError { + case invalidEndpoint + case timeout + case invalidResponse + case notHTTP + + var errorDescription: String? { + switch self { + case .invalidEndpoint: + "Invalid SOCKS endpoint" + case .timeout: + "Connection timed out" + case .invalidResponse: + "Invalid Tor API response" + case .notHTTP: + "Tor API did not return an HTTP response" + } + } +} + +extension TorMode { + static let uiCases: [TorMode] = [.builtIn, .orbot, .external] + + var title: String { + switch self { + case .builtIn: "Built-in" + case .orbot: "Orbot (External App)" + case .external: "Custom SOCKS5 Proxy" + } + } + + var shortTitle: String { + switch self { + case .builtIn: "Built-in" + case .orbot: "Orbot" + case .external: "Custom SOCKS5 Proxy" + } + } + + var persistedValue: String { + switch self { + case .builtIn: "BUILT_IN" + case .orbot: "ORBOT" + case .external: "EXTERNAL" + } + } + + static func fromConfig(_ value: String?) -> TorMode { + switch value { + case "Orbot", "ORBOT": + .orbot + case "External", "EXTERNAL": + .external + default: + .builtIn + } + } +} + +func torTimestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: Date()) +} + +func validateTorExternalConfig(host: String, port: String) -> String? { + if host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Host is required" + } + + guard let parsed = Int(port) else { + return "Port must be a number" + } + + guard (1 ... 65535).contains(parsed) else { + return "Port must be between 1 and 65535" + } + + return nil +} + +func parseEndpointHostPort(_ endpoint: String) -> (String, Int)? { + let parts = endpoint.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, let port = Int(parts[1]), (1 ... 65535).contains(port) else { + return nil + } + + let host = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return nil } + return (host, port) +} + +func isOnionNodeUrl(_ url: String) -> Bool { + func host(from value: String) -> String? { + URLComponents(string: value)?.host + } + + let rawHost = host(from: url) ?? host(from: "tcp://\(url)") + return rawHost?.lowercased().hasSuffix(".onion") == true +} + +func switchToFirstClearnetPresetNode(_ nodeSelector: NodeSelector) async throws -> Node { + let fallbackNode = nodeSelector.nodeList().compactMap { selection -> Node? in + guard case let .preset(node) = selection else { return nil } + return isOnionNodeUrl(node.url) ? nil : node + }.first + + guard let fallbackNode else { + throw TorSupportError.invalidEndpoint + } + + try await nodeSelector.checkAndSaveNode(node: fallbackNode) + return fallbackNode +} + +private func isRustTorLog(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("[INFO ") + || trimmed.hasPrefix("[WARN ") + || trimmed.hasPrefix("[ERROR ") + || trimmed.hasPrefix("[DEBUG ") +} + +private func firstRegexInt(_ pattern: String, in string: String, group: Int = 1) -> Int? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, range: range), + match.numberOfRanges > group, + let valueRange = Range(match.range(at: group), in: string) + else { + return nil + } + + return Int(string[valueRange]) +} + +private func firstRegexInts(_ pattern: String, in string: String) -> [Int]? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, range: range), match.numberOfRanges > 1 else { + return nil + } + + return (1 ..< match.numberOfRanges).compactMap { index in + guard let valueRange = Range(match.range(at: index), in: string) else { return nil } + return Int(string[valueRange]) + } +} + +func deriveBuiltInBootstrapSnapshot(_ logLines: [String]) -> TorBootstrapSnapshot { + guard !logLines.isEmpty else { + return TorBootstrapSnapshot( + percent: 0, + step: "Waiting for Tor runtime", + isReady: false, + hasError: false, + lastLine: "No Tor logs yet" + ) + } + + let rustLogs = logLines.filter(isRustTorLog) + guard !rustLogs.isEmpty else { + return TorBootstrapSnapshot( + percent: 0, + step: "Waiting for Tor runtime", + isReady: false, + hasError: false, + lastLine: logLines.last ?? "No Tor logs yet" + ) + } + + let restartMarkers = [ + "built-in tor endpoint requested without cache; launching proxy", + "built-in tor launch initiated", + "starting built-in tor runtime thread", + ] + let restartIndex = rustLogs.indices.last { index in + let line = rustLogs[index].lowercased() + return restartMarkers.contains { line.contains($0) } + } + let scopedLogs = restartIndex.map { Array(rustLogs[$0...]) } ?? rustLogs + + var percent = 0 + var step = "Starting Tor" + var ready = false + var hasError = false + var initialMissingMicrodescriptors: Int? + + for line in scopedLogs { + let lowered = line.lowercased() + if let found = firstRegexInt(#"\b(100|[0-9]{1,2})%\b"#, in: line), found > percent { + percent = found + } + + if lowered.contains("built-in tor launch initiated") + || lowered.contains("starting built-in tor runtime thread") + { + percent = max(percent, 3) + step = "Launching runtime" + } else if lowered.contains("built-in tor runtime created") + || lowered.contains("launching arti socks proxy task") + { + percent = max(percent, 8) + step = "Starting SOCKS proxy" + } else if lowered.contains("listening on"), + lowered.contains("127.0.0.1:") || lowered.contains("[::1]:") + { + percent = max(percent, 15) + step = "SOCKS listener ready" + } else if lowered.contains("looking for a consensus") { + percent = max(percent, 22) + step = "Looking for consensus" + } else if lowered.contains("downloading certificates for consensus") { + percent = max(percent, 35) + step = "Downloading consensus certificates" + } else if lowered.contains("downloading microdescriptors") { + step = "Downloading microdescriptors" + let missingFraction = firstRegexInts(#"missing\s+(\d+)\s*/\s*(\d+)"#, in: lowered) + let missing = missingFraction?.first ?? firstRegexInt(#"missing\s+(\d+)"#, in: lowered) + + if let missing { + let baseline: Int + if let total = missingFraction?.dropFirst().first, total > 0 { + if initialMissingMicrodescriptors == nil || total > initialMissingMicrodescriptors! { + initialMissingMicrodescriptors = total + } + baseline = max(initialMissingMicrodescriptors ?? total, 1) + } else { + if initialMissingMicrodescriptors == nil || missing > initialMissingMicrodescriptors! { + initialMissingMicrodescriptors = missing + } + baseline = max(initialMissingMicrodescriptors ?? missing, 1) + } + + let completedRatio = Double(max(baseline - missing, 0)) / Double(baseline) + let dynamicPercent = min(max(45 + Int(completedRatio * 46.0), 45), 91) + percent = max(percent, dynamicPercent) + } else { + percent = max(percent, 45) + } + } else if lowered.contains("marked consensus usable") { + percent = max(percent, 93) + step = "Building circuits" + } else if lowered.contains("enough information to build circuits") { + percent = max(percent, 96) + step = "Building circuits" + } else if lowered.contains("directory is complete") { + percent = 100 + step = "Tor ready" + ready = true + } else if lowered.contains("sufficiently bootstrapped; proxy now functional") { + percent = max(percent, 97) + step = "Circuits available, finishing directory" + } + + let benignReloadXdgWarning = lowered.contains("arti::reload_cfg") + && (lowered.contains("xdg project directories") + || lowered.contains("unable to determine home directory") + || lowered.contains("cache_dir")) + let fatalSignals = [ + "built-in tor bootstrap failed", + "built-in tor proxy exited", + "failed to initialize built-in tor runtime", + "failed to create built-in tor runtime", + "built-in tor socks listener not ready", + "can't find path for port_info_file", + "operation not supported because arti feature disabled", + ] + + if !benignReloadXdgWarning, fatalSignals.contains(where: { lowered.contains($0) }) { + hasError = true + } + } + + if ready { + hasError = false + } + + if !ready, percent >= 100 { + percent = 99 + } + + return TorBootstrapSnapshot( + percent: min(max(percent, 0), 100), + step: step, + isReady: ready, + hasError: hasError, + lastLine: scopedLogs.last ?? rustLogs.last ?? logLines.last ?? "No Tor logs yet" + ) +} + +func testSocksEndpoint(host: String, port: Int, timeout: TimeInterval = 3) async -> Result { + guard (1 ... 65535).contains(port), + let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) + else { + return .failure(TorSupportError.invalidEndpoint) + } + + return await withCheckedContinuation { continuation in + let queue = DispatchQueue(label: "org.bitcoinppl.cove.tor.socks-test") + let connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) + var finished = false + + func finish(_ result: Result) { + guard !finished else { return } + finished = true + connection.cancel() + continuation.resume(returning: result) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + finish(.success(())) + case let .failed(error): + finish(.failure(error)) + case let .waiting(error): + if case .posix(.ECONNREFUSED) = error { + finish(.failure(error)) + } + default: + break + } + } + + queue.asyncAfter(deadline: .now() + timeout) { + finish(.failure(TorSupportError.timeout)) + } + connection.start(queue: queue) + } +} + +func testTorApiThroughSocks(host: String, port: Int, timeout: TimeInterval = 8) async -> Result { + guard (1 ... 65535).contains(port) else { + return .failure(TorSupportError.invalidEndpoint) + } + + let httpsResult = await testTorApiThroughUrlSessionSocks(host: host, port: port, timeout: timeout) + if httpsResult.isSuccess() { + return httpsResult + } + + return await withCheckedContinuation { continuation in + SocksTorApiProbe( + host: host, + port: port, + timeout: timeout, + continuation: continuation + ).start() + } +} + +private func testTorApiThroughUrlSessionSocks( + host: String, + port: Int, + timeout: TimeInterval +) async -> Result { + do { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.connectionProxyDictionary = [ + "SOCKSEnable": NSNumber(value: true), + "SOCKSProxy": host, + "SOCKSPort": NSNumber(value: port), + ] + + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + + guard let url = URL(string: "https://check.torproject.org/api/ip") else { + return .failure(TorSupportError.invalidEndpoint) + } + + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse else { + return .failure(TorSupportError.notHTTP) + } + guard (200 ..< 300).contains(http.statusCode) else { + return .failure(TorSupportError.invalidResponse) + } + + return parseTorApiJson(data) + } catch { + return .failure(error) + } +} + +private func parseTorApiJson(_ data: Data) -> Result { + let raw = String(data: data, encoding: .utf8) ?? "" + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let isTor = (json?["IsTor"] as? Bool) + ?? (json?["is_tor"] as? Bool) + ?? (raw.range( + of: #""istor"\s*:\s*true"#, + options: [.caseInsensitive, .regularExpression] + ) != nil) + let ip = json?["IP"] as? String + + return .success(TorApiSnapshot(isTor: isTor, ip: ip, raw: raw)) +} + +private final class SocksTorApiProbe: @unchecked Sendable { + private let host: String + private let port: Int + private let timeout: TimeInterval + private let continuation: CheckedContinuation, Never> + private let queue = DispatchQueue(label: "org.bitcoinppl.cove.tor.api-test") + private var connection: NWConnection? + private var finished = false + + init( + host: String, + port: Int, + timeout: TimeInterval, + continuation: CheckedContinuation, Never> + ) { + self.host = host + self.port = port + self.timeout = timeout + self.continuation = continuation + } + + func start() { + guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + finish(.failure(TorSupportError.invalidEndpoint)) + return + } + + let connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) + self.connection = connection + connection.stateUpdateHandler = { [self] state in + switch state { + case .ready: + sendSocksGreeting() + case let .failed(error): + finish(.failure(error)) + case let .waiting(error): + if case .posix(.ECONNREFUSED) = error { + finish(.failure(error)) + } + default: + break + } + } + + queue.asyncAfter(deadline: .now() + timeout) { [self] in + finish(.failure(TorSupportError.timeout)) + } + connection.start(queue: queue) + } + + private func finish(_ result: Result) { + guard !finished else { return } + finished = true + connection?.cancel() + continuation.resume(returning: result) + } + + private func send(_ data: Data, then next: @escaping () -> Void) { + connection?.send(content: data, completion: .contentProcessed { [self] error in + if let error { + finish(.failure(error)) + return + } + next() + }) + } + + private func receive( + minimumLength: Int, + maximumLength: Int, + then next: @escaping (Data) -> Void + ) { + connection?.receive( + minimumIncompleteLength: minimumLength, + maximumLength: maximumLength + ) { [self] data, _, _, error in + if let error { + finish(.failure(error)) + return + } + + guard let data, data.count >= minimumLength else { + finish(.failure(TorSupportError.invalidResponse)) + return + } + + next(data) + } + } + + private func sendSocksGreeting() { + send(Data([0x05, 0x01, 0x00])) { [self] in + receive(minimumLength: 2, maximumLength: 2) { [self] data in + let bytes = [UInt8](data) + guard bytes.count == 2, bytes[0] == 0x05, bytes[1] == 0x00 else { + finish(.failure(TorSupportError.invalidResponse)) + return + } + sendSocksConnectRequest() + } + } + } + + private func sendSocksConnectRequest() { + let domain = "check.torproject.org" + guard let domainData = domain.data(using: .utf8), domainData.count < 256 else { + finish(.failure(TorSupportError.invalidEndpoint)) + return + } + + var request = Data([0x05, 0x01, 0x00, 0x03, UInt8(domainData.count)]) + request.append(domainData) + request.append(contentsOf: [0x00, 0x50]) + + send(request) { [self] in + receiveSocksConnectResponseHeader() + } + } + + private func receiveSocksConnectResponseHeader() { + receive(minimumLength: 4, maximumLength: 4) { [self] data in + let bytes = [UInt8](data) + guard bytes.count == 4, bytes[0] == 0x05, bytes[1] == 0x00 else { + finish(.failure(TorSupportError.invalidResponse)) + return + } + + switch bytes[3] { + case 0x01: + receiveAndDiscardSocksAddress(length: 6) + case 0x03: + receiveSocksDomainLength() + case 0x04: + receiveAndDiscardSocksAddress(length: 18) + default: + finish(.failure(TorSupportError.invalidResponse)) + } + } + } + + private func receiveSocksDomainLength() { + receive(minimumLength: 1, maximumLength: 1) { [self] data in + guard let length = data.first else { + finish(.failure(TorSupportError.invalidResponse)) + return + } + receiveAndDiscardSocksAddress(length: Int(length) + 2) + } + } + + private func receiveAndDiscardSocksAddress(length: Int) { + receive(minimumLength: length, maximumLength: length) { [self] _ in + sendTorApiHttpRequest() + } + } + + private func sendTorApiHttpRequest() { + let request = [ + "GET /api/ip HTTP/1.1", + "Host: check.torproject.org", + "Accept: application/json", + "Accept-Encoding: identity", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + send(Data(request.utf8)) { [self] in + receiveHttpResponse(Data()) + } + } + + private func receiveHttpResponse(_ accumulated: Data) { + connection?.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [self] data, _, isComplete, error in + if let error { + finish(.failure(error)) + return + } + + var nextData = accumulated + if let data { + nextData.append(data) + } + + if isComplete || nextData.count > 128 * 1024 { + finish(parseTorApiHttpResponse(nextData)) + return + } + + receiveHttpResponse(nextData) + } + } + + private func parseTorApiHttpResponse(_ data: Data) -> Result { + let raw = String(data: data, encoding: .utf8) ?? "" + guard raw.hasPrefix("HTTP/1.1 200") || raw.hasPrefix("HTTP/1.0 200") else { + return .failure(TorSupportError.invalidResponse) + } + + let body = raw.components(separatedBy: "\r\n\r\n").dropFirst().joined(separator: "\r\n\r\n") + return parseTorApiJson(Data((body.isEmpty ? raw : body).utf8)) + } +} diff --git a/ios/Cove/WalletManager.swift b/ios/Cove/WalletManager.swift index 2a251db6b..e47392185 100644 --- a/ios/Cove/WalletManager.swift +++ b/ios/Cove/WalletManager.swift @@ -171,6 +171,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana self.loadState = .scanning(txns) case let .availableTransactions(txns): + errorAlert = nil switch self.loadState { case .loading: self.loadState = .scanning(txns) @@ -185,6 +186,7 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana } case let .updatedTransactions(txns): + errorAlert = nil switch self.loadState { case .scanning, .loading: self.loadState = .scanning(txns) @@ -193,9 +195,11 @@ extension WeakReconciler: WalletManagerReconciler where Reconciler == WalletMana } case let .scanComplete(txns): + errorAlert = nil self.loadState = .loaded(txns) case let .walletBalanceChanged(balance): + errorAlert = nil withAnimation { self.balance = balance } case .unsignedTransactionsChanged: diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index a5a86b8f9..9a9b6d979 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -4001,6 +4001,14 @@ public protocol GlobalConfigTableProtocol: AnyObject, Sendable { func setSelectedNode(node: Node) throws + func setTorExternalPort(port: UInt16) throws + + func setUseTor(useTor: Bool) throws + + func torExternalPort() -> UInt16 + + func useTor() -> Bool + func walletMode() -> WalletMode } @@ -4209,6 +4217,38 @@ open func setSelectedNode(node: Node)throws {try rustCallWithError(FfiConverte } } +open func setTorExternalPort(port: UInt16)throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { + uniffi_cove_fn_method_globalconfigtable_set_tor_external_port( + self.uniffiCloneHandle(), + FfiConverterUInt16.lower(port),$0 + ) +} +} + +open func setUseTor(useTor: Bool)throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { + uniffi_cove_fn_method_globalconfigtable_set_use_tor( + self.uniffiCloneHandle(), + FfiConverterBool.lower(useTor),$0 + ) +} +} + +open func torExternalPort() -> UInt16 { + return try! FfiConverterUInt16.lift(try! rustCall() { + uniffi_cove_fn_method_globalconfigtable_tor_external_port( + self.uniffiCloneHandle(),$0 + ) +}) +} + +open func useTor() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_cove_fn_method_globalconfigtable_use_tor( + self.uniffiCloneHandle(),$0 + ) +}) +} + open func walletMode() -> WalletMode { return try! FfiConverterTypeWalletMode_lift(try! rustCall() { uniffi_cove_fn_method_globalconfigtable_wallet_mode( @@ -21614,6 +21654,10 @@ public enum GlobalConfigKey: Equatable, Hashable { case selectedFiatCurrency case selectedNode(Network ) + case useTor + case torMode + case torExternalHost + case torExternalPort case colorScheme case authType case hashedPinCode @@ -21654,25 +21698,33 @@ public struct FfiConverterTypeGlobalConfigKey: FfiConverterRustBuffer { case 4: return .selectedNode(try FfiConverterTypeNetwork.read(from: &buf) ) - case 5: return .colorScheme + case 5: return .useTor + + case 6: return .torMode - case 6: return .authType + case 7: return .torExternalHost - case 7: return .hashedPinCode + case 8: return .torExternalPort - case 8: return .wipeDataPin + case 9: return .colorScheme - case 9: return .decoyPin + case 10: return .authType - case 10: return .inDecoyMode + case 11: return .hashedPinCode - case 11: return .mainSelectedWalletId + case 12: return .wipeDataPin - case 12: return .decoySelectedWalletId + case 13: return .decoyPin - case 13: return .lockedAt + case 14: return .inDecoyMode - case 14: return .onboardingProgress + case 15: return .mainSelectedWalletId + + case 16: return .decoySelectedWalletId + + case 17: return .lockedAt + + case 18: return .onboardingProgress default: throw UniffiInternalError.unexpectedEnumCase } @@ -21699,45 +21751,61 @@ public struct FfiConverterTypeGlobalConfigKey: FfiConverterRustBuffer { FfiConverterTypeNetwork.write(v1, into: &buf) - case .colorScheme: + case .useTor: writeInt(&buf, Int32(5)) - case .authType: + case .torMode: writeInt(&buf, Int32(6)) - case .hashedPinCode: + case .torExternalHost: writeInt(&buf, Int32(7)) - case .wipeDataPin: + case .torExternalPort: writeInt(&buf, Int32(8)) - case .decoyPin: + case .colorScheme: writeInt(&buf, Int32(9)) - case .inDecoyMode: + case .authType: writeInt(&buf, Int32(10)) - case .mainSelectedWalletId: + case .hashedPinCode: writeInt(&buf, Int32(11)) - case .decoySelectedWalletId: + case .wipeDataPin: writeInt(&buf, Int32(12)) - case .lockedAt: + case .decoyPin: writeInt(&buf, Int32(13)) - case .onboardingProgress: + case .inDecoyMode: writeInt(&buf, Int32(14)) + + case .mainSelectedWalletId: + writeInt(&buf, Int32(15)) + + + case .decoySelectedWalletId: + writeInt(&buf, Int32(16)) + + + case .lockedAt: + writeInt(&buf, Int32(17)) + + + case .onboardingProgress: + writeInt(&buf, Int32(18)) + } } } @@ -21867,6 +21935,7 @@ public enum GlobalFlagKey: Equatable, Hashable { case acceptedTerms case betaFeaturesEnabled case betaImportExportEnabled + case torSettingsDiscovered @@ -21896,6 +21965,8 @@ public struct FfiConverterTypeGlobalFlagKey: FfiConverterRustBuffer { case 4: return .betaImportExportEnabled + case 5: return .torSettingsDiscovered + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -21919,6 +21990,10 @@ public struct FfiConverterTypeGlobalFlagKey: FfiConverterRustBuffer { case .betaImportExportEnabled: writeInt(&buf, Int32(4)) + + case .torSettingsDiscovered: + writeInt(&buf, Int32(5)) + } } } @@ -29240,6 +29315,154 @@ public func FfiConverterTypeTapSignerRoute_lower(_ value: TapSignerRoute) -> Rus +public +enum TorBootstrapError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case BuiltInTor(message: String) + + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension TorBootstrapError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTorBootstrapError: FfiConverterRustBuffer { + typealias SwiftType = TorBootstrapError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TorBootstrapError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .BuiltInTor( + message: try FfiConverterString.read(from: &buf) + ) + + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: TorBootstrapError, into buf: inout [UInt8]) { + switch value { + + + + + case .BuiltInTor(_ /* message is ignored*/): + writeInt(&buf, Int32(1)) + + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTorBootstrapError_lift(_ buf: RustBuffer) throws -> TorBootstrapError { + return try FfiConverterTypeTorBootstrapError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTorBootstrapError_lower(_ value: TorBootstrapError) -> RustBuffer { + return FfiConverterTypeTorBootstrapError.lower(value) +} + + + +public enum TorMode: Equatable, Hashable { + + case builtIn + case orbot + case external + + + + + +} + +#if compiler(>=6) +extension TorMode: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTorMode: FfiConverterRustBuffer { + typealias SwiftType = TorMode + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TorMode { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .builtIn + + case 2: return .orbot + + case 3: return .external + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: TorMode, into buf: inout [UInt8]) { + switch value { + + + case .builtIn: + writeInt(&buf, Int32(1)) + + + case .orbot: + writeInt(&buf, Int32(2)) + + + case .external: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTorMode_lift(_ buf: RustBuffer) throws -> TorMode { + return try FfiConverterTypeTorMode.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTorMode_lower(_ value: TorMode) -> RustBuffer { + return FfiConverterTypeTorMode.lower(value) +} + + + public enum Transaction { @@ -35829,6 +36052,25 @@ private func uniffiForeignFutureDroppedCallback(handle: UInt64) { public func uniffiForeignFutureHandleCountCove() -> Int { UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.count } +public func clearTorConnectionLogs() {try! rustCall() { + uniffi_cove_fn_func_clear_tor_connection_logs($0 + ) +} +} +public func ensureBuiltInTorBootstrap()async throws -> String { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_func_ensure_built_in_tor_bootstrap( + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterString.lift, + errorHandler: FfiConverterTypeTorBootstrapError_lift + ) +} /** * set root data directory before any database access * required for Android to specify app-specific storage path @@ -35839,6 +36081,12 @@ public func setRootDataDir(path: String)throws {try rustCallWithError(FfiConve ) } } +public func torConnectionLogs() -> [String] { + return try! FfiConverterSequenceString.lift(try! rustCall() { + uniffi_cove_fn_func_tor_connection_logs($0 + ) +}) +} /** * Initialize the global App instance (Updater, router, state) * Must be called after storage bootstrap completes @@ -36244,9 +36492,18 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap() != 8592) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_func_set_root_data_dir() != 56109) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_func_tor_connection_logs() != 59010) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_func_initialize_app() != 18498) { return InitializationResult.apiChecksumMismatch } @@ -36610,6 +36867,18 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_globalconfigtable_set_selected_node() != 44882) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_globalconfigtable_set_tor_external_port() != 2190) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_globalconfigtable_set_use_tor() != 3178) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_globalconfigtable_tor_external_port() != 48622) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_globalconfigtable_use_tor() != 22520) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_globalconfigtable_wallet_mode() != 27720) { return InitializationResult.apiChecksumMismatch } From abe162cffca18a42b8abfbd61ebe5ef36436afc1 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Thu, 23 Apr 2026 13:42:15 +0200 Subject: [PATCH 4/8] feat: Add just setup in CI workflows --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f3da9d8..3668f8ca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: with: tool: just + - name: Set up repository + run: just setup + - name: Setup Java uses: actions/setup-java@v5 with: @@ -106,6 +109,9 @@ jobs: with: tool: just + - name: Set up repository + run: just setup + - name: Build Rust FFI bindings run: just build-ios @@ -140,6 +146,12 @@ jobs: with: toolchain: stable + - name: Set up just + uses: extractions/setup-just@v1 + + - name: Set up repository + run: just setup + - name: Run tests run: cd rust && cargo test --workspace @@ -153,4 +165,10 @@ jobs: components: clippy override: true + - name: Set up just + uses: extractions/setup-just@v1 + + - name: Set up repository + run: just setup + - run: cd rust && cargo clippy -- -D warnings From be30cc99e3c139829ed65edb26413da07d4801f7 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 29 Apr 2026 19:29:03 +0300 Subject: [PATCH 5/8] fix: Stabilize Tor bootstrap status and onion flows (#256) Use structured Arti bootstrap status, Polish Tor quick status, SOCKS availability handling, custom onion node saves, and Tor disable fallback behavior. --- .../java/org/bitcoinppl/cove/AppManager.kt | 7 +- .../java/org/bitcoinppl/cove/MainActivity.kt | 10 +- .../SelectedWalletScreen.kt | 85 +++++- .../SettingsFlow/NetworkSettingsScreen.kt | 259 +++++++++++++---- .../flows/SettingsFlow/NodeSettingsScreen.kt | 103 +++---- .../cove/flows/SettingsFlow/TorNodeSupport.kt | 25 +- .../org/bitcoinppl/cove/tor/TorModeParsing.kt | 12 + .../bitcoinppl/cove/tor/TorStatusSupport.kt | 40 ++- .../java/org/bitcoinppl/cove_core/cove.kt | 96 +++++++ ios/Cove/CoveMainView.swift | 13 +- .../SelectedWalletScreen.swift | 54 +++- .../SettingsFlow/NetworkSettingsView.swift | 140 +++++++--- .../SettingsScreen/NodeSelectionView.swift | 28 +- ios/Cove/Info.plist | 4 + ios/Cove/TorSupport.swift | 257 +++-------------- .../Sources/CoveCore/generated/cove.swift | 89 ++++++ rust/crates/cove-common/src/logging.rs | 53 +++- rust/src/database/global_config.rs | 10 +- rust/src/lib.rs | 15 + rust/src/node/client.rs | 19 +- rust/src/node_connect.rs | 32 ++- rust/src/tor_runtime.rs | 264 +++++++++++------- 22 files changed, 1082 insertions(+), 533 deletions(-) create mode 100644 android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index 8f6ec7c41..fb7289371 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.cloudbackup.CloudBackupManager import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter +import org.bitcoinppl.cove.tor.parseCoreTorMode import org.bitcoinppl.cove_core.* import org.bitcoinppl.cove_core.AppAlertState import org.bitcoinppl.cove_core.device.KeychainException @@ -547,9 +548,9 @@ class AppManager private constructor() : FfiReconcile { return } - val mode = globalConfig.get(GlobalConfigKey.TorMode) - ?: org.bitcoinppl.cove_core.TorMode.BUILT_IN.name - if (mode != org.bitcoinppl.cove_core.TorMode.BUILT_IN.name) { + val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull() + val mode = parseCoreTorMode(modeName) + if (mode != TorMode.BUILT_IN) { Log.d(tag, "Tor enabled with mode=$mode; no built-in warmup needed") return } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 56549ec45..754307057 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -89,6 +89,7 @@ import org.bitcoinppl.cove.navigation.CoveNavDisplay import org.bitcoinppl.cove.nfc.NfcScanSheet import org.bitcoinppl.cove.nfc.TapCardNfcManager import org.bitcoinppl.cove.sidebar.SidebarContainer +import org.bitcoinppl.cove.tor.parseCoreTorMode import org.bitcoinppl.cove.tor.testSocksEndpoint import org.bitcoinppl.cove.ui.theme.CoveTheme import org.bitcoinppl.cove.views.LockView @@ -455,19 +456,18 @@ class MainActivity : FragmentActivity() { return@LaunchedEffect } - val mode = - runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull() - ?: TorMode.BUILT_IN.name + val mode = parseCoreTorMode(runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull()) when (mode) { - TorMode.ORBOT.name -> { + TorMode.BUILT_IN -> Unit + TorMode.ORBOT -> { val reachable = testSocksEndpoint("127.0.0.1", 9050, 1500).isSuccess if (!reachable) { startupTorUnavailableMode = TorMode.ORBOT startupTorUnavailableEndpoint = "127.0.0.1:9050" } } - TorMode.EXTERNAL.name -> { + TorMode.EXTERNAL -> { val host = runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) } .getOrNull() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt index db0de1951..b31d0aa9e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt @@ -94,10 +94,12 @@ import org.bitcoinppl.cove_core.WalletErrorAlert import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.WalletSettingsRoute import org.bitcoinppl.cove_core.WalletType +import org.bitcoinppl.cove_core.builtInTorBootstrapStatus import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap import org.bitcoinppl.cove_core.torConnectionLogs import org.bitcoinppl.cove_core.types.WalletId import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot +import org.bitcoinppl.cove.tor.parseCoreTorMode import org.bitcoinppl.cove.tor.testSocksEndpoint import java.util.concurrent.atomic.AtomicBoolean @@ -128,6 +130,43 @@ private fun TorStatusDot.color(): Color = TorStatusDot.Gray -> Color(0xFF9E9E9E) } +private fun recentTorQuickLogs(logs: List): List { + val usefulMarkers = + listOf( + "arti_client::status", + "tor_dirmgr", + "tor_guardmgr", + "tor_runtime", + "bootstrapped", + "bootstrap", + "directory", + "consensus", + "microdescriptors", + "failed", + "error", + "warn", + ) + + return logs + .asSequence() + .filter { line -> + usefulMarkers.any { marker -> line.contains(marker, ignoreCase = true) } + }.map { line -> + line.replace(Regex("""^\[(INFO|WARN|ERROR|DEBUG) [^\]]+]\s*"""), "") + }.filter { line -> line.isNotBlank() } + .distinct() + .toList() + .takeLast(6) +} + +private fun leadingPercent(message: String): Int? = + Regex("""^(\d{1,3})%:""") + .find(message) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.coerceIn(0, 100) + private fun parseEndpointHostPort(endpoint: String): Pair? { val host = endpoint.substringBefore(':', "") val port = endpoint.substringAfter(':', "").toIntOrNull() @@ -257,10 +296,10 @@ fun SelectedWalletScreen( while (true) { val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false) val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull() - val torMode = runCatching { TorMode.valueOf(modeName ?: TorMode.BUILT_IN.name) } - .getOrDefault(TorMode.BUILT_IN) + val torMode = parseCoreTorMode(modeName) val torLogs = runCatching { torConnectionLogs() }.getOrDefault(emptyList()) val bootstrap = deriveBuiltInBootstrapSnapshot(torLogs) + val builtInStatus = runCatching { builtInTorBootstrapStatus() }.getOrNull() val torConnection: TorStatusDot val torMessage: String @@ -279,27 +318,51 @@ fun SelectedWalletScreen( parseEndpointHostPort(builtInEndpoint) ?: ("127.0.0.1" to 39050) val socksReady = testSocksEndpoint(builtInHost, builtInPort, 1200).isSuccess + val hasStructuredStatus = builtInStatus?.launched == true + val bootstrapReady = builtInStatus?.ready ?: bootstrap.isReady + val bootstrapError = + builtInStatus?.lastError ?: if (!hasStructuredStatus && bootstrap.hasError) bootstrap.step else null + val bootstrapMessage = + bootstrapError + ?: builtInStatus + ?.blocked + ?.let { "Blocked: $it" } + ?: bootstrap + .step + .takeIf { leadingPercent(it) != null } + ?: builtInStatus + ?.message + ?.takeIf { hasStructuredStatus && it.isNotBlank() } + ?: bootstrap.step + val bootstrapPercent = + leadingPercent(bootstrapMessage) + ?: builtInStatus + ?.takeIf { hasStructuredStatus } + ?.percent + ?.toInt() + ?.coerceIn(0, 100) + ?: bootstrap.percent torConnection = when { - bootstrap.isReady && socksReady -> TorStatusDot.Green - bootstrap.hasError -> TorStatusDot.Red + bootstrapReady && socksReady -> TorStatusDot.Green + bootstrapError != null -> TorStatusDot.Red else -> TorStatusDot.Yellow } torMessage = when (torConnection) { TorStatusDot.Green -> "Built-in Tor ready" - TorStatusDot.Red -> "Built-in Tor error" - else -> "Built-in Tor bootstrapping (${bootstrap.percent}%)" + TorStatusDot.Red -> "Built-in Tor error: $bootstrapMessage" + else -> "Built-in Tor bootstrapping ($bootstrapPercent%)" } } TorMode.ORBOT -> { val socksReady = testSocksEndpoint("127.0.0.1", 9050, 1200).isSuccess - torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Yellow + torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red torMessage = if (socksReady) { "Orbot SOCKS endpoint reachable" } else { - "Waiting for Orbot SOCKS endpoint" + "Orbot SOCKS endpoint unavailable" } } TorMode.EXTERNAL -> { @@ -312,12 +375,12 @@ fun SelectedWalletScreen( runCatching { globalConfig.torExternalPort().toInt() } .getOrDefault(9050) val socksReady = testSocksEndpoint(host, port, 1200).isSuccess - torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Yellow + torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red torMessage = if (socksReady) { "External SOCKS endpoint reachable" } else { - "Waiting for external SOCKS endpoint" + "External SOCKS endpoint unavailable" } } } @@ -375,7 +438,7 @@ fun SelectedWalletScreen( torMessage = torMessage, nodeMessage = nodeMessage, syncMessage = syncMessage, - logs = torLogs.takeLast(6), + logs = recentTorQuickLogs(torLogs), ) delay(2500) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt index da1770eb2..797cce77f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt @@ -90,6 +90,7 @@ import org.bitcoinppl.cove_core.Node import org.bitcoinppl.cove_core.NodeSelector import org.bitcoinppl.cove_core.NodeSelectorException import org.bitcoinppl.cove_core.TorMode as CoreTorMode +import org.bitcoinppl.cove_core.builtInTorBootstrapStatus import org.bitcoinppl.cove_core.ensureBuiltInTorBootstrap import org.bitcoinppl.cove_core.torConnectionLogs import org.bitcoinppl.cove_core.types.Network @@ -97,6 +98,7 @@ import org.bitcoinppl.cove_core.types.allNetworks import org.bitcoinppl.cove.Log import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.tor.deriveBuiltInBootstrapSnapshot +import org.bitcoinppl.cove.tor.parseCoreTorMode import org.bitcoinppl.cove.tor.testSocksEndpoint import org.bitcoinppl.cove.tor.testTorApiThroughSocks import java.net.SocketTimeoutException @@ -142,18 +144,24 @@ fun NetworkSettingsScreen( val nodeSelector = remember { NodeSelector() } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + val persistedUseTor = remember { runCatching { globalConfig.useTor() }.getOrDefault(false) } val torSettingsDiscovered = remember { - globalFlag.getBoolConfig(GlobalFlagKey.TOR_SETTINGS_DISCOVERED) + persistedUseTor || + runCatching { + globalFlag.getBoolConfig(GlobalFlagKey.TOR_SETTINGS_DISCOVERED) + }.getOrDefault(false) + } + val persistedTorMode = remember { + parseTorMode(runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull()) } - - val persistedUseTor = remember { globalConfig.useTor() } - val persistedTorMode = remember { parseTorMode(globalConfig.get(GlobalConfigKey.TorMode)) } val persistedExternalHost = remember { - globalConfig.get(GlobalConfigKey.TorExternalHost) + runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) }.getOrNull() ?.takeIf { it.isNotBlank() } ?: "127.0.0.1" } - val persistedExternalPort = remember { globalConfig.torExternalPort().toString() } + val persistedExternalPort = remember { + runCatching { globalConfig.torExternalPort() }.getOrDefault(9050).toString() + } var uiState by remember { @@ -204,21 +212,57 @@ fun NetworkSettingsScreen( (uiState.logLines + newLogs).takeLast(300) } val snapshot = deriveBuiltInBootstrapSnapshot(rustLogs) + val structuredStatus = runCatching { builtInTorBootstrapStatus() }.getOrNull() + val hasStructuredStatus = structuredStatus?.launched == true val status = when { !uiState.enabled -> TorStatus.Disabled + structuredStatus?.ready == true -> TorStatus.Ready + structuredStatus?.lastError != null -> TorStatus.Error + hasStructuredStatus -> TorStatus.Bootstrapping snapshot.isReady -> TorStatus.Ready snapshot.hasError -> TorStatus.Error else -> TorStatus.Bootstrapping } + val currentStep = + structuredStatus + ?.lastError + ?: structuredStatus + ?.blocked + ?.let { "Blocked: $it" } + ?: snapshot + .step + .takeIf { Regex("""^\d{1,3}%:""").containsMatchIn(it) } + ?: structuredStatus + ?.message + ?.takeIf { hasStructuredStatus && it.isNotBlank() } + ?: snapshot.step + val messagePercent = + Regex("""^(\d{1,3})%:""") + .find(currentStep) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.coerceIn(0, 100) + val structuredPercent = + structuredStatus + ?.takeIf { hasStructuredStatus } + ?.percent + ?.toInt() + ?.coerceIn(0, 100) + val progressPercent = + when { + status == TorStatus.Ready -> 100 + else -> messagePercent ?: structuredPercent ?: snapshot.percent + } uiState = uiState.copy( status = status, - currentStep = snapshot.step, + currentStep = currentStep, latestLogLine = snapshot.lastLine, logLines = merged, - progressPercent = snapshot.percent, + progressPercent = progressPercent, ) }.onFailure { error -> Log.e(logTag, "syncRustTorLogs failed: ${error.message}", error) @@ -235,26 +279,94 @@ fun NetworkSettingsScreen( } fun disableTorState() { - globalConfig.setUseTor(false) + runCatching { + globalConfig.setUseTor(false) + }.onFailure { error -> + appendTorLog("Failed to disable Tor: ${error.message ?: "unknown error"}") + scope.launch { + snackbarHostState.showSnackbar("Could not disable Tor: ${error.message ?: "unknown error"}") + } + return + } + uiState = uiState.copy(enabled = false) builtInWarmupRequested = false } + fun enableTorState(): Boolean = + runCatching { + globalConfig.setUseTor(true) + }.onFailure { error -> + appendTorLog("Failed to enable Tor: ${error.message ?: "unknown error"}") + scope.launch { + snackbarHostState.showSnackbar("Could not enable Tor: ${error.message ?: "unknown error"}") + } + }.isSuccess + + fun setPersistedTorMode( + mode: TorMode, + onFailure: (Throwable) -> Unit = {}, + ): Boolean { + val persistedMode = + when (mode) { + TorMode.BuiltIn -> CoreTorMode.BUILT_IN.name + TorMode.Orbot -> CoreTorMode.ORBOT.name + TorMode.External -> CoreTorMode.EXTERNAL.name + } + + return runCatching { + globalConfig.set(GlobalConfigKey.TorMode, persistedMode) + }.onFailure { error -> + appendTorLog("Failed to save Tor mode: ${error.message ?: "unknown error"}") + scope.launch { + snackbarHostState.showSnackbar("Could not save Tor mode: ${error.message ?: "unknown error"}") + } + onFailure(error) + }.isSuccess + } + + fun saveExternalTorConfig( + host: String, + port: UShort, + proxyLog: String = redactedProxyForLog(host, port), + ): Boolean = + runCatching { + globalConfig.set(GlobalConfigKey.TorExternalHost, host) + globalConfig.setTorExternalPort(port) + }.onFailure { error -> + appendTorLog("Failed to save external Tor config $proxyLog: ${error.message ?: "unknown error"}") + scope.launch { + snackbarHostState.showSnackbar("Could not save Tor proxy configuration: ${error.message ?: "unknown error"}") + } + }.isSuccess + + fun persistTorTestConfiguration(host: String, port: UShort): Boolean { + if (!setPersistedTorMode(uiState.mode)) { + return false + } + if (uiState.mode == TorMode.External && !saveExternalTorConfig(host, port)) { + return false + } + return enableTorState() + } + suspend fun testTorProxy(host: String, port: UShort): Result = try { + val proxyLog = redactedProxyForLog(host, port) syncRustTorLogs() - appendTorLog("Testing SOCKS endpoint $host:$port") - Log.d(logTag, "testTorProxy start: host=$host, port=$port") + appendTorLog("Testing SOCKS endpoint $proxyLog") + Log.d(logTag, "testTorProxy start: proxy=$proxyLog") testSocksEndpoint(host, port.toInt()).getOrThrow() - Log.d(logTag, "testTorProxy success: host=$host, port=$port") - appendTorLog("SOCKS endpoint reachable: $host:$port") + Log.d(logTag, "testTorProxy success: proxy=$proxyLog") + appendTorLog("SOCKS endpoint reachable: $proxyLog") syncRustTorLogs() Result.success(Unit) } catch (error: CancellationException) { throw error } catch (error: Exception) { - Log.e(logTag, "testTorProxy failed: host=$host, port=$port, reason=${error.message}", error) - appendTorLog("SOCKS endpoint failed: $host:$port (${error.message ?: "unknown error"})") + val proxyLog = redactedProxyForLog(host, port) + Log.e(logTag, "testTorProxy failed: proxy=$proxyLog, reason=${error.message}", error) + appendTorLog("SOCKS endpoint failed: $proxyLog (${error.message ?: "unknown error"})") syncRustTorLogs() Result.failure(error) } @@ -264,13 +376,13 @@ fun NetworkSettingsScreen( withContext(Dispatchers.IO) { if (app.pendingNodeAwaitingTorSetup && app.pendingNodeUrl.isNotBlank()) { val typeName = app.pendingNodeTypeName.ifBlank { "Custom Electrum" } - Log.d(logTag, "resolveNodeForTorTest using pending node: url=${app.pendingNodeUrl}, type=$typeName, name=${app.pendingNodeName}") + Log.d(logTag, "resolveNodeForTorTest using pending node: type=$typeName, ${redactedEndpointForLog(app.pendingNodeUrl)}") val parsed = nodeSelector.parseCustomNode(app.pendingNodeUrl, typeName, app.pendingNodeName) - parsed to "Using pending node for Tor test: ${app.pendingNodeUrl}" + parsed to "Using pending node for Tor test: ${redactedNodeForLog(parsed)}" } else { val selected = app.selectedNode - Log.d(logTag, "resolveNodeForTorTest using selected node: name=${selected.name}, apiType=${selected.apiType}, url=${selected.url}") - selected to "Using selected node for Tor test: ${selected.url}" + Log.d(logTag, "resolveNodeForTorTest using selected node: ${redactedNodeForLog(selected)}") + selected to "Using selected node for Tor test: ${redactedNodeForLog(selected)}" } } @@ -281,20 +393,22 @@ fun NetworkSettingsScreen( suspend fun runNodeTorTest(node: Node): Result = try { syncRustTorLogs() - appendTorLog("Checking node via Tor: ${node.url}") - Log.d(logTag, "runNodeTorTest start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") + val nodeLog = redactedNodeForLog(node) + appendTorLog("Checking node via Tor: $nodeLog") + Log.d(logTag, "runNodeTorTest start: $nodeLog") withContext(Dispatchers.IO) { nodeSelector.checkSelectedNode(node) } - Log.d(logTag, "runNodeTorTest success: name=${node.name}, url=${node.url}") - appendTorLog("Node check passed: ${node.url}") + Log.d(logTag, "runNodeTorTest success: $nodeLog") + appendTorLog("Node check passed: $nodeLog") syncRustTorLogs() Result.success(Unit) } catch (error: CancellationException) { throw error } catch (error: Exception) { - Log.e(logTag, "runNodeTorTest failed: name=${node.name}, url=${node.url}, reason=${error.message}", error) - appendTorLog("Node check failed: ${node.url} (${error.message ?: "unknown error"})") + val nodeLog = redactedNodeForLog(node) + Log.e(logTag, "runNodeTorTest failed: $nodeLog, reason=${error.message}", error) + appendTorLog("Node check failed: $nodeLog (${error.message ?: "unknown error"})") syncRustTorLogs() Result.failure(error) } @@ -338,7 +452,7 @@ fun NetworkSettingsScreen( port: Int, ): Result { val maxAttempts = 5 - var timeoutMs = 8000 + var timeoutMs = 15000 var lastError: Throwable? = null repeat(maxAttempts) { index -> @@ -404,6 +518,13 @@ fun NetworkSettingsScreen( } val (host, port) = endpoint + if (!persistTorTestConfiguration(host, port)) { + app.pendingNodeTorValidated = false + uiState = uiState.copy(status = TorStatus.Error) + return + } + + val proxyLog = redactedProxyForLog(host, port) showTorTestDialog = true torTestState = TorConnectionTestState( @@ -414,7 +535,7 @@ fun NetworkSettingsScreen( TorTestStep( key = "proxy", title = "Tor proxy reachable", - detail = "Checking SOCKS endpoint $host:$port", + detail = "Checking SOCKS endpoint $proxyLog", ), TorTestStep( key = "api", @@ -430,10 +551,8 @@ fun NetworkSettingsScreen( logs = listOf("Starting Tor connection test (${uiState.mode})"), ) - globalConfig.setUseTor(true) when (uiState.mode) { TorMode.BuiltIn -> { - globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.BUILT_IN.name) if (!builtInWarmupRequested) { builtInWarmupRequested = true runCatching { ensureBuiltInTorBootstrap() } @@ -444,12 +563,9 @@ fun NetworkSettingsScreen( } } } - TorMode.Orbot -> globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.ORBOT.name) - TorMode.External -> { - globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.EXTERNAL.name) - globalConfig.set(GlobalConfigKey.TorExternalHost, host) - globalConfig.setTorExternalPort(port) - } + TorMode.Orbot, + TorMode.External, + -> Unit } updateTorTestStep("proxy", TorTestStepStatus.Running) @@ -627,14 +743,22 @@ fun NetworkSettingsScreen( currentStep = if (validationError == null) torActionSaveConfig else torErrorConfigInvalid, latestLogLine = if (validationError == null) { - "${uiState.externalHost}:${uiState.externalPort}" + redactedProxyForLog( + uiState.externalHost, + uiState.externalPort.toUShortOrNull() ?: 9050u, + ) } else { validationError }, ) appendTorLog( if (validationError == null) { - "External Tor selected: ${uiState.externalHost}:${uiState.externalPort}" + "External Tor selected: ${ + redactedProxyForLog( + uiState.externalHost, + uiState.externalPort.toUShortOrNull() ?: 9050u, + ) + }" } else { "External Tor config invalid: $validationError" }, @@ -803,7 +927,9 @@ fun NetworkSettingsScreen( return@MaterialSettingsItem } - globalConfig.setUseTor(true) + if (!enableTorState()) { + return@MaterialSettingsItem + } uiState = uiState.copy(enabled = true) if (uiState.mode == TorMode.BuiltIn && !builtInWarmupRequested) { scope.launch { @@ -897,13 +1023,6 @@ fun NetworkSettingsScreen( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 12.dp), ) - Text( - text = "${stringResource(R.string.tor_latest_log_prefix)} ${uiState.latestLogLine}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -996,11 +1115,20 @@ fun NetworkSettingsScreen( return@Button } - Log.d(logTag, "save external tor config: host=${uiState.externalHost}, port=${uiState.externalPort}") - globalConfig.set(GlobalConfigKey.TorExternalHost, uiState.externalHost) - uiState.externalPort.toUShortOrNull()?.let { globalConfig.setTorExternalPort(it) } + val proxyLog = + redactedProxyForLog( + uiState.externalHost, + uiState.externalPort.toUShortOrNull() ?: 9050u, + ) + Log.d(logTag, "save external tor config: proxy=$proxyLog") + val externalPort = uiState.externalPort.toUShortOrNull() + if (externalPort == null || + !saveExternalTorConfig(uiState.externalHost, externalPort, proxyLog) + ) { + return@Button + } app.pendingNodeTorValidated = false - appendTorLog("Saved external Tor config: ${uiState.externalHost}:${uiState.externalPort}") + appendTorLog("Saved external Tor config: $proxyLog") scope.launch { snackbarHostState.showSnackbar(torSuccessConfigurationSaved) } }, modifier = Modifier.weight(1f), @@ -1018,8 +1146,13 @@ fun NetworkSettingsScreen( } scope.launch { - appendTorLog("External Tor connection test requested for ${uiState.externalHost}:${uiState.externalPort}") - Log.d(logTag, "external test connection tapped: host=${uiState.externalHost}, port=${uiState.externalPort}, pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}") + val proxyLog = + redactedProxyForLog( + uiState.externalHost, + uiState.externalPort.toUShortOrNull() ?: 9050u, + ) + appendTorLog("External Tor connection test requested for $proxyLog") + Log.d(logTag, "external test connection tapped: proxy=$proxyLog, pendingAwaiting=${app.pendingNodeAwaitingTorSetup}, pendingValidated=${app.pendingNodeTorValidated}") runProgressiveTorTest() } }, @@ -1137,7 +1270,9 @@ fun NetworkSettingsScreen( selected = uiState.mode == TorMode.BuiltIn, onClick = { Log.d(logTag, "set tor mode: BUILT_IN") - globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.BUILT_IN.name) + if (!setPersistedTorMode(TorMode.BuiltIn)) { + return@TorModeOption + } app.pendingNodeTorValidated = false autoPendingTestKey = null uiState = uiState.copy(mode = TorMode.BuiltIn) @@ -1162,7 +1297,9 @@ fun NetworkSettingsScreen( selected = uiState.mode == TorMode.Orbot, onClick = { Log.d(logTag, "set tor mode: ORBOT") - globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.ORBOT.name) + if (!setPersistedTorMode(TorMode.Orbot)) { + return@TorModeOption + } app.pendingNodeTorValidated = false autoPendingTestKey = null uiState = uiState.copy(mode = TorMode.Orbot) @@ -1175,7 +1312,9 @@ fun NetworkSettingsScreen( selected = uiState.mode == TorMode.External, onClick = { Log.d(logTag, "set tor mode: EXTERNAL") - globalConfig.set(GlobalConfigKey.TorMode, CoreTorMode.EXTERNAL.name) + if (!setPersistedTorMode(TorMode.External)) { + return@TorModeOption + } app.pendingNodeTorValidated = false autoPendingTestKey = null uiState = uiState.copy(mode = TorMode.External) @@ -1215,7 +1354,9 @@ fun NetworkSettingsScreen( } val fallbackNode = fallbackResult.getOrThrow() - appendTorLog("Switched active node to ${fallbackNode.url} before disabling Tor") + appendTorLog( + "Switched active node to ${redactedNodeForLog(fallbackNode)} before disabling Tor", + ) snackbarHostState.showSnackbar( torFallbackApplied.format(fallbackNode.name), ) @@ -1506,10 +1647,10 @@ private fun TorTestStepProgressRow(step: TorTestStep) { } private fun parseTorMode(mode: String?): TorMode { - return when (mode) { - CoreTorMode.ORBOT.name -> TorMode.Orbot - CoreTorMode.EXTERNAL.name -> TorMode.External - else -> TorMode.BuiltIn + return when (parseCoreTorMode(mode)) { + CoreTorMode.ORBOT -> TorMode.Orbot + CoreTorMode.EXTERNAL -> TorMode.External + CoreTorMode.BUILT_IN -> TorMode.BuiltIn } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt index 7be60d473..5725a14f8 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NodeSettingsScreen.kt @@ -60,6 +60,7 @@ import org.bitcoinppl.cove.views.SectionHeader import org.bitcoinppl.cove_core.ApiType import org.bitcoinppl.cove_core.Database import org.bitcoinppl.cove_core.GlobalFlagKey +import org.bitcoinppl.cove_core.Node import org.bitcoinppl.cove_core.NodeSelection import org.bitcoinppl.cove_core.NodeSelector import org.bitcoinppl.cove_core.NodeSelectorException @@ -80,7 +81,7 @@ fun NodeSettingsScreen( val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - val nodeList = remember { nodeSelector.nodeList() } + var nodeList by remember { mutableStateOf(nodeSelector.nodeList()) } var selectedNodeSelection by remember { mutableStateOf(nodeSelector.selectedNode()) } var selectedNodeName by remember { mutableStateOf(selectedNodeSelection.toNode().name) @@ -95,6 +96,15 @@ fun NodeSettingsScreen( var errorMessage by remember { mutableStateOf("") } var errorTitle by remember { mutableStateOf("") } + fun refreshNodeSelection(node: Node) { + nodeList = NodeSelector().nodeList() + selectedNodeSelection = NodeSelection.Custom(node) + selectedNodeName = node.name + suppressCustomDraftActions = true + customUrl = "" + customNodeName = "" + } + // compute all string resources at composable level val customElectrum = stringResource(R.string.node_custom_electrum) val customEsplora = stringResource(R.string.node_custom_esplora) @@ -121,7 +131,7 @@ fun NodeSettingsScreen( customUrl = app.pendingNodeUrl customNodeName = app.pendingNodeName selectedNodeName = app.pendingNodeTypeName - Log.d(logTag, "restored pending onion draft: type=$selectedNodeName, url=$customUrl") + Log.d(logTag, "restored pending onion draft: type=$selectedNodeName, ${redactedEndpointForLog(customUrl)}") if (globalConfig.useTor() && app.pendingNodeTorValidated) { isLoading = true @@ -140,22 +150,13 @@ fun NodeSettingsScreen( nodeSelector.checkAndSaveNode(node) } - selectedNodeSelection = NodeSelection.Custom(node) - selectedNodeName = - when (node.apiType) { - ApiType.ELECTRUM -> customElectrum - ApiType.ESPLORA -> customEsplora - else -> node.name - } + refreshNodeSelection(node) app.pendingNodeAwaitingTorSetup = false app.pendingNodeTorValidated = false app.pendingNodeUrl = "" app.pendingNodeName = "" app.pendingNodeTypeName = "" - suppressCustomDraftActions = true - customUrl = "" - customNodeName = "" scope.launch { snackbarHostState.showSnackbar(successSaved) @@ -211,14 +212,14 @@ fun NodeSettingsScreen( val node = withContext(Dispatchers.IO) { val selected = nodeSelector.selectPresetNode(nodeName) - Log.d(logTag, "selectPresetNode resolved: name=${selected.name}, apiType=${selected.apiType}, url=${selected.url}") + Log.d(logTag, "selectPresetNode resolved: ${redactedNodeForLog(selected)}") selected } withContext(Dispatchers.IO) { - Log.d(logTag, "checkSelectedNode start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") + Log.d(logTag, "checkSelectedNode start: ${redactedNodeForLog(node)}") nodeSelector.checkSelectedNode(node) - Log.d(logTag, "checkSelectedNode success: name=${node.name}, url=${node.url}") + Log.d(logTag, "checkSelectedNode success: ${redactedNodeForLog(node)}") } selectedNodeSelection = NodeSelection.Preset(node) @@ -249,8 +250,18 @@ fun NodeSettingsScreen( } } + fun selectCustomNodeType(nodeName: String) { + Log.d(logTag, "selectCustomNodeType: nodeName=$nodeName") + suppressCustomDraftActions = false + if (selectedNodeName != nodeName) { + customUrl = "" + customNodeName = "" + } + selectedNodeName = nodeName + } + fun checkAndSaveCustomNode() { - Log.d(logTag, "checkAndSaveCustomNode: selectedNodeName=$selectedNodeName, customUrl=$customUrl, customNodeName=$customNodeName") + Log.d(logTag, "checkAndSaveCustomNode: selectedNodeName=$selectedNodeName, ${redactedEndpointForLog(customUrl)}") if (customUrl.isEmpty()) { Log.e(logTag, "checkAndSaveCustomNode aborted: empty customUrl") errorTitle = errorTitleDefault @@ -280,9 +291,9 @@ fun NodeSettingsScreen( val node = withContext(Dispatchers.IO) { - Log.d(logTag, "parseCustomNode start: url=$customUrl, typeName=$customNodeTypeName, enteredName=$customNodeName") + Log.d(logTag, "parseCustomNode start: typeName=$customNodeTypeName, ${redactedEndpointForLog(customUrl)}") val parsed = nodeSelector.parseCustomNode(customUrl, customNodeTypeName, customNodeName) - Log.d(logTag, "parseCustomNode success: parsedName=${parsed.name}, apiType=${parsed.apiType}, parsedUrl=${parsed.url}") + Log.d(logTag, "parseCustomNode success: ${redactedNodeForLog(parsed)}") parsed } @@ -293,32 +304,34 @@ fun NodeSettingsScreen( val isOnionNode = isOnionNodeUrl(node.url) if (isOnionNode) { if (globalConfig.useTor()) { - Log.d(logTag, "onion node detected with Tor already enabled; saving directly: url=${node.url}") + Log.d(logTag, "onion node detected with Tor already enabled; saving directly: ${redactedNodeForLog(node)}") withContext(Dispatchers.IO) { nodeSelector.checkAndSaveNode(node) } - selectedNodeSelection = NodeSelection.Custom(node) - selectedNodeName = - when (node.apiType) { - ApiType.ELECTRUM -> customElectrum - ApiType.ESPLORA -> customEsplora - else -> node.name - } + refreshNodeSelection(node) app.pendingNodeAwaitingTorSetup = false app.pendingNodeTorValidated = false app.pendingNodeUrl = "" app.pendingNodeName = "" app.pendingNodeTypeName = "" - suppressCustomDraftActions = false scope.launch { snackbarHostState.showSnackbar(successSaved) } return@launch } - Log.d(logTag, "onion node detected, redirecting to network settings: url=${node.url}") - globalFlag.set(GlobalFlagKey.TOR_SETTINGS_DISCOVERED, true) - globalConfig.setUseTor(true) + Log.d(logTag, "onion node detected, redirecting to network settings: ${redactedNodeForLog(node)}") + runCatching { + globalFlag.set(GlobalFlagKey.TOR_SETTINGS_DISCOVERED, true) + globalConfig.setUseTor(true) + }.onFailure { error -> + Log.e(logTag, "failed to persist Tor setup before onion redirect: ${error.message}", error) + errorTitle = errorTitleDefault + errorMessage = errorUnknown.format(error.message ?: "") + showErrorDialog = true + return@launch + } + selectedNodeSelection = NodeSelection.Custom(node) selectedNodeName = when (node.apiType) { @@ -341,35 +354,29 @@ fun NodeSettingsScreen( } withContext(Dispatchers.IO) { - Log.d(logTag, "checkAndSaveNode start: name=${node.name}, apiType=${node.apiType}, url=${node.url}") + Log.d(logTag, "checkAndSaveNode start: ${redactedNodeForLog(node)}") nodeSelector.checkAndSaveNode(node) - Log.d(logTag, "checkAndSaveNode success: name=${node.name}, apiType=${node.apiType}, url=${node.url}") + Log.d(logTag, "checkAndSaveNode success: ${redactedNodeForLog(node)}") } - selectedNodeSelection = NodeSelection.Custom(node) - selectedNodeName = - when (node.apiType) { - ApiType.ELECTRUM -> customElectrum - ApiType.ESPLORA -> customEsplora - else -> node.name - } - Log.d(logTag, "custom node saved: selectedNodeName=$selectedNodeName, persistedName=${node.name}, apiType=${node.apiType}, url=${node.url}") + refreshNodeSelection(node) + Log.d(logTag, "custom node saved: selectedNodeName=$selectedNodeName, ${redactedNodeForLog(node)}") // launch snackbar in separate coroutine so it doesn't block finally scope.launch { snackbarHostState.showSnackbar(successSaved) } } catch (e: NodeSelectorException.ParseNodeUrlException) { - Log.e(logTag, "checkAndSaveCustomNode failed: ParseNodeUrl url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) + Log.e(logTag, "checkAndSaveCustomNode failed: ParseNodeUrl ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) errorTitle = errorParseTitle errorMessage = e.v1 showErrorDialog = true } catch (e: NodeSelectorException.NodeAccessException) { - Log.e(logTag, "checkAndSaveCustomNode failed: NodeAccess url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) + Log.e(logTag, "checkAndSaveCustomNode failed: NodeAccess ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.v1}", e) errorTitle = errorConnectionFailed errorMessage = errorConnectionMessage.format(e.v1) showErrorDialog = true } catch (e: Exception) { - Log.e(logTag, "checkAndSaveCustomNode failed: unexpected url=$customUrl, selectedNodeName=$selectedNodeName, reason=${e.message}", e) + Log.e(logTag, "checkAndSaveCustomNode failed: unexpected ${redactedEndpointForLog(customUrl)}, selectedNodeName=$selectedNodeName, reason=${e.message}", e) errorTitle = errorTitleDefault errorMessage = errorUnknown.format(e.message ?: "") showErrorDialog = true @@ -452,10 +459,7 @@ fun NodeSettingsScreen( NodeRow( nodeName = customElectrum, isSelected = selectedNodeName == customElectrum, - onClick = { - suppressCustomDraftActions = false - selectedNodeName = customElectrum - }, + onClick = { selectCustomNodeType(customElectrum) }, ) MaterialDivider() @@ -464,10 +468,7 @@ fun NodeSettingsScreen( NodeRow( nodeName = customEsplora, isSelected = selectedNodeName == customEsplora, - onClick = { - suppressCustomDraftActions = false - selectedNodeName = customEsplora - }, + onClick = { selectCustomNodeType(customEsplora) }, ) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt index 48b2c878c..f35e3c6be 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/TorNodeSupport.kt @@ -6,15 +6,35 @@ import org.bitcoinppl.cove_core.Node import org.bitcoinppl.cove_core.NodeSelection import org.bitcoinppl.cove_core.NodeSelector import java.net.URI +import java.security.MessageDigest fun isOnionNodeUrl(url: String): Boolean { return try { - URI(url).host?.endsWith(".onion", ignoreCase = true) == true + val normalized = if (url.contains("://")) url else "tcp://$url" + URI(normalized).host?.endsWith(".onion", ignoreCase = true) == true } catch (_: Exception) { false } } +fun redactedEndpointForLog(endpoint: String): String { + return "id=${shortLogId(endpoint)}, onion=${isOnionNodeUrl(endpoint)}" +} + +fun redactedProxyForLog(host: String, port: UShort): String { + return "id=${shortLogId("$host:$port")}, port=$port" +} + +fun redactedNodeForLog(node: Node): String { + return "apiType=${node.apiType}, ${redactedEndpointForLog(node.url)}" +} + +private fun shortLogId(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + return digest.take(4).joinToString("") { byte -> "%02x".format(byte) } +} + suspend fun switchToFirstClearnetPresetNode(nodeSelector: NodeSelector): Result = runCatching { withContext(Dispatchers.IO) { @@ -26,7 +46,6 @@ suspend fun switchToFirstClearnetPresetNode(nodeSelector: NodeSelector): Result< .firstOrNull { node -> !isOnionNodeUrl(node.url) } ?: throw IllegalStateException("No clearnet preset node available for fallback") - nodeSelector.checkAndSaveNode(fallbackNode) - fallbackNode + nodeSelector.selectPresetNode(fallbackNode.name) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt new file mode 100644 index 000000000..1d80e1acf --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorModeParsing.kt @@ -0,0 +1,12 @@ +package org.bitcoinppl.cove.tor + +import org.bitcoinppl.cove_core.TorMode + +fun parseCoreTorMode(mode: String?): TorMode { + return when (mode?.replace("_", "")?.lowercase()) { + "orbot" -> TorMode.ORBOT + "external" -> TorMode.EXTERNAL + "builtin" -> TorMode.BUILT_IN + else -> TorMode.BUILT_IN + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt index 328d109a3..9130c8c7f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/tor/TorStatusSupport.kt @@ -3,6 +3,7 @@ package org.bitcoinppl.cove.tor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext +import org.json.JSONObject import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket @@ -23,8 +24,13 @@ data class TorApiSnapshot( ) private val bootstrapPercentRegex = Regex("""\b(100|[0-9]{1,2})%\b""") +private val artiStatusRegex = Regex("""arti_client::status]\s*(100|[0-9]{1,2})%:\s*(.+)$""") private val missingCountRegex = Regex("""missing\s+(\d+)""") private val missingFractionRegex = Regex("""missing\s+(\d+)\s*/\s*(\d+)""") +private val torApiBooleanRegex = + Regex(""""is_?tor"\s*:\s*true""", RegexOption.IGNORE_CASE) +private val torApiIpRegex = + Regex(""""ip"\s*:\s*"([^"]+)"""", RegexOption.IGNORE_CASE) private fun isRustTorLog(line: String): Boolean { val trimmed = line.trim() @@ -76,6 +82,17 @@ fun deriveBuiltInBootstrapSnapshot(logLines: List): TorBootstrapSnapshot scopedLogs.forEach { line -> val lowered = line.lowercase() + artiStatusRegex.find(line)?.let { match -> + val found = match.groupValues[1].toInt().coerceIn(0, 100) + val message = match.groupValues[2] + percent = found + step = "$found%: $message" + if (found >= 100) { + ready = true + } + return@forEach + } + bootstrapPercentRegex.find(line)?.groupValues?.get(1)?.toIntOrNull()?.let { found -> if (found > percent) { percent = found @@ -226,7 +243,7 @@ suspend fun testSocksEndpoint( suspend fun testTorApiThroughSocks( host: String, port: Int, - timeoutMs: Int = 8000, + timeoutMs: Int = 15000, ): Result = try { val snapshot = @@ -240,9 +257,14 @@ suspend fun testTorApiThroughSocks( connection.getInputStream().bufferedReader().use { reader -> reader.readText() } - val normalized = raw.lowercase() - val isTor = "\"istor\":true" in normalized || "\"is_tor\":true" in normalized - val ip = Regex(""""IP"\s*:\s*"([^"]+)"""").find(raw)?.groupValues?.getOrNull(1) + val json = runCatching { JSONObject(raw) }.getOrNull() + val isTor = + json?.caseInsensitiveBoolean("IsTor") + ?: json?.caseInsensitiveBoolean("is_tor") + ?: torApiBooleanRegex.containsMatchIn(raw) + val ip = + json?.caseInsensitiveString("IP") + ?: torApiIpRegex.find(raw)?.groupValues?.getOrNull(1) TorApiSnapshot(isTor = isTor, ip = ip, raw = raw) } @@ -252,3 +274,13 @@ suspend fun testTorApiThroughSocks( } catch (error: Exception) { Result.failure(error) } + +private fun JSONObject.caseInsensitiveBoolean(key: String): Boolean? { + val actualKey = keys().asSequence().firstOrNull { it.equals(key, ignoreCase = true) } + return actualKey?.let { optBoolean(it) } +} + +private fun JSONObject.caseInsensitiveString(key: String): String? { + val actualKey = keys().asSequence().firstOrNull { it.equals(key, ignoreCase = true) } + return actualKey?.let { optString(it).takeIf(String::isNotEmpty) } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index a27fa60f3..13787febb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1032,6 +1032,8 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + external fun uniffi_cove_checksum_func_built_in_tor_bootstrap_status( + ): Short external fun uniffi_cove_checksum_func_clear_tor_connection_logs( ): Short external fun uniffi_cove_checksum_func_ensure_built_in_tor_bootstrap( @@ -3322,6 +3324,8 @@ internal object UniffiLib { ): Byte external fun uniffi_cove_fn_method_walletmetadata_uniffi_trait_hash(`ptr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long + external fun uniffi_cove_fn_func_built_in_tor_bootstrap_status(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_func_clear_tor_connection_logs(uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_func_ensure_built_in_tor_bootstrap( @@ -3543,6 +3547,9 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if (lib.uniffi_cove_checksum_func_built_in_tor_bootstrap_status() != 39940.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -27848,6 +27855,64 @@ public object FfiConverterTypeBackupWalletSummary: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BuiltInTorBootstrapStatus { + return BuiltInTorBootstrapStatus( + FfiConverterUInt.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterString.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterOptionalString.read(buf), + ) + } + + override fun allocationSize(value: BuiltInTorBootstrapStatus) = ( + FfiConverterUInt.allocationSize(value.`percent`) + + FfiConverterBoolean.allocationSize(value.`ready`) + + FfiConverterOptionalString.allocationSize(value.`blocked`) + + FfiConverterString.allocationSize(value.`message`) + + FfiConverterBoolean.allocationSize(value.`launched`) + + FfiConverterOptionalString.allocationSize(value.`lastError`) + ) + + override fun write(value: BuiltInTorBootstrapStatus, buf: ByteBuffer) { + FfiConverterUInt.write(value.`percent`, buf) + FfiConverterBoolean.write(value.`ready`, buf) + FfiConverterOptionalString.write(value.`blocked`, buf) + FfiConverterString.write(value.`message`, buf) + FfiConverterBoolean.write(value.`launched`, buf) + FfiConverterOptionalString.write(value.`lastError`, buf) + } +} + + + data class CloudBackupDetail ( var `lastSync`: kotlin.ULong? , @@ -38309,6 +38374,14 @@ sealed class GlobalConfigTableException: kotlin.Exception() { get() = "" } + class BuiltInTorStop( + + val v1: kotlin.String + ) : GlobalConfigTableException() { + override val message + get() = "v1=${ v1 }" + } + @@ -38344,6 +38417,9 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer GlobalConfigTableException.PinCodeMustBeHashed() + 4 -> GlobalConfigTableException.BuiltInTorStop( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -38364,6 +38440,11 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) } } @@ -38383,6 +38464,11 @@ public object FfiConverterTypeGlobalConfigTableError : FfiConverterRustBuffer { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -55250,6 +55336,16 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler + UniffiLib.uniffi_cove_fn_func_built_in_tor_bootstrap_status( + + _status) +} + ) + } + fun `clearTorConnectionLogs`() = uniffiRustCall() { _status -> diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index 1fa051dd2..7e70d0d26 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -556,7 +556,7 @@ struct CoveMainView: View { let endpointDescription = "\(endpoint.host):\(endpoint.port)" let signature = "\(mode.persistedValue)|\(endpointDescription)|\(app.selectedNode.url)" - guard startupTorCheckSignature != signature else { return } + guard startupTorCheckSignature != signature || startupTorUnavailable != nil else { return } startupTorCheckSignature = signature let result = await testSocksEndpoint(host: endpoint.host, port: endpoint.port, timeout: 2) @@ -604,7 +604,16 @@ struct CoveMainView: View { private func openOrbotFromStartupWarning() { startupTorUnavailable = nil guard let url = URL(string: "orbot://") else { return } - UIApplication.shared.open(url) + UIApplication.shared.open(url, options: [:]) { success in + if !success { + app.alertState = .init( + .general( + title: "Unable to open Orbot", + message: "Open Orbot manually, then return to Cove and retry Tor." + ) + ) + } + } } var body: some View { diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 59adde24f..07156a970 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -281,17 +281,27 @@ struct SelectedWalletScreen: View { let logs = torConnectionLogs() let snapshot = deriveBuiltInBootstrapSnapshot(logs) + let structuredStatus = builtInTorBootstrapStatus() + let hasStructuredStatus = structuredStatus.launched quick.logs = recentTorLogs(logs) - if snapshot.isReady { + if structuredStatus.ready || (!hasStructuredStatus && snapshot.isReady) { quick.torConnection = .green quick.torMessage = "Built-in Tor ready" - } else if snapshot.hasError { + } else if let lastError = structuredStatus.lastError { + quick.torConnection = .red + quick.torMessage = lastError + } else if !hasStructuredStatus, snapshot.hasError { quick.torConnection = .red quick.torMessage = snapshot.step } else { + let message = + structuredStatus.blocked.map { "Blocked: \($0)" } + ?? (leadingPercent(snapshot.step) != nil ? snapshot.step : nil) + ?? (hasStructuredStatus && !structuredStatus.message.isEmpty ? structuredStatus.message : snapshot.step) + let percent = leadingPercent(message) ?? (hasStructuredStatus ? Int(structuredStatus.percent) : snapshot.percent) quick.torConnection = .yellow - quick.torMessage = "\(snapshot.percent)% \(snapshot.step)" + quick.torMessage = "Built-in Tor bootstrapping (\(percent)%)" } } @@ -360,7 +370,43 @@ struct SelectedWalletScreen: View { } private func recentTorLogs(_ logs: [String]) -> [String] { - Array(logs.dropFirst(max(logs.count - 6, 0))) + let usefulMarkers = [ + "arti_client::status", + "tor_dirmgr", + "tor_guardmgr", + "tor_runtime", + "bootstrapped", + "bootstrap", + "directory", + "consensus", + "microdescriptors", + "failed", + "error", + "warn", + ] + let usefulLogs = logs + .filter { line in + usefulMarkers.contains { marker in + line.range(of: marker, options: .caseInsensitive) != nil + } + } + .map { line in + line.replacingOccurrences( + of: #"^\[(INFO|WARN|ERROR|DEBUG) [^\]]+]\s*"#, + with: "", + options: .regularExpression + ) + } + .filter { !$0.isEmpty } + + return Array(NSOrderedSet(array: usefulLogs).array.compactMap { $0 as? String }.suffix(6)) + } + + private func leadingPercent(_ message: String) -> Int? { + guard let match = message.range(of: #"^\d{1,3}(?=%:)"#, options: .regularExpression) else { + return nil + } + return Int(message[match]).map { min(max($0, 0), 100) } } var titleContent: some View { diff --git a/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift index f6625e1a4..c48a48e20 100644 --- a/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift +++ b/ios/Cove/Flows/SettingsFlow/NetworkSettingsView.swift @@ -239,10 +239,6 @@ struct NetworkSettingsView: View { Text("Status: \(uiState.currentStep)") .font(.subheadline) - Text("Log: \(uiState.latestLogLine)") - .font(.caption) - .foregroundStyle(.secondary) - Button("Test connection") { Task { await runProgressiveTorTest() } } @@ -445,7 +441,14 @@ struct NetworkSettingsView: View { return } - try? globalConfig.setUseTor(useTor: true) + do { + try globalConfig.setUseTor(useTor: true) + } catch { + noticeMessage = "Could not enable Tor: \(error.localizedDescription)" + appendTorLog("Failed to enable Tor: \(error.localizedDescription)") + return + } + uiState.enabled = true uiState.status = .bootstrapping uiState.currentStep = "Preparing Tor" @@ -457,7 +460,14 @@ struct NetworkSettingsView: View { } private func setTorMode(_ mode: TorMode) { - try? globalConfig.set(key: .torMode, value: mode.persistedValue) + do { + try globalConfig.set(key: .torMode, value: mode.persistedValue) + } catch { + noticeMessage = "Could not save Tor mode: \(error.localizedDescription)" + appendTorLog("Failed to save Tor mode: \(error.localizedDescription)") + return + } + app.pendingNodeTorValidated = false autoPendingTestKey = nil uiState.mode = mode @@ -481,17 +491,32 @@ struct NetworkSettingsView: View { return } - try? globalConfig.set(key: .torExternalHost, value: uiState.externalHost) - if let port = UInt16(uiState.externalPort) { - try? globalConfig.setTorExternalPort(port: port) + do { + try globalConfig.set(key: .torExternalHost, value: uiState.externalHost) + if let port = UInt16(uiState.externalPort) { + try globalConfig.setTorExternalPort(port: port) + } + } catch { + noticeMessage = "Could not save Tor proxy configuration: \(error.localizedDescription)" + appendTorLog("Failed to save external Tor config: \(error.localizedDescription)") + return } + app.pendingNodeTorValidated = false - appendTorLog("Saved external Tor config: \(uiState.externalHost):\(uiState.externalPort)") + let proxyLog = redactedProxyForLog(host: uiState.externalHost, port: Int(uiState.externalPort) ?? 0) + appendTorLog("Saved external Tor config: \(proxyLog)") noticeMessage = "Tor proxy configuration saved." } private func disableTorState() { - try? globalConfig.setUseTor(useTor: false) + do { + try globalConfig.setUseTor(useTor: false) + } catch { + noticeMessage = "Could not disable Tor: \(error.localizedDescription)" + appendTorLog("Failed to disable Tor: \(error.localizedDescription)") + return + } + uiState.enabled = false uiState.status = .disabled uiState.progressPercent = 0 @@ -504,7 +529,7 @@ struct NetworkSettingsView: View { if isOnionNodeUrl(app.selectedNode.url) { do { let fallback = try await switchToFirstClearnetPresetNode(nodeSelector) - appendTorLog("Switched active node to \(fallback.url) before disabling Tor") + appendTorLog("Switched active node to \(redactedNodeForLog(fallback)) before disabling Tor") noticeMessage = "Switched to \(fallback.name) and disabled Tor." } catch { appendTorLog("Unable to disable Tor: clearnet fallback failed (\(error.localizedDescription))") @@ -539,6 +564,13 @@ struct NetworkSettingsView: View { torTestState.logs = Array((torTestState.logs + [message]).suffix(150)) } + private func leadingPercent(_ message: String) -> Int? { + guard let match = message.range(of: #"^\d{1,3}(?=%:)"#, options: .regularExpression) else { + return nil + } + return Int(message[match]).map { min(max($0, 0), 100) } + } + private func syncRustTorLogs() { guard uiState.enabled, uiState.mode == .builtIn else { return } @@ -554,10 +586,32 @@ struct NetworkSettingsView: View { } let snapshot = deriveBuiltInBootstrapSnapshot(rustLogs) - uiState.progressPercent = snapshot.percent - uiState.currentStep = snapshot.step + let structuredStatus = builtInTorBootstrapStatus() + let hasStructuredStatus = structuredStatus.launched + let nextStatus: TorStatus = if structuredStatus.ready { + .ready + } else if structuredStatus.lastError != nil { + .error + } else if hasStructuredStatus { + .bootstrapping + } else if snapshot.isReady { + .ready + } else if snapshot.hasError { + .error + } else { + .bootstrapping + } + let currentStep = + structuredStatus.lastError + ?? structuredStatus.blocked.map { "Blocked: \($0)" } + ?? (leadingPercent(snapshot.step) != nil ? snapshot.step : nil) + ?? (hasStructuredStatus && !structuredStatus.message.isEmpty ? structuredStatus.message : snapshot.step) + let messagePercent = leadingPercent(currentStep) + let snapshotPercent = messagePercent ?? (hasStructuredStatus ? Int(structuredStatus.percent) : snapshot.percent) + uiState.progressPercent = nextStatus == .ready ? 100 : snapshotPercent + uiState.currentStep = currentStep uiState.latestLogLine = snapshot.lastLine - uiState.status = snapshot.isReady ? .ready : snapshot.hasError ? .error : .bootstrapping + uiState.status = nextStatus } private func ensureBuiltInWarmup() async { @@ -611,7 +665,8 @@ struct NetworkSettingsView: View { uiState.status = validationError == nil ? .bootstrapping : .error uiState.progressPercent = validationError == nil ? 50 : 0 uiState.currentStep = validationError == nil ? "Save and test custom proxy" : "External proxy config invalid" - uiState.latestLogLine = validationError ?? "\(uiState.externalHost):\(uiState.externalPort)" + uiState.latestLogLine = validationError + ?? redactedProxyForLog(host: uiState.externalHost, port: Int(uiState.externalPort) ?? 0) case .orbot: uiState.status = uiState.orbotStatus == .detected ? .bootstrapping : .error uiState.progressPercent = uiState.orbotStatus == .detected ? 50 : 0 @@ -624,13 +679,14 @@ struct NetworkSettingsView: View { private func testTorProxy(host: String, port: Int) async -> Result { syncRustTorLogs() - appendTorLog("Testing SOCKS endpoint \(host):\(port)") + let proxyLog = redactedProxyForLog(host: host, port: port) + appendTorLog("Testing SOCKS endpoint \(proxyLog)") let result = await testSocksEndpoint(host: host, port: port) switch result { case .success: - appendTorLog("SOCKS endpoint reachable: \(host):\(port)") + appendTorLog("SOCKS endpoint reachable: \(proxyLog)") case let .failure(error): - appendTorLog("SOCKS endpoint failed: \(host):\(port) (\(error.localizedDescription))") + appendTorLog("SOCKS endpoint failed: \(proxyLog) (\(error.localizedDescription))") } syncRustTorLogs() return result @@ -644,24 +700,25 @@ struct NetworkSettingsView: View { name: typeName, enteredName: app.pendingNodeName ) - appendTorLog("Using pending node for Tor test: \(app.pendingNodeUrl)") + appendTorLog("Using pending node for Tor test: \(redactedNodeForLog(node))") return node } - appendTorLog("Using selected node for Tor test: \(app.selectedNode.url)") + appendTorLog("Using selected node for Tor test: \(redactedNodeForLog(app.selectedNode))") return app.selectedNode } private func runNodeTorTest(_ node: Node) async -> Result { syncRustTorLogs() - appendTorLog("Checking node via Tor: \(node.url)") + let nodeLog = redactedNodeForLog(node) + appendTorLog("Checking node via Tor: \(nodeLog)") do { try await nodeSelector.checkSelectedNode(node: node) - appendTorLog("Node check passed: \(node.url)") + appendTorLog("Node check passed: \(nodeLog)") syncRustTorLogs() return .success(()) } catch { - appendTorLog("Node check failed: \(node.url) (\(error.localizedDescription))") + appendTorLog("Node check failed: \(nodeLog) (\(error.localizedDescription))") syncRustTorLogs() return .failure(error) } @@ -681,7 +738,7 @@ struct NetworkSettingsView: View { private func runTorApiTestWithRetries(host: String, port: Int) async -> Result { let maxAttempts = 5 - var timeout: TimeInterval = 8 + var timeout: TimeInterval = 15 var lastError: Error? for attempt in 1 ... maxAttempts { @@ -709,6 +766,18 @@ struct NetworkSettingsView: View { return .failure(lastError ?? TorSupportError.timeout) } + private func persistTorTestConfiguration(host: String, port: Int) throws { + try globalConfig.set(key: .torMode, value: uiState.mode.persistedValue) + if uiState.mode == .external { + guard let port = UInt16(exactly: port) else { + throw TorSupportError.invalidEndpoint + } + try globalConfig.set(key: .torExternalHost, value: host) + try globalConfig.setTorExternalPort(port: port) + } + try globalConfig.setUseTor(useTor: true) + } + private func runProgressiveTorTest() async { let endpoint: (String, Int) do { @@ -735,25 +804,30 @@ struct NetworkSettingsView: View { } let (host, port) = endpoint + let proxyLog = redactedProxyForLog(host: host, port: port) + + do { + try persistTorTestConfiguration(host: host, port: port) + } catch { + app.pendingNodeTorValidated = false + uiState.status = .error + appendTorLog("Failed to save Tor settings before test: \(error.localizedDescription)") + noticeMessage = "Could not save Tor settings: \(error.localizedDescription)" + return + } + showTorTestSheet = true torTestState = TorConnectionTestState( running: true, finished: false, steps: [ - TorTestStep(key: "proxy", title: "Tor proxy reachable", detail: "Checking SOCKS endpoint \(host):\(port)"), + TorTestStep(key: "proxy", title: "Tor proxy reachable", detail: "Checking SOCKS endpoint \(proxyLog)"), TorTestStep(key: "api", title: "Tor API reports Tor exit", detail: "Checking torproject API over SOCKS"), TorTestStep(key: "node", title: "Node reachable via Tor", detail: "Checking selected node over Tor"), ], logs: ["Starting Tor connection test (\(uiState.mode.shortTitle))"] ) - try? globalConfig.setUseTor(useTor: true) - try? globalConfig.set(key: .torMode, value: uiState.mode.persistedValue) - if uiState.mode == .external { - try? globalConfig.set(key: .torExternalHost, value: host) - try? globalConfig.setTorExternalPort(port: UInt16(port)) - } - updateTorTestStep("proxy", status: .running) let proxyResult = await testTorProxy(host: host, port: port) if let error = proxyResult.failureValue { diff --git a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift index 3aab63e44..e13e1ff22 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsScreen/NodeSelectionView.swift @@ -167,7 +167,7 @@ struct NodeSelectionView: View { guard db.globalConfig().useTor(), app.pendingNodeTorValidated else { return } - Task { + let task = Task { showLoadingPopup() do { let node = try nodeSelector.parseCustomNode( @@ -187,6 +187,7 @@ struct NodeSelectionView: View { completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) } } + checkUrlTask = task } private func parseCustomNodeOrShowError() -> Node? { @@ -217,7 +218,7 @@ struct NodeSelectionView: View { if isOnionNodeUrl(node.url) { if db.globalConfig().useTor() { - Task { + let task = Task { showLoadingPopup() do { try await nodeSelector.checkAndSaveNode(node: node) @@ -229,11 +230,19 @@ struct NodeSelectionView: View { completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) } } + checkUrlTask = task + return + } + + do { + try db.globalFlag().set(key: .torSettingsDiscovered, value: true) + try db.globalConfig().setUseTor(useTor: true) + } catch { + showParseUrlAlert = true + parseUrlMessage = "Failed to enable Tor for this onion node: \(error.localizedDescription)" return } - try? db.globalFlag().set(key: .torSettingsDiscovered, value: true) - try? db.globalConfig().setUseTor(useTor: true) setCustomSelection(for: node) app.pendingNodeUrl = node.url app.pendingNodeName = node.name @@ -244,7 +253,7 @@ struct NodeSelectionView: View { return } - Task { + let task = Task { showLoadingPopup() do { try await nodeSelector.checkAndSaveNode(node: node) @@ -254,6 +263,7 @@ struct NodeSelectionView: View { completeLoading(.failure("Failed to connect to node\n\(error.localizedDescription)")) } } + checkUrlTask = task } private func selectPresetNode(_ nodeSelection: NodeSelection) { @@ -278,7 +288,7 @@ struct NodeSelectionView: View { try await nodeSelector.checkSelectedNode(node: selected) selectedNodeSelection = .preset(selected) selectedNodeName = selected.name - completeLoading(.success("Succesfully connected to \(selected.url)")) + completeLoading(.success("Successfully connected to \(selected.url)")) } catch { completeLoading(.failure("Failed to connect to \(node.url), reason: \(error.localizedDescription)")) } @@ -306,6 +316,9 @@ struct NodeSelectionView: View { if case let .custom(node) = selectedNodeSelection, node.apiType == .electrum { customUrl = node.url customNodeName = node.name + } else { + customUrl = "" + customNodeName = "" } } ) @@ -319,6 +332,9 @@ struct NodeSelectionView: View { if case let .custom(node) = selectedNodeSelection, node.apiType == .esplora { customUrl = node.url customNodeName = node.name + } else { + customUrl = "" + customNodeName = "" } } ) diff --git a/ios/Cove/Info.plist b/ios/Cove/Info.plist index fe08e0b0c..4e7ef14df 100644 --- a/ios/Cove/Info.plist +++ b/ios/Cove/Info.plist @@ -2,6 +2,10 @@ + LSApplicationQueriesSchemes + + orbot + UILaunchScreen UIColorName diff --git a/ios/Cove/TorSupport.swift b/ios/Cove/TorSupport.swift index 4a0955806..b1e74132d 100644 --- a/ios/Cove/TorSupport.swift +++ b/ios/Cove/TorSupport.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Network import SwiftUI @@ -137,6 +138,23 @@ func torTimestamp() -> String { return formatter.string(from: Date()) } +func redactedEndpointForLog(_ endpoint: String) -> String { + "id=\(shortLogId(endpoint)), onion=\(isOnionNodeUrl(endpoint))" +} + +func redactedProxyForLog(host: String, port: Int) -> String { + "id=\(shortLogId("\(host):\(port)")), port=\(port)" +} + +func redactedNodeForLog(_ node: Node) -> String { + "apiType=\(node.apiType), \(redactedEndpointForLog(node.url))" +} + +private func shortLogId(_ value: String) -> String { + let digest = SHA256.hash(data: Data(value.utf8)) + return digest.prefix(4).map { String(format: "%02x", $0) }.joined() +} + func validateTorExternalConfig(host: String, port: String) -> String? { if host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Host is required" @@ -221,6 +239,22 @@ private func firstRegexInts(_ pattern: String, in string: String) -> [Int]? { } } +private func artiStatus(_ line: String) -> (percent: Int, message: String)? { + let pattern = #"arti_client::status]\s*(100|[0-9]{1,2})%:\s*(.+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, range: range), + match.numberOfRanges == 3, + let percentRange = Range(match.range(at: 1), in: line), + let messageRange = Range(match.range(at: 2), in: line), + let percent = Int(line[percentRange]) + else { + return nil + } + + return (min(max(percent, 0), 100), String(line[messageRange])) +} + func deriveBuiltInBootstrapSnapshot(_ logLines: [String]) -> TorBootstrapSnapshot { guard !logLines.isEmpty else { return TorBootstrapSnapshot( @@ -262,6 +296,15 @@ func deriveBuiltInBootstrapSnapshot(_ logLines: [String]) -> TorBootstrapSnapsho for line in scopedLogs { let lowered = line.lowercased() + if let status = artiStatus(line) { + percent = status.percent + step = "\(status.percent)%: \(status.message)" + if status.percent >= 100 { + ready = true + } + continue + } + if let found = firstRegexInt(#"\b(100|[0-9]{1,2})%\b"#, in: line), found > percent { percent = found } @@ -404,7 +447,7 @@ func testSocksEndpoint(host: String, port: Int, timeout: TimeInterval = 3) async } } -func testTorApiThroughSocks(host: String, port: Int, timeout: TimeInterval = 8) async -> Result { +func testTorApiThroughSocks(host: String, port: Int, timeout: TimeInterval = 15) async -> Result { guard (1 ... 65535).contains(port) else { return .failure(TorSupportError.invalidEndpoint) } @@ -414,14 +457,7 @@ func testTorApiThroughSocks(host: String, port: Int, timeout: TimeInterval = 8) return httpsResult } - return await withCheckedContinuation { continuation in - SocksTorApiProbe( - host: host, - port: port, - timeout: timeout, - continuation: continuation - ).start() - } + return httpsResult } private func testTorApiThroughUrlSessionSocks( @@ -473,206 +509,3 @@ private func parseTorApiJson(_ data: Data) -> Result { return .success(TorApiSnapshot(isTor: isTor, ip: ip, raw: raw)) } - -private final class SocksTorApiProbe: @unchecked Sendable { - private let host: String - private let port: Int - private let timeout: TimeInterval - private let continuation: CheckedContinuation, Never> - private let queue = DispatchQueue(label: "org.bitcoinppl.cove.tor.api-test") - private var connection: NWConnection? - private var finished = false - - init( - host: String, - port: Int, - timeout: TimeInterval, - continuation: CheckedContinuation, Never> - ) { - self.host = host - self.port = port - self.timeout = timeout - self.continuation = continuation - } - - func start() { - guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { - finish(.failure(TorSupportError.invalidEndpoint)) - return - } - - let connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) - self.connection = connection - connection.stateUpdateHandler = { [self] state in - switch state { - case .ready: - sendSocksGreeting() - case let .failed(error): - finish(.failure(error)) - case let .waiting(error): - if case .posix(.ECONNREFUSED) = error { - finish(.failure(error)) - } - default: - break - } - } - - queue.asyncAfter(deadline: .now() + timeout) { [self] in - finish(.failure(TorSupportError.timeout)) - } - connection.start(queue: queue) - } - - private func finish(_ result: Result) { - guard !finished else { return } - finished = true - connection?.cancel() - continuation.resume(returning: result) - } - - private func send(_ data: Data, then next: @escaping () -> Void) { - connection?.send(content: data, completion: .contentProcessed { [self] error in - if let error { - finish(.failure(error)) - return - } - next() - }) - } - - private func receive( - minimumLength: Int, - maximumLength: Int, - then next: @escaping (Data) -> Void - ) { - connection?.receive( - minimumIncompleteLength: minimumLength, - maximumLength: maximumLength - ) { [self] data, _, _, error in - if let error { - finish(.failure(error)) - return - } - - guard let data, data.count >= minimumLength else { - finish(.failure(TorSupportError.invalidResponse)) - return - } - - next(data) - } - } - - private func sendSocksGreeting() { - send(Data([0x05, 0x01, 0x00])) { [self] in - receive(minimumLength: 2, maximumLength: 2) { [self] data in - let bytes = [UInt8](data) - guard bytes.count == 2, bytes[0] == 0x05, bytes[1] == 0x00 else { - finish(.failure(TorSupportError.invalidResponse)) - return - } - sendSocksConnectRequest() - } - } - } - - private func sendSocksConnectRequest() { - let domain = "check.torproject.org" - guard let domainData = domain.data(using: .utf8), domainData.count < 256 else { - finish(.failure(TorSupportError.invalidEndpoint)) - return - } - - var request = Data([0x05, 0x01, 0x00, 0x03, UInt8(domainData.count)]) - request.append(domainData) - request.append(contentsOf: [0x00, 0x50]) - - send(request) { [self] in - receiveSocksConnectResponseHeader() - } - } - - private func receiveSocksConnectResponseHeader() { - receive(minimumLength: 4, maximumLength: 4) { [self] data in - let bytes = [UInt8](data) - guard bytes.count == 4, bytes[0] == 0x05, bytes[1] == 0x00 else { - finish(.failure(TorSupportError.invalidResponse)) - return - } - - switch bytes[3] { - case 0x01: - receiveAndDiscardSocksAddress(length: 6) - case 0x03: - receiveSocksDomainLength() - case 0x04: - receiveAndDiscardSocksAddress(length: 18) - default: - finish(.failure(TorSupportError.invalidResponse)) - } - } - } - - private func receiveSocksDomainLength() { - receive(minimumLength: 1, maximumLength: 1) { [self] data in - guard let length = data.first else { - finish(.failure(TorSupportError.invalidResponse)) - return - } - receiveAndDiscardSocksAddress(length: Int(length) + 2) - } - } - - private func receiveAndDiscardSocksAddress(length: Int) { - receive(minimumLength: length, maximumLength: length) { [self] _ in - sendTorApiHttpRequest() - } - } - - private func sendTorApiHttpRequest() { - let request = [ - "GET /api/ip HTTP/1.1", - "Host: check.torproject.org", - "Accept: application/json", - "Accept-Encoding: identity", - "Connection: close", - "", - "", - ].joined(separator: "\r\n") - send(Data(request.utf8)) { [self] in - receiveHttpResponse(Data()) - } - } - - private func receiveHttpResponse(_ accumulated: Data) { - connection?.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [self] data, _, isComplete, error in - if let error { - finish(.failure(error)) - return - } - - var nextData = accumulated - if let data { - nextData.append(data) - } - - if isComplete || nextData.count > 128 * 1024 { - finish(parseTorApiHttpResponse(nextData)) - return - } - - receiveHttpResponse(nextData) - } - } - - private func parseTorApiHttpResponse(_ data: Data) -> Result { - let raw = String(data: data, encoding: .utf8) ?? "" - guard raw.hasPrefix("HTTP/1.1 200") || raw.hasPrefix("HTTP/1.0 200") else { - return .failure(TorSupportError.invalidResponse) - } - - let body = raw.components(separatedBy: "\r\n\r\n").dropFirst().joined(separator: "\r\n\r\n") - return parseTorApiJson(Data((body.isEmpty ? raw : body).utf8)) - } -} diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 9a9b6d979..35552925c 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -13064,6 +13064,76 @@ public func FfiConverterTypeBackupWalletSummary_lower(_ value: BackupWalletSumma } +public struct BuiltInTorBootstrapStatus: Equatable, Hashable { + public var percent: UInt32 + public var ready: Bool + public var blocked: String? + public var message: String + public var launched: Bool + public var lastError: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(percent: UInt32, ready: Bool, blocked: String?, message: String, launched: Bool, lastError: String?) { + self.percent = percent + self.ready = ready + self.blocked = blocked + self.message = message + self.launched = launched + self.lastError = lastError + } + + + + +} + +#if compiler(>=6) +extension BuiltInTorBootstrapStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBuiltInTorBootstrapStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BuiltInTorBootstrapStatus { + return + try BuiltInTorBootstrapStatus( + percent: FfiConverterUInt32.read(from: &buf), + ready: FfiConverterBool.read(from: &buf), + blocked: FfiConverterOptionString.read(from: &buf), + message: FfiConverterString.read(from: &buf), + launched: FfiConverterBool.read(from: &buf), + lastError: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: BuiltInTorBootstrapStatus, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.percent, into: &buf) + FfiConverterBool.write(value.ready, into: &buf) + FfiConverterOptionString.write(value.blocked, into: &buf) + FfiConverterString.write(value.message, into: &buf) + FfiConverterBool.write(value.launched, into: &buf) + FfiConverterOptionString.write(value.lastError, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBuiltInTorBootstrapStatus_lift(_ buf: RustBuffer) throws -> BuiltInTorBootstrapStatus { + return try FfiConverterTypeBuiltInTorBootstrapStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBuiltInTorBootstrapStatus_lower(_ value: BuiltInTorBootstrapStatus) -> RustBuffer { + return FfiConverterTypeBuiltInTorBootstrapStatus.lower(value) +} + + public struct CloudBackupDetail: Equatable, Hashable { public var lastSync: UInt64? public var upToDate: [CloudBackupWalletItem] @@ -21837,6 +21907,8 @@ enum GlobalConfigTableError: Swift.Error, Equatable, Hashable, Foundation.Locali case Read(String ) case PinCodeMustBeHashed + case BuiltInTorStop(String + ) @@ -21883,6 +21955,9 @@ public struct FfiConverterTypeGlobalConfigTableError: FfiConverterRustBuffer { try FfiConverterString.read(from: &buf) ) case 3: return .PinCodeMustBeHashed + case 4: return .BuiltInTorStop( + try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -21908,6 +21983,11 @@ public struct FfiConverterTypeGlobalConfigTableError: FfiConverterRustBuffer { case .PinCodeMustBeHashed: writeInt(&buf, Int32(3)) + + case let .BuiltInTorStop(v1): + writeInt(&buf, Int32(4)) + FfiConverterString.write(v1, into: &buf) + } } } @@ -36052,6 +36132,12 @@ private func uniffiForeignFutureDroppedCallback(handle: UInt64) { public func uniffiForeignFutureHandleCountCove() -> Int { UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.count } +public func builtInTorBootstrapStatus() -> BuiltInTorBootstrapStatus { + return try! FfiConverterTypeBuiltInTorBootstrapStatus_lift(try! rustCall() { + uniffi_cove_fn_func_built_in_tor_bootstrap_status($0 + ) +}) +} public func clearTorConnectionLogs() {try! rustCall() { uniffi_cove_fn_func_clear_tor_connection_logs($0 ) @@ -36492,6 +36578,9 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_cove_checksum_func_built_in_tor_bootstrap_status() != 39940) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_func_clear_tor_connection_logs() != 25876) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/crates/cove-common/src/logging.rs b/rust/crates/cove-common/src/logging.rs index 606328b62..6d4d5e8bb 100644 --- a/rust/crates/cove-common/src/logging.rs +++ b/rust/crates/cove-common/src/logging.rs @@ -4,9 +4,8 @@ use std::{ }; use once_cell::sync::Lazy; -use tracing::{Event, Subscriber, field::Visit}; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::{Layer, layer::Context, registry::LookupSpan}; +use tracing::{Event, Level, Subscriber, field::Visit}; +use tracing_subscriber::{EnvFilter, Layer, layer::Context, registry::LookupSpan}; static INIT: Once = Once::new(); static TOR_LOG_BUFFER: Lazy>> = Lazy::new(|| Mutex::new(VecDeque::new())); @@ -37,6 +36,9 @@ where { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let metadata = event.metadata(); + if !should_capture_tor_event(metadata.level(), metadata.target()) { + return; + } let mut visitor = TorEventVisitor::default(); event.record(&mut visitor); @@ -57,17 +59,28 @@ where } } +fn should_capture_tor_event(level: &Level, target: &str) -> bool { + match *level { + Level::ERROR | Level::WARN | Level::INFO => true, + Level::DEBUG => target == "arti_client::status" || target.contains("tor_runtime"), + Level::TRACE => false, + } +} + fn is_tor_related(target: &str, line: &str) -> bool { if target.starts_with("arti") || target.starts_with("tor_") || target.contains("tor_runtime") { return true; } let lowercase = line.to_ascii_lowercase(); - lowercase.contains(" tor ") - || lowercase.contains("tor:") - || lowercase.contains("tor-") + let has_tor_token = lowercase + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .any(|token| token == "tor" || token == "socks5" || token == "socks5h"); + + has_tor_token || lowercase.contains("onion") - || lowercase.contains("socks") + || lowercase.contains("tor:") + || lowercase.contains("tor=") } fn push_tor_log(line: String) { @@ -89,23 +102,37 @@ pub fn clear_tor_connection_logs() { } pub fn init() { + init_with_default_filter(default_log_filter()); +} + +pub fn init_with_default_filter(default_filter: &str) { INIT.call_once(|| { use tracing_subscriber::{fmt, prelude::*}; - if std::env::var("RUST_LOG").is_err() { - unsafe { std::env::set_var("RUST_LOG", "cove=debug,info") } - } - #[cfg(target_os = "android")] let fmt_layer = fmt::layer().with_writer(std::io::stderr).with_ansi(false); #[cfg(not(target_os = "android"))] let fmt_layer = fmt::layer().with_writer(std::io::stdout).with_ansi(false); + let fmt_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)); + tracing_subscriber::registry() - .with(fmt_layer) - .with(EnvFilter::from_default_env()) + .with(fmt_layer.with_filter(fmt_filter)) .with(TorLogLayer) .init(); }); } + +fn default_log_filter() -> &'static str { + #[cfg(debug_assertions)] + { + "cove=debug,info" + } + + #[cfg(not(debug_assertions))] + { + "cove=info,info" + } +} diff --git a/rust/src/database/global_config.rs b/rust/src/database/global_config.rs index d634cc17a..7b6bb65b0 100644 --- a/rust/src/database/global_config.rs +++ b/rust/src/database/global_config.rs @@ -96,6 +96,9 @@ pub enum GlobalConfigTableError { #[error("pin code must be hashed before saving")] PinCodeMustBeHashed, + + #[error("failed to stop built-in tor proxy before publishing config update: {0}")] + BuiltInTorStop(String), } impl GlobalConfigTable { @@ -370,8 +373,11 @@ impl GlobalConfigTable { write_txn.commit().map_err(|error| Error::DatabaseAccess(error.to_string()))?; - if should_stop_built_in_tor { - crate::tor_runtime::request_stop_built_in_proxy(); + if should_stop_built_in_tor && !crate::tor_runtime::request_stop_built_in_proxy() { + return Err(GlobalConfigTableError::BuiltInTorStop( + "timed out waiting for proxy shutdown".to_string(), + ) + .into()); } Updater::send_update(Update::DatabaseUpdated); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index e5754025e..6ecfcced1 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -106,6 +106,16 @@ pub enum TorBootstrapError { BuiltInTor(String), } +#[derive(Debug, Clone, uniffi::Record)] +pub struct BuiltInTorBootstrapStatus { + pub percent: u32, + pub ready: bool, + pub blocked: Option, + pub message: String, + pub launched: bool, + pub last_error: Option, +} + /// set root data directory before any database access /// required for Android to specify app-specific storage path #[uniffi::export] @@ -126,6 +136,11 @@ fn tor_connection_logs() -> Vec { logging::tor_connection_logs() } +#[uniffi::export] +fn built_in_tor_bootstrap_status() -> BuiltInTorBootstrapStatus { + tor_runtime::built_in_bootstrap_status() +} + #[uniffi::export] fn clear_tor_connection_logs() { logging::clear_tor_connection_logs(); diff --git a/rust/src/node/client.rs b/rust/src/node/client.rs index a48f6d2f0..d3a252ad9 100644 --- a/rust/src/node/client.rs +++ b/rust/src/node/client.rs @@ -18,6 +18,7 @@ use bdk_wallet::{ }, }; use bitcoin::{Transaction, Txid}; +use cove_util::ResultExt as _; use tracing::{debug, info, warn}; use crate::{ @@ -143,17 +144,12 @@ impl NodeClientOptions { } TorMode::BuiltIn => { info!("tor mode is built-in; requesting Arti socks endpoint"); - match tor_runtime::built_in_socks_endpoint().await { - Ok(endpoint) => { - self.tor_external_host = endpoint.ip().to_string(); - self.tor_external_port = endpoint.port(); - info!(%endpoint, "resolved built-in tor socks endpoint"); - } - Err(error) => { - tracing::error!("failed to initialize built-in tor runtime: {error}"); - return Err(Error::ResolveTorEndpoint(error.to_string())); - } - } + let endpoint = tor_runtime::built_in_socks_endpoint() + .await + .map_err_str(Error::ResolveTorEndpoint)?; + self.tor_external_host = endpoint.ip().to_string(); + self.tor_external_port = endpoint.port(); + info!(%endpoint, "resolved built-in tor socks endpoint"); } } @@ -186,7 +182,6 @@ impl NodeClient { info!(node = %node.url, api_type = ?node.api_type, options = ?options, "creating node client with db-backed options"); - let options = options.resolve_tor_endpoint().await?; Self::new_with_options(node, options).await } diff --git a/rust/src/node_connect.rs b/rust/src/node_connect.rs index c4f9341c9..277a36013 100644 --- a/rust/src/node_connect.rs +++ b/rust/src/node_connect.rs @@ -1,6 +1,8 @@ use tracing::{error, info, warn}; use url::Url; +use cove_util::ResultExt as _; + use crate::{ database::{Database, global_flag::GlobalFlagKey}, network::Network, @@ -170,8 +172,7 @@ impl NodeSelector { None => hinted_api_type, }; - let url = parse_node_url(&url, api_type) - .map_err(|error| Error::ParseNodeUrlError(error.to_string()))?; + let url = parse_node_url(&url, api_type).map_err_str(Error::ParseNodeUrlError)?; if !url.domain().unwrap_or_default().contains('.') { return Err(Error::ParseNodeUrlError("invalid url, no domain".to_string())); @@ -204,11 +205,6 @@ impl NodeSelector { let database = Database::global(); - database - .global_config - .set_selected_node(&node) - .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; - if node_implies_tor(&node) { database .global_flag @@ -220,6 +216,11 @@ impl NodeSelector { .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; } + database + .global_config + .set_selected_node(&node) + .map_err(|error| Error::SetSelectedNodeError(error.to_string()))?; + Ok(()) } } @@ -289,13 +290,21 @@ async fn check_node_with_tor_inference(node: &Node) -> Result<(), crate::node::E let inferred_tor = node_implies_tor(node); info!(node = %node.url, api_type = ?node.api_type, inferred_tor, "checking node with tor inference"); + let db = Database::global(); + let config = db.global_config(); + if !inferred_tor { - info!(node = %node.url, "node does not imply tor; checking directly"); + if config.use_tor() { + info!(node = %node.url, "node does not imply tor, but global tor is enabled; checking through configured tor client"); + let client = NodeClient::new(node).await?; + client.check_url().await?; + return Ok(()); + } + + info!(node = %node.url, "node does not imply tor and global tor is disabled; checking directly"); return node.check_url().await; } - let db = Database::global(); - let config = db.global_config(); let tor_external_host = config .tor_external_host() .ok() @@ -317,9 +326,6 @@ async fn check_node_with_tor_inference(node: &Node) -> Result<(), crate::node::E info!(node = %node.url, options = ?options, "node implies tor; building tor-enabled node client"); - let options = options.resolve_tor_endpoint().await?; - info!(resolved_options = ?options, "resolved tor endpoint for inferred tor node check"); - let client = NodeClient::new_with_options(node, options).await?; info!(node = %node.url, "running node check through tor-capable client"); client.check_url().await?; diff --git a/rust/src/tor_runtime.rs b/rust/src/tor_runtime.rs index 0e443e46d..456704824 100644 --- a/rust/src/tor_runtime.rs +++ b/rust/src/tor_runtime.rs @@ -3,23 +3,25 @@ use std::{ path::PathBuf, }; +use futures::{FutureExt as _, StreamExt as _}; use tokio::{ net::TcpStream, sync::watch, time::{Duration, sleep}, }; -use arti::cfg::ArtiConfig; use arti::proxy::ListenProtocols; -use arti::run_proxy; -use arti_client::{TorClientConfig, config::TorClientConfigBuilder}; +use arti_client::{ + BootstrapBehavior, TorClient, TorClientConfig, config::TorClientConfigBuilder, + status::BootstrapStatus, +}; use once_cell::sync::Lazy; -use parking_lot::Mutex; -use tor_config::{ConfigurationSources, Listen, load::Buildable}; -use tor_config_path::CfgPath; -use tor_rtcompat::{PreferredRuntime, ToplevelBlockOn}; +use parking_lot::{Condvar, Mutex}; +use tor_config::Listen; +use tor_rtcompat::{NetStreamListener, NetStreamProvider, PreferredRuntime, ToplevelBlockOn}; use tracing::{debug, error, info, warn}; +use crate::BuiltInTorBootstrapStatus; use cove_common::consts::ROOT_DATA_DIR; const BUILT_IN_TOR_SOCKS_PORT: u16 = 39050; @@ -36,13 +38,13 @@ struct BuiltInTorState { launched: bool, last_error: Option, shutdown_tx: Option>, + bootstrap_status: BuiltInTorBootstrapStatus, } #[derive(Debug, Clone)] struct BuiltInTorPaths { state_dir: PathBuf, cache_dir: PathBuf, - port_info_file: PathBuf, } static BUILT_IN_TOR_STATE: Lazy> = Lazy::new(|| { @@ -51,20 +53,25 @@ static BUILT_IN_TOR_STATE: Lazy> = Lazy::new(|| { launched: false, last_error: None, shutdown_tx: None, + bootstrap_status: default_bootstrap_status(false, None), }) }); +static BUILT_IN_TOR_STOPPED: Lazy = Lazy::new(Condvar::new); fn clear_built_in_state(reason: &str) { let mut state = BUILT_IN_TOR_STATE.lock(); state.endpoint = None; state.launched = false; state.shutdown_tx = None; + state.bootstrap_status = default_bootstrap_status(false, state.last_error.clone()); warn!(%reason, "cleared built-in tor state"); + BUILT_IN_TOR_STOPPED.notify_all(); } fn set_built_in_error(error: String) { let mut state = BUILT_IN_TOR_STATE.lock(); state.last_error = Some(error.clone()); + state.bootstrap_status = default_bootstrap_status(state.launched, Some(error.clone())); warn!(%error, "recorded built-in tor runtime error"); } @@ -82,6 +89,46 @@ pub(crate) fn built_in_status_summary() -> String { format!("endpoint={endpoint}, launched={}, last_error={last_error}", state.launched) } +pub(crate) fn built_in_bootstrap_status() -> BuiltInTorBootstrapStatus { + let state = BUILT_IN_TOR_STATE.lock(); + let mut status = state.bootstrap_status.clone(); + status.launched = state.launched; + status.last_error = state.last_error.clone(); + status +} + +fn default_bootstrap_status( + launched: bool, + last_error: Option, +) -> BuiltInTorBootstrapStatus { + BuiltInTorBootstrapStatus { + percent: if launched { 1 } else { 0 }, + ready: false, + blocked: last_error.clone(), + message: if launched { + "Starting Tor".to_string() + } else { + "Built-in Tor is not running".to_string() + }, + launched, + last_error, + } +} + +fn set_bootstrap_status(status: &BootstrapStatus) { + let percent = (status.as_frac() * 100.0).round().clamp(0.0, 100.0) as u32; + let blocked = status.blocked().map(|blockage| blockage.to_string()); + let mut state = BUILT_IN_TOR_STATE.lock(); + state.bootstrap_status = BuiltInTorBootstrapStatus { + percent, + ready: status.ready_for_traffic(), + blocked, + message: status.to_string(), + launched: state.launched, + last_error: state.last_error.clone(), + }; +} + pub(crate) fn request_stop_built_in_proxy() -> bool { let shutdown_tx = { let state = BUILT_IN_TOR_STATE.lock(); @@ -92,25 +139,44 @@ pub(crate) fn request_stop_built_in_proxy() -> bool { Some(tx) => { if tx.send(true).is_err() { warn!("failed to request built-in tor shutdown: runtime channel closed"); - return false; + return wait_for_built_in_proxy_stopped(); } info!("requested built-in tor shutdown"); - true + wait_for_built_in_proxy_stopped() } None => { debug!("built-in tor shutdown requested but runtime is not active"); - false + true + } + } +} + +fn wait_for_built_in_proxy_stopped() -> bool { + const STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + + let started_at = std::time::Instant::now(); + let mut state = BUILT_IN_TOR_STATE.lock(); + loop { + if !state.launched && state.endpoint.is_none() { + info!("built-in tor proxy stopped"); + return true; + } + + let Some(remaining) = STOP_TIMEOUT.checked_sub(started_at.elapsed()) else { + warn!("timed out waiting for built-in tor proxy to stop"); + return false; + }; + + if BUILT_IN_TOR_STOPPED.wait_for(&mut state, remaining).timed_out() { + warn!("timed out waiting for built-in tor proxy to stop"); + return false; } } } fn built_in_tor_paths() -> BuiltInTorPaths { let tor_root = ROOT_DATA_DIR.join("tor"); - BuiltInTorPaths { - state_dir: tor_root.join("state"), - cache_dir: tor_root.join("cache"), - port_info_file: tor_root.join("public").join("port_info.json"), - } + BuiltInTorPaths { state_dir: tor_root.join("state"), cache_dir: tor_root.join("cache") } } fn ensure_built_in_tor_dirs(paths: &BuiltInTorPaths) -> Result<(), Error> { @@ -128,51 +194,6 @@ fn ensure_built_in_tor_dirs(paths: &BuiltInTorPaths) -> Result<(), Error> { )) })?; - if let Some(parent_dir) = paths.port_info_file.parent() { - std::fs::create_dir_all(parent_dir).map_err(|error| { - Error::Proxy(format!( - "failed to create built-in tor port-info dir {}: {error}", - parent_dir.display() - )) - })?; - } - - Ok(()) -} - -fn configure_built_in_tor_environment(paths: &BuiltInTorPaths) -> Result<(), Error> { - let tor_root = - paths.state_dir.parent().map(PathBuf::from).unwrap_or_else(|| ROOT_DATA_DIR.join("tor")); - let xdg_root = tor_root.join("xdg"); - let xdg_cache_home = xdg_root.join("cache"); - let xdg_data_home = xdg_root.join("data"); - let xdg_state_home = xdg_root.join("state"); - - for dir in [&xdg_cache_home, &xdg_data_home, &xdg_state_home] { - std::fs::create_dir_all(dir).map_err(|error| { - Error::Proxy(format!( - "failed to create built-in tor environment dir {}: {error}", - dir.display() - )) - })?; - } - - let set_env_if_missing = |key: &str, value: &PathBuf| { - if std::env::var_os(key).is_none() { - // SAFETY: we only set process environment entries during single-threaded startup - // of the built-in Tor runtime, before handing control to Arti internals. - unsafe { std::env::set_var(key, value) }; - info!(env = key, value = %value.display(), "configured built-in tor environment path"); - } - }; - - set_env_if_missing("HOME", &ROOT_DATA_DIR); - set_env_if_missing("XDG_CACHE_HOME", &xdg_cache_home); - set_env_if_missing("XDG_DATA_HOME", &xdg_data_home); - set_env_if_missing("XDG_STATE_HOME", &xdg_state_home); - set_env_if_missing("ARTI_CACHE", &paths.cache_dir); - set_env_if_missing("ARTI_LOCAL_DATA", &tor_root); - Ok(()) } @@ -183,15 +204,12 @@ fn build_tor_client_config() -> Result { info!( state_dir = %paths.state_dir.display(), cache_dir = %paths.cache_dir.display(), - port_info_file = %paths.port_info_file.display(), "configuring built-in tor storage directories" ); - // Keep storage locations aligned with Arti's env-expanded defaults. This avoids - // spurious "Cannot change storage.* on a running client" warnings during reloads. - TorClientConfigBuilder::default().build().map_err(|error| { - Error::Proxy(format!("failed to build built-in tor client config: {error}")) - }) + TorClientConfigBuilder::from_directories(&paths.state_dir, &paths.cache_dir).build().map_err( + |error| Error::Proxy(format!("failed to build built-in tor client config: {error}")), + ) } async fn wait_for_socks_listener(endpoint: SocketAddr) -> Result<(), Error> { @@ -254,41 +272,40 @@ async fn launch_built_in_proxy() -> Result { "resolved built-in tor endpoint" ); - let (shutdown_tx, mut shutdown_rx) = watch::channel(false); - { - let mut state = BUILT_IN_TOR_STATE.lock(); + let state = BUILT_IN_TOR_STATE.lock(); if let Some(endpoint) = state.endpoint { return Ok(endpoint); } if state.launched { - warn!(%endpoint, "built-in tor marked launched but endpoint was missing; reusing configured endpoint"); - state.endpoint = Some(endpoint); - return Ok(endpoint); + return Err(Error::Proxy( + "built-in tor launch already in progress without a ready endpoint".to_string(), + )); } + } + let client_config = build_tor_client_config()?; + let socks_listen = Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT); + + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + { + let mut state = BUILT_IN_TOR_STATE.lock(); + if let Some(endpoint) = state.endpoint { + return Ok(endpoint); + } + if state.launched { + return Err(Error::Proxy( + "built-in tor launch already in progress without a ready endpoint".to_string(), + )); + } state.launched = true; state.endpoint = Some(endpoint); state.last_error = None; state.shutdown_tx = Some(shutdown_tx); + state.bootstrap_status = default_bootstrap_status(true, None); } - let paths = built_in_tor_paths(); - ensure_built_in_tor_dirs(&paths)?; - configure_built_in_tor_environment(&paths)?; - - let mut arti_builder = ArtiConfig::builder(); - arti_builder.proxy().socks_listen(Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT)); - arti_builder.storage().port_info_file(CfgPath::new_literal(&paths.port_info_file)); - arti_builder.application().watch_configuration(false); - info!(%endpoint, "configuring Arti built-in SOCKS listener"); - - let arti_config = arti_builder.build().map_err(|error| Error::Proxy(error.to_string()))?; - - let client_config = build_tor_client_config()?; - let socks_listen = Listen::new_localhost(BUILT_IN_TOR_SOCKS_PORT); - std::thread::spawn(move || { info!("starting built-in tor runtime thread"); @@ -306,20 +323,67 @@ async fn launch_built_in_proxy() -> Result { } }; - info!("launching Arti SOCKS proxy task"); - let run = run_proxy( - proxy_runtime.clone(), - socks_listen, - Listen::new_none(), - ListenProtocols::SocksOnly, - ConfigurationSources::new_empty(), - arti_config, - client_config, - ); - let run_result = proxy_runtime.block_on(async { + info!("creating built-in Tor client"); + let client = TorClient::with_runtime(proxy_runtime.clone()) + .config(client_config) + .bootstrap_behavior(BootstrapBehavior::OnDemand) + .create_unbootstrapped_async() + .await + .map_err(|error| format!("failed to create built-in tor client: {error}"))?; + set_bootstrap_status(&client.bootstrap_status()); + + info!("launching Arti SOCKS proxy task"); + let mut listeners = Vec::new(); + for addrs in socks_listen + .ip_addrs() + .map_err(|error| format!("invalid built-in tor socks listener: {error}"))? + { + for addr in addrs { + let listener = proxy_runtime + .listen(&addr) + .await + .map_err(|error| format!("failed to listen on built-in tor socks address {addr}: {error}"))?; + info!(address = ?listener.local_addr(), "Listening on built-in Tor SOCKS address"); + listeners.push(listener); + } + } + if listeners.is_empty() { + return Err("failed to open built-in tor socks listener".to_string()); + } + + let proxy = arti::proxy::run_proxy_with_listeners( + client.isolated_client(), + listeners, + ListenProtocols::SocksOnly, + None, + ) + .map(|result| result.map_err(|error| format!("built-in tor socks proxy exited: {error}"))); + + let mut bootstrap_events = client.bootstrap_events(); + let status_watcher = async move { + while let Some(status) = bootstrap_events.next().await { + set_bootstrap_status(&status); + } + futures::future::pending::>().await + }; + + let bootstrap_client = client.clone(); + let bootstrap = async move { + bootstrap_client + .bootstrap() + .await + .map_err(|error| format!("built-in tor bootstrap failed: {error}"))?; + let status = bootstrap_client.bootstrap_status(); + set_bootstrap_status(&status); + info!("Sufficiently bootstrapped; proxy now functional."); + futures::future::pending::>().await + }; + tokio::select! { - run_result = run => run_result, + run_result = proxy => run_result, + run_result = bootstrap => run_result, + run_result = status_watcher => run_result, changed = shutdown_rx.changed() => { match changed { Ok(()) => { From b6362e9c4771a44195fd53244cbb2c17142525c0 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 29 Apr 2026 22:40:44 +0300 Subject: [PATCH 6/8] fix: Redact node URLs from logs, Align Tor bootstrap bindings (#256) --- .../SelectedWalletScreen.kt | 6 ++- .../java/org/bitcoinppl/cove_core/cove.kt | 39 ++++++++++++++----- .../Sources/CoveCore/generated/cove.swift | 22 +++++------ rust/src/lib.rs | 1 - rust/src/node/client/electrum.rs | 23 ++++++++--- rust/src/node/client/esplora.rs | 15 ++++++- 6 files changed, 74 insertions(+), 32 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt index b31d0aa9e..2384f92d3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt @@ -310,9 +310,11 @@ fun SelectedWalletScreen( when (torMode) { TorMode.BUILT_IN -> { if (!builtInWarmupRequested) { - builtInWarmupRequested = true runCatching { ensureBuiltInTorBootstrap() } - .onSuccess { endpoint -> builtInEndpoint = endpoint } + .onSuccess { endpoint -> + builtInEndpoint = endpoint + builtInWarmupRequested = true + } } val (builtInHost, builtInPort) = parseEndpointHostPort(builtInEndpoint) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 13787febb..c970d69ab 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -47882,14 +47882,25 @@ public object FfiConverterTypeTapSignerRoute : FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): TorBootstrapException = FfiConverterTypeTorBootstrapError.lift(error_buf) } + + } /** @@ -47897,22 +47908,31 @@ sealed class TorBootstrapException(message: String): kotlin.Exception(message) { */ public object FfiConverterTypeTorBootstrapError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): TorBootstrapException { - - return when(buf.getInt()) { - 1 -> TorBootstrapException.BuiltInTor(FfiConverterString.read(buf)) + + + return when(buf.getInt()) { + 1 -> TorBootstrapException.BuiltInTor( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } - } override fun allocationSize(value: TorBootstrapException): ULong { - return 4UL + return when(value) { + is TorBootstrapException.BuiltInTor -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } } override fun write(value: TorBootstrapException, buf: ByteBuffer) { when(value) { is TorBootstrapException.BuiltInTor -> { buf.putInt(1) + FfiConverterString.write(value.v1, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -55939,4 +55959,3 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler Result { let url = node.url.strip_suffix('/').unwrap_or(&node.url).to_string(); - debug!(url = %url, options = ?options, "creating electrum client from node and options"); + debug!( + api_type = "electrum", + tor_enabled = options.use_tor, + tor_mode = ?options.tor_mode, + batch_size = options.batch_size, + "creating electrum client from node and options" + ); let config = Self::connection_config(options); let retry_delays = if options.use_tor && matches!(options.tor_mode, TorMode::BuiltIn) { BUILT_IN_TOR_CONNECT_RETRY_DELAYS_MS.as_slice() @@ -85,7 +91,9 @@ impl ElectrumClient { && let Some(delay_ms) = retry_delays.get(attempt).copied() { warn!( - node_url = %url, + api_type = "electrum", + tor_enabled = options.use_tor, + tor_mode = ?options.tor_mode, attempt = attempt + 1, max_retries = retry_delays.len(), delay_ms, @@ -104,11 +112,9 @@ impl ElectrumClient { }; error!( - node_url = %url, + api_type = "electrum", tor_enabled = options.use_tor, tor_mode = ?options.tor_mode, - tor_host = %options.tor_external_host, - tor_port = options.tor_external_port, attempt = attempt + 1, max_retries = retry_delays.len(), built_in_tor_status = %built_in_tor_status, @@ -354,7 +360,12 @@ impl ElectrumClient { fn connection_config(options: &NodeClientOptions) -> Config { let socks5_addr = if options.use_tor { let endpoint = format!("{}:{}", options.tor_external_host, options.tor_external_port); - debug!(endpoint = %endpoint, tor_mode = ?options.tor_mode, "electrum using socks5 endpoint"); + debug!( + api_type = "electrum", + tor_enabled = true, + tor_mode = ?options.tor_mode, + "electrum using socks5 endpoint" + ); Some(endpoint) } else { debug!("electrum connecting without tor proxy"); diff --git a/rust/src/node/client/esplora.rs b/rust/src/node/client/esplora.rs index f53607dc4..5bd6f648c 100644 --- a/rust/src/node/client/esplora.rs +++ b/rust/src/node/client/esplora.rs @@ -40,13 +40,24 @@ impl EsploraClient { node: &Node, options: &NodeClientOptions, ) -> Result { - debug!(url = %node.url, options = ?options, "creating esplora client from node and options"); + debug!( + api_type = "esplora", + tor_enabled = options.use_tor, + tor_mode = ?options.tor_mode, + batch_size = options.batch_size, + "creating esplora client from node and options" + ); let mut builder = esplora_client::Builder::new(&node.url); if options.use_tor { + debug!( + api_type = "esplora", + tor_enabled = true, + tor_mode = ?options.tor_mode, + "esplora using socks proxy" + ); let proxy = format!("socks5h://{}:{}", options.tor_external_host, options.tor_external_port); - debug!(proxy = %proxy, tor_mode = ?options.tor_mode, "esplora using socks proxy"); builder = builder.proxy(&proxy); } else { debug!("esplora connecting without tor proxy"); From eea58026f3f69acce9781f8ce27ee1564e42d902 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Wed, 29 Apr 2026 23:39:02 +0300 Subject: [PATCH 7/8] fix: Localize Tor status UI and redact node logs and handle handle IPv6 SOCKS endpoints --- .../SelectedWalletScreen.kt | 115 +++++++++++++----- android/app/src/main/res/values/strings.xml | 27 ++++ rust/src/database/global_config.rs | 4 +- rust/src/lib.rs | 6 +- rust/src/tor_runtime.rs | 6 +- 5 files changed, 116 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt index 2384f92d3..75922064d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt @@ -116,9 +116,9 @@ private data class TorQuickStatus( val torConnection: TorStatusDot = TorStatusDot.Gray, val nodeReachable: TorStatusDot = TorStatusDot.Gray, val nodeSynced: TorStatusDot = TorStatusDot.Gray, - val torMessage: String = "Tor disabled", - val nodeMessage: String = "Node status unavailable", - val syncMessage: String = "Sync status unavailable", + val torMessage: String = "", + val nodeMessage: String = "", + val syncMessage: String = "", val logs: List = emptyList(), ) @@ -168,6 +168,20 @@ private fun leadingPercent(message: String): Int? = ?.coerceIn(0, 100) private fun parseEndpointHostPort(endpoint: String): Pair? { + if (endpoint.startsWith('[')) { + val closingBracket = endpoint.indexOf(']') + if (closingBracket <= 1) { + return null + } + val host = endpoint.substring(1, closingBracket) + val portSeparator = endpoint.indexOf(':', startIndex = closingBracket + 1) + val port = endpoint.substring(closingBracket + 2).toIntOrNull() + if (portSeparator != closingBracket + 1 || host.isBlank() || port == null || port <= 0) { + return null + } + return host to port + } + val host = endpoint.substringBefore(':', "") val port = endpoint.substringAfter(':', "").toIntOrNull() if (host.isBlank() || port == null || port <= 0) { @@ -274,7 +288,6 @@ fun SelectedWalletScreen( val isWatchOnly = manager.walletMetadata?.walletType == WalletType.WATCH_ONLY val globalConfig = remember(app) { app.database.globalConfig() } var showTorStatusMenu by remember { mutableStateOf(false) } - var torQuickStatus by remember { mutableStateOf(TorQuickStatus()) } var builtInWarmupRequested by remember { mutableStateOf(false) } var builtInEndpoint by remember { mutableStateOf("127.0.0.1:39050") } val torBusyBlinkTransition = rememberInfiniteTransition(label = "torBusyBlink") @@ -289,9 +302,33 @@ fun SelectedWalletScreen( ), label = "torBusyBlinkAlpha", ) + val torDisabledText = stringResource(R.string.selected_wallet_tor_status_disabled) + val torBuiltInReadyText = stringResource(R.string.selected_wallet_tor_status_built_in_ready) + val torOrbotReachableText = stringResource(R.string.selected_wallet_tor_status_orbot_reachable) + val torOrbotUnavailableText = stringResource(R.string.selected_wallet_tor_status_orbot_unavailable) + val torExternalReachableText = stringResource(R.string.selected_wallet_tor_status_external_reachable) + val torExternalUnavailableText = stringResource(R.string.selected_wallet_tor_status_external_unavailable) + val torBuiltInErrorText = stringResource(R.string.selected_wallet_tor_status_built_in_error) + val torBuiltInBootstrappingText = stringResource(R.string.selected_wallet_tor_status_built_in_bootstrapping) + val nodeReachableText = stringResource(R.string.selected_wallet_node_reachable) + val nodeConnectionFailedText = stringResource(R.string.selected_wallet_node_connection_failed) + val checkingNodeConnectionText = stringResource(R.string.selected_wallet_checking_node_connection) + val nodeStatusUnavailableText = stringResource(R.string.selected_wallet_node_status_unavailable) + val nodeSyncedText = stringResource(R.string.selected_wallet_node_synced) + val nodeSyncingText = stringResource(R.string.selected_wallet_node_syncing) + val nodeSyncFailedText = stringResource(R.string.selected_wallet_node_sync_failed) + val syncStatusUnavailableText = stringResource(R.string.selected_wallet_sync_status_unavailable) + val torQuickStatusDefaults = + remember(torDisabledText, nodeStatusUnavailableText, syncStatusUnavailableText) { + TorQuickStatus( + torMessage = torDisabledText, + nodeMessage = nodeStatusUnavailableText, + syncMessage = syncStatusUnavailableText, + ) + } + var torQuickStatus by remember { mutableStateOf(torQuickStatusDefaults) } val torOverallDotAlpha = if (torQuickStatus.overall == TorStatusDot.Yellow) torBusyBlinkAlpha.value else 1f - LaunchedEffect(manager, app, globalConfig) { while (true) { val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false) @@ -305,7 +342,7 @@ fun SelectedWalletScreen( val torMessage: String if (!useTor) { torConnection = TorStatusDot.Red - torMessage = "Tor disabled" + torMessage = torDisabledText } else { when (torMode) { TorMode.BUILT_IN -> { @@ -352,9 +389,9 @@ fun SelectedWalletScreen( } torMessage = when (torConnection) { - TorStatusDot.Green -> "Built-in Tor ready" - TorStatusDot.Red -> "Built-in Tor error: $bootstrapMessage" - else -> "Built-in Tor bootstrapping ($bootstrapPercent%)" + TorStatusDot.Green -> torBuiltInReadyText + TorStatusDot.Red -> torBuiltInErrorText.format(bootstrapMessage) + else -> torBuiltInBootstrappingText.format(bootstrapPercent) } } TorMode.ORBOT -> { @@ -362,9 +399,9 @@ fun SelectedWalletScreen( torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red torMessage = if (socksReady) { - "Orbot SOCKS endpoint reachable" + torOrbotReachableText } else { - "Orbot SOCKS endpoint unavailable" + torOrbotUnavailableText } } TorMode.EXTERNAL -> { @@ -380,9 +417,9 @@ fun SelectedWalletScreen( torConnection = if (socksReady) TorStatusDot.Green else TorStatusDot.Red torMessage = if (socksReady) { - "External SOCKS endpoint reachable" + torExternalReachableText } else { - "External SOCKS endpoint unavailable" + torExternalUnavailableText } } } @@ -397,10 +434,10 @@ fun SelectedWalletScreen( } val nodeMessage = when (nodeReachable) { - TorStatusDot.Green -> "Node reachable" - TorStatusDot.Red -> "Node connection failed" - TorStatusDot.Yellow -> "Checking node connection" - TorStatusDot.Gray -> "Node status unavailable" + TorStatusDot.Green -> nodeReachableText + TorStatusDot.Red -> nodeConnectionFailedText + TorStatusDot.Yellow -> checkingNodeConnectionText + TorStatusDot.Gray -> nodeStatusUnavailableText } val nodeSynced = @@ -413,10 +450,10 @@ fun SelectedWalletScreen( } val syncMessage = when (nodeSynced) { - TorStatusDot.Green -> "Node synced" - TorStatusDot.Yellow -> "Node syncing" - TorStatusDot.Red -> "Node sync failed" - TorStatusDot.Gray -> "Sync status unavailable" + TorStatusDot.Green -> nodeSyncedText + TorStatusDot.Yellow -> nodeSyncingText + TorStatusDot.Red -> nodeSyncFailedText + TorStatusDot.Gray -> syncStatusUnavailableText } val overall = @@ -524,14 +561,14 @@ fun SelectedWalletScreen( IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.content_description_back), ) } } else { IconButton(onClick = onBack) { Icon( imageVector = Icons.Filled.Menu, - contentDescription = "Menu", + contentDescription = stringResource(R.string.content_description_menu), ) } } @@ -554,13 +591,13 @@ fun SelectedWalletScreen( ) { Icon( painter = painterResource(R.drawable.icon_tor_onion), - contentDescription = "Tor", + contentDescription = stringResource(R.string.content_description_tor_onion), tint = Color.White, modifier = Modifier.size(20.dp), ) Icon( imageVector = Icons.Filled.FiberManualRecord, - contentDescription = "Tor status", + contentDescription = stringResource(R.string.content_description_tor_status), tint = torQuickStatus.overall.color(), modifier = Modifier @@ -581,14 +618,26 @@ fun SelectedWalletScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Tor Network Status", + text = stringResource(R.string.selected_wallet_tor_network_status), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - TorStatusRow("Tor Connection", torQuickStatus.torConnection, torQuickStatus.torMessage) - TorStatusRow("Node Reachable", torQuickStatus.nodeReachable, torQuickStatus.nodeMessage) - TorStatusRow("Node Synced", torQuickStatus.nodeSynced, torQuickStatus.syncMessage) + TorStatusRow( + stringResource(R.string.selected_wallet_tor_connection_title), + torQuickStatus.torConnection, + torQuickStatus.torMessage, + ) + TorStatusRow( + stringResource(R.string.selected_wallet_node_reachable_title), + torQuickStatus.nodeReachable, + torQuickStatus.nodeMessage, + ) + TorStatusRow( + stringResource(R.string.selected_wallet_node_synced_title), + torQuickStatus.nodeSynced, + torQuickStatus.syncMessage, + ) } if (torQuickStatus.logs.isNotEmpty()) { @@ -601,7 +650,7 @@ fun SelectedWalletScreen( .padding(8.dp), ) { Text( - text = "Recent Logs", + text = stringResource(R.string.selected_wallet_recent_logs), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -620,7 +669,7 @@ fun SelectedWalletScreen( } Text( - text = "Network Settings", + text = stringResource(R.string.selected_wallet_network_settings), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -643,7 +692,7 @@ fun SelectedWalletScreen( ) { Icon( imageVector = Icons.Filled.QrCode2, - contentDescription = "QR Code", + contentDescription = stringResource(R.string.content_description_qr_code), ) } IconButton( @@ -652,7 +701,7 @@ fun SelectedWalletScreen( ) { Icon( imageVector = Icons.Filled.MoreVert, - contentDescription = "More", + contentDescription = stringResource(R.string.content_description_more), ) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2c1ca3171..33ed38357 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -190,6 +190,11 @@ Manage UTXOs Wallet Settings Address unavailable + Menu + Tor + Tor status + QR Code + More Tor Network @@ -258,5 +263,27 @@ Custom proxy configured Tor is currently disabled Routing via custom proxy + Tor disabled + Built-in Tor ready + Built-in Tor error: %1$s + Built-in Tor bootstrapping (%1$d%%) + Orbot SOCKS endpoint reachable + Orbot SOCKS endpoint unavailable + External SOCKS endpoint reachable + External SOCKS endpoint unavailable + Node reachable + Node connection failed + Checking node connection + Node status unavailable + Node synced + Node syncing + Node sync failed + Sync status unavailable + Tor Network Status + Recent Logs + Network Settings + Tor Connection + Node Reachable + Node Synced TOR diff --git a/rust/src/database/global_config.rs b/rust/src/database/global_config.rs index 7b6bb65b0..bfc99abeb 100644 --- a/rust/src/database/global_config.rs +++ b/rust/src/database/global_config.rs @@ -371,8 +371,6 @@ impl GlobalConfigTable { .map_err(|error| GlobalConfigTableError::Save(error.to_string()))?; } - write_txn.commit().map_err(|error| Error::DatabaseAccess(error.to_string()))?; - if should_stop_built_in_tor && !crate::tor_runtime::request_stop_built_in_proxy() { return Err(GlobalConfigTableError::BuiltInTorStop( "timed out waiting for proxy shutdown".to_string(), @@ -380,6 +378,8 @@ impl GlobalConfigTable { .into()); } + write_txn.commit().map_err(|error| Error::DatabaseAccess(error.to_string()))?; + Updater::send_update(Update::DatabaseUpdated); Ok(()) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6ad7f51f8..9febb5653 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -88,7 +88,6 @@ use cove_types::color; use cove_types::color_scheme; use cove_types::network; use cove_types::psbt; -use cove_util::ResultExt as _; use std::path::PathBuf; @@ -125,8 +124,9 @@ fn set_root_data_dir(path: String) -> Result<(), InitError> { #[uniffi::export(async_runtime = "tokio")] async fn ensure_built_in_tor_bootstrap() -> Result { - let endpoint = - tor_runtime::built_in_socks_endpoint().await.map_err_str(TorBootstrapError::BuiltInTor)?; + let endpoint = tor_runtime::built_in_socks_endpoint().await.map_err(|error| match error { + tor_runtime::Error::Proxy(message) => TorBootstrapError::BuiltInTor(message), + })?; Ok(endpoint.to_string()) } diff --git a/rust/src/tor_runtime.rs b/rust/src/tor_runtime.rs index 456704824..8d0a6da68 100644 --- a/rust/src/tor_runtime.rs +++ b/rust/src/tor_runtime.rs @@ -82,11 +82,9 @@ fn take_built_in_error() -> Option { pub(crate) fn built_in_status_summary() -> String { let state = BUILT_IN_TOR_STATE.lock(); - let endpoint = - state.endpoint.map(|value| value.to_string()).unwrap_or_else(|| "none".to_string()); - let last_error = state.last_error.clone().unwrap_or_else(|| "none".to_string()); + let last_error = if state.last_error.is_some() { "present" } else { "none" }; - format!("endpoint={endpoint}, launched={}, last_error={last_error}", state.launched) + format!("launched={}, last_error={last_error}", state.launched) } pub(crate) fn built_in_bootstrap_status() -> BuiltInTorBootstrapStatus { From 717d39da696a2f833a9f9d383bc9a3adf53bd3b7 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Thu, 30 Apr 2026 00:22:55 +0300 Subject: [PATCH 8/8] fix: pump uniffi bindings --- .../src/main/java/org/bitcoinppl/cove_core/cove.kt | 13 +++++++------ ios/CoveCore/Sources/CoveCore/generated/cove.swift | 10 +++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index c970d69ab..e07aa25a0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -47883,24 +47883,24 @@ public object FfiConverterTypeTapSignerRoute : FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): TorBootstrapException = FfiConverterTypeTorBootstrapError.lift(error_buf) } - + } /** @@ -47908,7 +47908,7 @@ sealed class TorBootstrapException: kotlin.Exception() { */ public object FfiConverterTypeTorBootstrapError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): TorBootstrapException { - + return when(buf.getInt()) { 1 -> TorBootstrapException.BuiltInTor( @@ -55959,3 +55959,4 @@ object UrExceptionExternalErrorHandler : UniffiRustCallStatusErrorHandler