diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b865342..cf23c17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,8 +44,24 @@ jobs: - target: aarch64-unknown-linux-musl os: ubuntu-latest name: mhrv-rs-linux-musl-arm64 + # OpenWRT MT7621 (soft-float mipsel 32-bit). Dozens of cheap + # home routers run this chipset and they *specifically* need + # the soft-float variant — MT7621 has no hardware FPU and a + # hard-float binary segfaults on the first fp op. Tier-3 in + # Rust since 1.72; we build it via messense's musl-cross + # docker image which still has a mipsel-softfloat toolchain. + # `continue-on-error: true` so a regression here doesn't block + # the rest of the release. Issue #26. + - target: mipsel-unknown-linux-musl + os: ubuntu-latest + name: mhrv-rs-openwrt-mipsel-softfloat + mipsel_softfloat: true runs-on: ${{ matrix.os }} + # mipsel-softfloat is best-effort: the Rust tier-3 target occasionally + # regresses. Letting it fail keeps the main release going so + # desktop/Android users aren't blocked by MT7621 router support. + continue-on-error: ${{ matrix.mipsel_softfloat == true }} steps: - uses: actions/checkout@v4 @@ -125,6 +141,23 @@ jobs: cargo build --release --target aarch64-unknown-linux-musl --bin mhrv-rs sudo chown -R "$(id -u):$(id -g)" target + # OpenWRT MT7621 / mipsel-softfloat. The messense image tag + # `mipsel-musl-softfloat` ships a toolchain that emits soft-float + # insn exclusively — matches the MT7621's FPU-less reality. + # Requires Rust nightly + -Z build-std because mipsel is tier 3 + # in the stable channel, which means no pre-built std. + - name: Build CLI (mipsel-softfloat via docker) + if: matrix.target == 'mipsel-unknown-linux-musl' && matrix.mipsel_softfloat == true + run: | + docker run --rm -v "$PWD":/src -w /src \ + messense/rust-musl-cross:mipsel-musl-softfloat \ + sh -c "rustup toolchain install nightly --profile minimal --component rust-src && \ + cargo +nightly build --release \ + -Z build-std=std,panic_abort \ + --target mipsel-unknown-linux-musl \ + --bin mhrv-rs" + sudo chown -R "$(id -u):$(id -g)" target + # UI build: we try to build the UI binary on every platform. If it fails # on cross-compile for linux-arm64 (missing arm64 system libs cross), # we still ship the CLI. We also skip the UI on musl targets (OpenWRT etc. diff --git a/Cargo.lock b/Cargo.lock index 86f6c5c..037a820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -213,7 +222,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -225,7 +234,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -254,7 +263,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -307,6 +316,21 @@ dependencies = [ "virtue", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -322,6 +346,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -395,7 +425,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -427,7 +457,7 @@ checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -580,7 +610,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -598,12 +628,53 @@ dependencies = [ "error-code", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "combine" version = "4.6.7" @@ -836,7 +907,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -891,6 +962,7 @@ dependencies = [ "directories", "document-features", "egui", + "egui-wgpu", "egui-winit", "egui_glow", "glow", @@ -904,6 +976,7 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", + "pollster", "raw-window-handle 0.5.2", "raw-window-handle 0.6.2", "ron", @@ -913,6 +986,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", + "wgpu", "winapi", "winit", ] @@ -933,6 +1007,25 @@ dependencies = [ "serde", ] +[[package]] +name = "egui-wgpu" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c7a7c707877c3362a321ebb4f32be811c0b91f7aebf345fb162405c0218b4c" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + [[package]] name = "egui-winit" version = "0.28.1" @@ -986,7 +1079,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -997,7 +1090,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1139,6 +1232,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1163,7 +1262,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1247,7 +1346,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1410,6 +1509,58 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "h2" version = "0.4.13" @@ -1429,13 +1580,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1453,6 +1613,21 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.1", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + [[package]] name = "heck" version = "0.5.0" @@ -1471,6 +1646,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -1524,7 +1705,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1727,7 +1908,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1773,7 +1954,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1801,7 +1982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1826,6 +2007,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -1844,6 +2036,16 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1915,6 +2117,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1958,9 +2169,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5637e166ea14be6063a3f8ba5ccb9a4159df7d8f6d61c02fc3d480b1f90dcfcb" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "mhrv-rs" -version = "1.0.2" +version = "1.1.0" dependencies = [ "base64 0.22.1", "bytes", @@ -2028,6 +2254,27 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e536ae46fcab0876853bd4a632ede5df4b1c2527a58f6c5a4150fe86be858231" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + [[package]] name = "ndk" version = "0.8.0" @@ -2210,7 +2457,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", ] [[package]] @@ -2552,6 +2808,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2591,6 +2853,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2609,6 +2877,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + [[package]] name = "pxfm" version = "0.1.29" @@ -2791,6 +3065,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "resolv-conf" version = "0.7.6" @@ -2841,6 +3121,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2988,7 +3280,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3179,6 +3471,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3203,6 +3504,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[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" @@ -3222,7 +3534,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3259,6 +3571,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -3295,7 +3616,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3306,7 +3627,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3399,7 +3720,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3497,7 +3818,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3606,6 +3927,15 @@ dependencies = [ "windows-service", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "typenum" version = "1.20.0" @@ -3641,6 +3971,18 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -3762,7 +4104,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3989,6 +4331,112 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wgpu" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e37c7b9921b75dfd26dd973fdcbce36f13dfa6e2dc82aece584e0ed48c355c" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50819ab545b867d8a454d1d756b90cd5f15da1f2943334ca314af10583c9d39" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172e490a87295564f3fcc0f165798d87386f6231b04d4548bca458cbbfd63222" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.11.1", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1353d9a46bff7f955a680577f34c69122628cc2076e1d6f3a9be6ef00ae793ef" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" @@ -4026,6 +4474,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4047,7 +4514,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4058,7 +4525,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4597,7 +5064,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4618,7 +5085,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4638,7 +5105,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4678,7 +5145,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a228fe..a631bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.0.2" +version = "1.1.0" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" @@ -51,9 +51,17 @@ directories = "5" futures-util = { version = "0.3", default-features = false, features = ["std"] } # Optional UI dep: only pulled in when --features ui is set. +# Both `glow` (OpenGL 2+) and `wgpu` (DX12/Vulkan/Metal) are compiled in; +# the binary picks one at startup — glow by default for compat with the +# egui look-and-feel we've been shipping, but falls back to wgpu when +# `MHRV_RENDERER=wgpu` is set. Issue #28: users on older Windows +# hardware / RDP / VMs without OpenGL 2.0 crash with +# `egui_glow requires opengl 2.0+` — the wgpu backend uses DX12/Vulkan +# instead and covers those boxes. eframe = { version = "0.28", default-features = false, features = [ "default_fonts", "glow", + "wgpu", "persistence", ], optional = true } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 557aa6e..e82a277 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.therealaleph.mhrv" minSdk = 24 // Android 7.0 — covers 99%+ of live devices. targetSdk = 34 - versionCode = 102 - versionName = "1.0.2" + versionCode = 110 + versionName = "1.1.0" // Ship all four mainstream Android ABIs: // - arm64-v8a — 95%+ of real-world Android phones since 2019 @@ -102,6 +102,10 @@ dependencies { implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + // AppCompatDelegate.setApplicationLocales is the only thing we need + // out of AppCompat — lets us flip the whole app locale at runtime + // from MhrvApp.onCreate without touching every composable. + implementation("androidx.appcompat:appcompat:1.7.0") // Compose UI. implementation("androidx.compose.ui:ui") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5da2413..dd2e94e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,29 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 6af1903..b9bbb7c 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -17,6 +17,48 @@ import java.io.File * - log level / verify_ssl / parallel_relay knobs * Anything else gets phone-appropriate defaults. */ +/** + * How the foreground service exposes the proxy to the rest of the device. + * + * - [VPN_TUN] — the default; `VpnService` claims a TUN interface and every + * app's traffic goes through `tun2proxy` → our SOCKS5 → Apps Script. + * Requires the user to accept the system "VPN connection request" + * dialog on first Start. + * + * - [PROXY_ONLY] — just runs the HTTP (`127.0.0.1:8080`) and SOCKS5 + * (`127.0.0.1:1081`) listeners; no VpnService, no TUN. The user sets + * their Wi-Fi proxy (or a per-app proxy setting) to those addresses. + * Useful when the device already has another VPN up, or the user + * specifically wants per-app opt-in, or on rooted/specialized devices + * where VpnService is unwelcome. Closes issue #37. + */ +enum class ConnectionMode { VPN_TUN, PROXY_ONLY } + +/** + * App-splitting policy when in VPN_TUN mode. + * + * - [ALL] — tunnel every app (default; the package list is ignored). + * - [ONLY] — allow-list: tunnel ONLY the apps in `splitApps`. Everything + * else bypasses the VPN. Useful when you want mhrv-rs for a specific + * browser / messenger and nothing else. + * - [EXCEPT] — deny-list: tunnel everything EXCEPT the apps in + * `splitApps`. Useful for excluding a banking app that would break + * under MITM anyway, or a self-updater you don't want going through + * the quota-limited relay. + * + * Our own package (`packageName`) is always excluded regardless of mode + * — that's the loop-avoidance rule from day one, not a user toggle. + */ +enum class SplitMode { ALL, ONLY, EXCEPT } + +/** + * UI language preference. AUTO respects the device locale; FA / EN + * force the app into Persian / English with proper RTL / LTR layout + * on next app launch (AppCompatDelegate.setApplicationLocales is + * applied at Application.onCreate). + */ +enum class UiLang { AUTO, FA, EN } + data class MhrvConfig( val listenHost: String = "127.0.0.1", val listenPort: Int = 8080, @@ -35,6 +77,17 @@ data class MhrvConfig( val logLevel: String = "info", val parallelRelay: Int = 1, val upstreamSocks5: String = "", + + /** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */ + val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN, + + /** ALL / ONLY / EXCEPT — scope of app splitting inside VPN_TUN mode. */ + val splitMode: SplitMode = SplitMode.ALL, + /** Package names used by ONLY and EXCEPT. Empty under ALL. */ + val splitApps: List = emptyList(), + + /** UI language toggle. Non-Rust; honoured only by the Android wrapper. */ + val uiLang: UiLang = UiLang.AUTO, ) { /** * Extract just the deployment ID from either a full @@ -103,6 +156,28 @@ data class MhrvConfig( // who need it can do that on the desktop UI and paste the IP. put("fetch_ips_from_api", false) put("max_ips_to_scan", 20) + + // Android-only: surfaced in the UI dropdown. The Rust side + // doesn't read this key (serde ignores unknown fields), which + // is intentional — proxy-vs-TUN is a service-layer decision + // that belongs to the Android wrapper, not the crate. + put("connection_mode", when (connectionMode) { + ConnectionMode.VPN_TUN -> "vpn_tun" + ConnectionMode.PROXY_ONLY -> "proxy_only" + }) + put("split_mode", when (splitMode) { + SplitMode.ALL -> "all" + SplitMode.ONLY -> "only" + SplitMode.EXCEPT -> "except" + }) + if (splitApps.isNotEmpty()) { + put("split_apps", JSONArray().apply { splitApps.forEach { put(it) } }) + } + put("ui_lang", when (uiLang) { + UiLang.AUTO -> "auto" + UiLang.FA -> "fa" + UiLang.EN -> "en" + }) } return obj.toString(2) } @@ -146,6 +221,23 @@ object ConfigStore { logLevel = obj.optString("log_level", "info"), parallelRelay = obj.optInt("parallel_relay", 1), upstreamSocks5 = obj.optString("upstream_socks5", ""), + connectionMode = when (obj.optString("connection_mode", "vpn_tun")) { + "proxy_only" -> ConnectionMode.PROXY_ONLY + else -> ConnectionMode.VPN_TUN // default for unknown/missing + }, + splitMode = when (obj.optString("split_mode", "all")) { + "only" -> SplitMode.ONLY + "except" -> SplitMode.EXCEPT + else -> SplitMode.ALL + }, + splitApps = obj.optJSONArray("split_apps")?.let { arr -> + buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } + }?.filter { it.isNotBlank() }.orEmpty(), + uiLang = when (obj.optString("ui_lang", "auto")) { + "fa" -> UiLang.FA + "en" -> UiLang.EN + else -> UiLang.AUTO + }, ) } catch (_: Throwable) { MhrvConfig() diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt index 292696b..5aedb33 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt @@ -7,9 +7,12 @@ import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.os.Bundle -import androidx.activity.ComponentActivity +import android.content.Context +import android.content.res.Configuration import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import java.util.Locale import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -22,7 +25,41 @@ import com.therealaleph.mhrv.ui.CaInstallOutcome import com.therealaleph.mhrv.ui.HomeScreen import com.therealaleph.mhrv.ui.theme.MhrvTheme -class MainActivity : ComponentActivity() { +// UiLang is in the outer package namespace already. + + +// AppCompatActivity (not plain ComponentActivity) because it's what picks +// up AppCompatDelegate.setApplicationLocales() and swaps per-activity +// Configuration + LayoutDirection on recreate(). Compose works fine on +// top — setContent / rememberLauncherForActivityResult live on +// ComponentActivity and AppCompatActivity inherits from it. +class MainActivity : AppCompatActivity() { + + override fun attachBaseContext(newBase: Context) { + // Force the persisted ui_lang into the Activity's Configuration + // before it's constructed. AppCompatDelegate.setApplicationLocales + // schedules a locale change but only takes effect on the NEXT + // process, so on cold start with a saved preference the activity + // would render in the device-default locale until recreate(). + // Overriding attachBaseContext wraps `newBase` with the correct + // locale at the earliest possible moment — what AppCompat did + // internally before the setApplicationLocales API existed. This + // path is reliable across all Android versions we support. + val cfg = ConfigStore.load(newBase) + val tag = when (cfg.uiLang) { + UiLang.FA -> "fa" + UiLang.EN -> "en" + UiLang.AUTO -> null + } + val wrapped = if (tag != null) { + val config = Configuration(newBase.resources.configuration) + val locale = Locale.forLanguageTag(tag) + Locale.setDefault(locale) + config.setLocale(locale) + newBase.createConfigurationContext(config) + } else newBase + super.attachBaseContext(wrapped) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -100,11 +137,21 @@ class MainActivity : ComponentActivity() { // auto-resolve (it uses the same persist() flow the UI uses // for text-field edits, so there's one source of truth). onStart = { - val prepareIntent = VpnService.prepare(this) - if (prepareIntent == null) { - startVpnService() + // Only ask for the VPN-consent grant when the user has + // opted into VPN_TUN mode. In PROXY_ONLY we don't touch + // VpnService.prepare — firing the consent dialog there + // would be wrong (user said "no VPN") and MhrvVpnService + // wouldn't call establish() anyway. + val cfg = ConfigStore.load(this) + if (cfg.connectionMode == ConnectionMode.VPN_TUN) { + val prepareIntent = VpnService.prepare(this) + if (prepareIntent == null) { + startVpnService() + } else { + vpnPrepareLauncher.launch(prepareIntent) + } } else { - vpnPrepareLauncher.launch(prepareIntent) + startVpnService() } }, onStop = { @@ -157,6 +204,29 @@ class MainActivity : ComponentActivity() { }, caOutcome = caOutcome, onCaOutcomeConsumed = { caOutcome = null }, + onLangChange = { lang -> + // Re-apply the new locale to the running process. AppCompatDelegate + // picks it up from MhrvApp.onCreate on process restart, so we + // recreate() the activity to take effect immediately — otherwise + // the user would have to swipe the app away and reopen it for + // RTL/LTR to swap. + val tag = when (lang) { + UiLang.FA -> "fa" + UiLang.EN -> "en" + UiLang.AUTO -> "" + } + androidx.appcompat.app.AppCompatDelegate.setApplicationLocales( + if (tag.isEmpty()) + androidx.core.os.LocaleListCompat.getEmptyLocaleList() + else + androidx.core.os.LocaleListCompat.forLanguageTags(tag), + ) + // AppCompatDelegate triggers recreate internally on API 33+ + // via the per-app language OS setting, but on older API + // levels it doesn't — call it explicitly for consistent + // behaviour across the minSdk=24 range. + recreate() + }, ) } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt index ebe23f5..ffa7154 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvApp.kt @@ -2,6 +2,8 @@ package com.therealaleph.mhrv import android.app.Application import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat /** * Application-level setup. The only job here right now is to catch @@ -20,6 +22,24 @@ import android.util.Log class MhrvApp : Application() { override fun onCreate() { super.onCreate() + + // Apply the saved UI-language preference before any UI class + // loads. AppCompatDelegate propagates locale changes to the whole + // process, including Compose text rendering and + // LocalLayoutDirection (which becomes RTL when Persian is + // selected), without us having to thread it through every + // composable. + val cfg = ConfigStore.load(this) + val tag = when (cfg.uiLang) { + UiLang.FA -> "fa" + UiLang.EN -> "en" + UiLang.AUTO -> "" // empty list = follow system locale + } + Log.i(APP_TAG, "applying ui_lang=${cfg.uiLang} (tag='$tag')") + AppCompatDelegate.setApplicationLocales( + if (tag.isEmpty()) LocaleListCompat.getEmptyLocaleList() + else LocaleListCompat.forLanguageTags(tag), + ) val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Log.e( @@ -36,5 +56,6 @@ class MhrvApp : Application() { companion object { private const val CRASH_TAG = "mhrv-crash" + private const val APP_TAG = "MhrvApp" } } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt index 5c27be3..f6525c6 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -110,6 +110,21 @@ class MhrvVpnService : VpnService() { val socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1) + // PROXY_ONLY mode: the user wants just the 127.0.0.1 HTTP + SOCKS5 + // listeners up, with no VpnService / no TUN. Typical reasons: + // another VPN app already owns the system VPN slot, the user + // wants per-app opt-in via Wi-Fi proxy settings, or the device + // is a sandboxed/rooted setup where VpnService is unwelcome. + // We still run as a foreground service (required for the native + // listener thread to survive backgrounding), we just skip every + // VPN-specific step below. Issue #37. + if (cfg.connectionMode == ConnectionMode.PROXY_ONLY) { + Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN") + startForeground(NOTIF_ID, buildNotif(cfg.listenPort)) + VpnState.setRunning(true) + return + } + // 2) Establish the TUN. Key Builder calls: // - addAddress(10.0.0.2/32): our local IP inside the tunnel. // - addRoute(0.0.0.0/0): capture ALL IPv4 traffic. IPv6 isn't added, @@ -135,6 +150,47 @@ class MhrvVpnService : VpnService() { Log.w(TAG, "addDisallowedApplication failed: ${e.message}") } + // Apply user-chosen app splitting on top of the mandatory + // self-exclusion above. + // + // ALL — no extra restriction; every other app routes through + // us. Matches pre-splitting behaviour. + // ONLY — allow-list. addAllowedApplication() for each chosen + // package; anything missing from the list bypasses the + // VPN on the OS-native route. Note that ONLY and the + // mandatory self-exclude are mutually exclusive in the + // VpnService API, so if the user also put us in the + // allow-list we skip the self-exclude (it's already + // implicit via "we're not in the list"). + // EXCEPT — deny-list. addDisallowedApplication() for each chosen + // package, additive with our self-exclude. + // + // Packages that are not installed (leftover selections from a + // previous device) throw PackageManager.NameNotFoundException — + // we log and skip rather than aborting the whole VPN start. + when (cfg.splitMode) { + SplitMode.ALL -> { /* no-op */ } + SplitMode.ONLY -> { + if (cfg.splitApps.isEmpty()) { + Log.w(TAG, "ONLY mode with empty splitApps list — no app would get the VPN; falling back to ALL") + } else { + for (pkg in cfg.splitApps) { + try { builder.addAllowedApplication(pkg) } catch (e: Throwable) { + Log.w(TAG, "addAllowedApplication($pkg) failed: ${e.message}") + } + } + } + } + SplitMode.EXCEPT -> { + for (pkg in cfg.splitApps) { + if (pkg == packageName) continue // already self-excluded above + try { builder.addDisallowedApplication(pkg) } catch (e: Throwable) { + Log.w(TAG, "addDisallowedApplication($pkg) failed: ${e.message}") + } + } + } + } + val parcelFd = try { builder.establish() } catch (t: Throwable) { @@ -177,6 +233,12 @@ class MhrvVpnService : VpnService() { }, "tun2proxy").apply { start() } startForeground(NOTIF_ID, buildNotif(cfg.listenPort)) + + // Publish "running" state for the UI's Connect/Disconnect button + // to observe. Only flipped true once everything above succeeded — + // if we'd flipped it earlier the button would light up green for + // a failed-to-establish run. + VpnState.setRunning(true) } /** @@ -255,6 +317,9 @@ class MhrvVpnService : VpnService() { Log.e(TAG, "Native.stopProxy threw: ${t.message}", t) } } + // Flip UI state last — the button reverts to Connect only after + // the native-side cleanup actually happened, not optimistically. + VpnState.setRunning(false) Log.i(TAG, "teardown: done") } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt b/android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt new file mode 100644 index 0000000..4806acb --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/VpnState.kt @@ -0,0 +1,34 @@ +package com.therealaleph.mhrv + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide observable for "is mhrv-rs's VPN/proxy currently up?" + * + * The activity and the service live in the same process (same UID, same + * ClassLoader), so a plain singleton with a `MutableStateFlow` is the + * shortest path from "service just finished starting" to "button swaps + * to Disconnect". No IPC, no broadcasts, no lifecycle dance. + * + * The service toggles this from its startEverything() / teardown() paths; + * the Compose UI collects it and swaps the Connect/Disconnect button + * label + color accordingly. We intentionally do NOT try to reconstruct + * the flag by querying Android's ConnectivityManager or a service-binding + * check: those race with the service's own teardown and would show + * "Connected" for a half-second after the user tapped Disconnect. + * Trusting the service's own self-report is both simpler and correct. + * + * Process death resets the flag to false, which is also correct — VPN is + * torn down by Android when our process dies, so "not running" is the + * accurate state on the next launch. + */ +object VpnState { + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning.asStateFlow() + + fun setRunning(running: Boolean) { + _isRunning.value = running + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/AppPickerDialog.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/AppPickerDialog.kt new file mode 100644 index 0000000..b4de133 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/AppPickerDialog.kt @@ -0,0 +1,187 @@ +package com.therealaleph.mhrv.ui + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A bottom-sheet-style dialog for picking apps by package name. Used by + * the App-splitting section to seed the allow-list or the deny-list. + * + * Design: + * - Lists every user-installed app (system apps filtered by default — + * they're rarely what you want to single out, and the list would be + * overwhelming without filtering). A "Show system apps" toggle at + * the top brings them back. + * - Search bar filters by label + package name substring. + * - Multi-select via Checkbox per row; a running counter at the top + * reminds the user how many packages are currently selected. + * - Save returns the chosen package-name list; Cancel is a no-op. + * + * Dismissing the dialog (back-press / scrim tap) is treated as Cancel — + * we never silently overwrite the caller's selection with a partial + * in-flight edit. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AppPickerDialog( + initial: Set, + ownPackage: String, + onSave: (List) -> Unit, + onDismiss: () -> Unit, +) { + val ctx = LocalContext.current + + // Load installed-app metadata off the main thread — PackageManager + // queries can be slow on devices with 400+ apps. + var apps by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var showSystem by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + val selected = remember { mutableStateListOf().apply { addAll(initial) } } + + LaunchedEffect(showSystem) { + loading = true + apps = withContext(Dispatchers.IO) { + loadInstalledApps(ctx.packageManager, includeSystem = showSystem, ownPackage = ownPackage) + } + loading = false + } + + val filtered: List = remember(apps, query) { + if (query.isBlank()) apps + else apps.filter { + it.label.contains(query, ignoreCase = true) || + it.packageName.contains(query, ignoreCase = true) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Pick apps (${selected.size} selected)") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search") }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = ImeAction.Search, + ), + modifier = Modifier.fillMaxWidth(), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Checkbox( + checked = showSystem, + onCheckedChange = { showSystem = it }, + ) + Text("Show system apps", style = MaterialTheme.typography.bodySmall) + } + if (loading) { + Box( + modifier = Modifier.fillMaxWidth().height(160.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp, max = 420.dp), + ) { + items(filtered, key = { it.packageName }) { entry -> + AppRow( + entry = entry, + checked = entry.packageName in selected, + onCheck = { now -> + if (now) { + if (entry.packageName !in selected) { + selected.add(entry.packageName) + } + } else { + selected.remove(entry.packageName) + } + }, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(selected.toList()) }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun AppRow(entry: AppEntry, checked: Boolean, onCheck: (Boolean) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + Checkbox(checked = checked, onCheckedChange = onCheck) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(entry.label, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + Text( + entry.packageName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private data class AppEntry(val packageName: String, val label: String) + +private fun loadInstalledApps( + pm: PackageManager, + includeSystem: Boolean, + ownPackage: String, +): List { + // Only apps that have a launcher entry are user-visible — the rest + // are content providers, platform helpers, etc. that the user would + // never want to manually include/exclude. + val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN) + .addCategory(android.content.Intent.CATEGORY_LAUNCHER) + val resolved = pm.queryIntentActivities(mainIntent, 0) + return resolved + .asSequence() + .mapNotNull { it.activityInfo?.applicationInfo } + .filter { info -> + // Our own package is handled by the mandatory self-exclude + // at service-start time; surfacing it in the picker would be + // confusing and a selection would be silently overridden. + if (info.packageName == ownPackage) return@filter false + val isSystem = (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0 && + (info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0 + includeSystem || !isSystem + } + .distinctBy { it.packageName } + .map { info -> + AppEntry( + packageName = info.packageName, + label = pm.getApplicationLabel(info).toString(), + ) + } + .sortedBy { it.label.lowercase() } + .toList() +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 0c14132..9d80960 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -34,7 +34,14 @@ import com.therealaleph.mhrv.ConfigStore import com.therealaleph.mhrv.DEFAULT_SNI_POOL import com.therealaleph.mhrv.MhrvConfig import com.therealaleph.mhrv.Native +import com.therealaleph.mhrv.ConnectionMode import com.therealaleph.mhrv.NetworkDetect +import com.therealaleph.mhrv.R +import com.therealaleph.mhrv.SplitMode +import com.therealaleph.mhrv.UiLang +import com.therealaleph.mhrv.VpnState +import androidx.compose.ui.res.stringResource +import com.therealaleph.mhrv.ui.theme.ErrRed import com.therealaleph.mhrv.ui.theme.OkGreen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -73,6 +80,7 @@ fun HomeScreen( onInstallCaConfirmed: () -> Unit, caOutcome: CaInstallOutcome?, onCaOutcomeConsumed: () -> Unit, + onLangChange: (UiLang) -> Unit = {}, ) { val ctx = LocalContext.current val scope = rememberCoroutineScope() @@ -89,6 +97,30 @@ fun HomeScreen( // CA install dialog visibility. var showInstallDialog by rememberSaveable { mutableStateOf(false) } + // One-shot auto update check on first composition. Silent if we're + // already on the latest (no point nagging about a network miss or an + // up-to-date install); surfaces a snackbar only when a newer tag is + // available. rememberSaveable so it doesn't re-fire on every config + // change / rotation. + var autoUpdateChecked by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(autoUpdateChecked) { + if (autoUpdateChecked) return@LaunchedEffect + autoUpdateChecked = true + val json = withContext(Dispatchers.IO) { + runCatching { Native.checkUpdate() }.getOrNull() + } + if (json != null) { + val obj = runCatching { JSONObject(json) }.getOrNull() + if (obj?.optString("kind") == "updateAvailable") { + snackbar.showSnackbar( + "Update available: v${obj.optString("current")} → " + + "v${obj.optString("latest")} ${obj.optString("url")}", + withDismissAction = true, + ) + } + } + } + // Cooldown on Start/Stop. Rapid taps during a VPN transition trigger // an emulator-specific EGL renderer crash // (F OpenGLRenderer: EGL_NOT_INITIALIZED during rendering) — the @@ -131,10 +163,35 @@ fun HomeScreen( TopAppBar( title = { Text("mhrv-rs") }, actions = { - // Tap the version label to check for updates. Keeps - // the top bar visually quiet (no explicit menu) but - // is discoverable because the cursor-style ripple - // makes it obvious it's interactive. + // Language toggle — cycles AUTO → FA → EN → AUTO. + // Saving writes to config.json and triggers activity + // recreate, which re-applies the AppCompatDelegate + // locale (and flips LTR ↔ RTL accordingly). Kept as + // a small label button instead of an icon because + // "AUTO/FA/EN" communicates the current state at a + // glance; a flag icon alone would be ambiguous. + TextButton( + onClick = { + val next = when (cfg.uiLang) { + UiLang.AUTO -> UiLang.FA + UiLang.FA -> UiLang.EN + UiLang.EN -> UiLang.AUTO + } + persist(cfg.copy(uiLang = next)) + onLangChange(next) + }, + ) { + Text( + text = when (cfg.uiLang) { + UiLang.AUTO -> "AUTO" + UiLang.FA -> "FA" + UiLang.EN -> "EN" + }, + style = MaterialTheme.typography.labelSmall, + ) + } + + // Tap the version label to check for updates. var checking by remember { mutableStateOf(false) } TextButton( onClick = { @@ -152,8 +209,9 @@ fun HomeScreen( modifier = Modifier.padding(end = 4.dp), ) { Text( - text = if (checking) "checking…" - else "v" + runCatching { Native.version() }.getOrDefault("?"), + text = if (checking) stringResource(R.string.tb_check_update_checking) + else stringResource(R.string.tb_version_prefix) + + runCatching { Native.version() }.getOrDefault("?"), style = MaterialTheme.typography.labelMedium, ) } @@ -170,7 +228,7 @@ fun HomeScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionHeader("Apps Script relay") + SectionHeader(stringResource(R.string.sec_apps_script_relay)) DeploymentIdsField( urls = cfg.appsScriptUrls, @@ -180,17 +238,24 @@ fun HomeScreen( OutlinedTextField( value = cfg.authKey, onValueChange = { persist(cfg.copy(authKey = it)) }, - label = { Text("auth_key") }, + label = { Text(stringResource(R.string.field_auth_key)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth(), supportingText = { - Text("The shared secret you set in the Apps Script.") + Text(stringResource(R.string.help_auth_key)) }, ) Spacer(Modifier.height(4.dp)) - SectionHeader("Network") + SectionHeader(stringResource(R.string.sec_network)) + + ConnectionModeDropdown( + mode = cfg.connectionMode, + onChange = { persist(cfg.copy(connectionMode = it)) }, + httpPort = cfg.listenPort, + socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1), + ) Row( modifier = Modifier.fillMaxWidth(), @@ -199,7 +264,7 @@ fun HomeScreen( OutlinedTextField( value = cfg.googleIp, onValueChange = { persist(cfg.copy(googleIp = it)) }, - label = { Text("google_ip") }, + label = { Text(stringResource(R.string.field_google_ip)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.weight(1f), @@ -207,7 +272,7 @@ fun HomeScreen( OutlinedTextField( value = cfg.frontDomain, onValueChange = { persist(cfg.copy(frontDomain = it)) }, - label = { Text("front_domain") }, + label = { Text(stringResource(R.string.field_front_domain)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.weight(1f), @@ -237,23 +302,38 @@ fun HomeScreen( ) { updated = updated.copy(frontDomain = "www.google.com") } + // Captured up-front so the lambda has access + // to the format-string resources via context + // before running on the IO dispatcher. if (updated !== cfg) { persist(updated) - snackbar.showSnackbar("google_ip updated to $fresh") + snackbar.showSnackbar( + ctx.getString(R.string.snack_google_ip_updated, fresh), + ) } else { - snackbar.showSnackbar("google_ip already current ($fresh)") + snackbar.showSnackbar( + ctx.getString(R.string.snack_google_ip_current, fresh), + ) } } else { - snackbar.showSnackbar("DNS lookup failed — check network") + snackbar.showSnackbar(ctx.getString(R.string.snack_dns_lookup_failed)) } } }, modifier = Modifier.align(Alignment.End), - ) { Text("Auto-detect google_ip") } + ) { Text(stringResource(R.string.btn_auto_detect_google_ip)) } + + // App splitting — only makes sense in VPN_TUN mode. + // PROXY_ONLY has no system-level routing to partition. + if (cfg.connectionMode == ConnectionMode.VPN_TUN) { + CollapsibleSection(title = stringResource(R.string.sec_app_splitting)) { + AppSplittingEditor(cfg = cfg, onChange = ::persist) + } + } // SNI pool: collapsed by default. Users without a reason to // touch it should leave Rust's auto-expansion to handle it. - CollapsibleSection(title = "SNI pool + tester") { + CollapsibleSection(title = stringResource(R.string.sec_sni_pool_tester)) { SniPoolEditor( cfg = cfg, onChange = ::persist, @@ -261,7 +341,7 @@ fun HomeScreen( } // Advanced settings: collapsed by default. - CollapsibleSection(title = "Advanced") { + CollapsibleSection(title = stringResource(R.string.sec_advanced)) { AdvancedSettings( cfg = cfg, onChange = ::persist, @@ -270,24 +350,29 @@ fun HomeScreen( Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - // Start flow: (1) auto-resolve google_ip so we - // don't hand the proxy a stale anycast target, - // (2) repair front_domain if it got corrupted into - // an IP (has to be a hostname — that's what goes - // into the TLS SNI on the outbound leg), - // (3) fire the VpnService. All three steps live - // here (rather than in MainActivity) so they go - // through the same persist() used for text edits - // — otherwise the Compose cfg would go stale and - // a subsequent field edit would overwrite our - // fresh values with the pre-resolve ones. - transitionCooldown = true + // Unified Connect/Disconnect button. Color + label track the + // service's real "is it running right now" state (via + // `VpnState.isRunning`), so the UI never shows "Connect" while + // the tunnel is still up or "Disconnect" after the service + // finished tearing down. Two tap paths, one button: + // - running=false → green "Connect" → runs the auto-resolve + // + persist + onStart() sequence we used to hang off the + // old Start button. + // - running=true → red "Disconnect" → fires onStop(). + val isVpnRunning by VpnState.isRunning.collectAsState() + Button( + onClick = { + transitionCooldown = true + if (isVpnRunning) { + onStop() + } else { + // Connect flow: auto-resolve google_ip so we don't + // hand the proxy a stale anycast target; repair + // front_domain if it got corrupted into an IP + // (SNI has to be a hostname); then fire onStart. + // All three steps go through the Compose persist() + // so a subsequent field edit can't overwrite the + // fresh values with pre-resolve ones. scope.launch { val fresh = withContext(Dispatchers.IO) { NetworkDetect.resolveGoogleIp() @@ -296,14 +381,6 @@ fun HomeScreen( if (!fresh.isNullOrBlank() && fresh != updated.googleIp) { updated = updated.copy(googleIp = fresh) } - // Defensive front_domain repair. An IP literal - // here breaks the outbound leg: TLS SNI - // must be a hostname, and the Apps Script - // dispatcher uses front_domain as the SNI - // when rewriting www.google.com-bound TCP - // flows. If the field got corrupted (bad - // paste, previous bug, etc.) reset to the - // safe default. if (updated.frontDomain.isBlank() || updated.frontDomain.parseAsIpOrNull() != null ) { @@ -312,22 +389,27 @@ fun HomeScreen( if (updated !== cfg) persist(updated) onStart() } + } + }, + enabled = (isVpnRunning || + (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown, + colors = ButtonDefaults.buttonColors( + containerColor = if (isVpnRunning) ErrRed else OkGreen, + contentColor = androidx.compose.ui.graphics.Color.White, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + ) { + Text( + when { + transitionCooldown -> "…" + isVpnRunning -> stringResource(R.string.btn_disconnect) + else -> stringResource(R.string.btn_connect) }, - enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown, - modifier = Modifier.weight(1f), - ) { - Text(if (transitionCooldown) "…" else "Start") - } - OutlinedButton( - onClick = { - transitionCooldown = true - onStop() - }, - enabled = !transitionCooldown, - modifier = Modifier.weight(1f), - ) { - Text(if (transitionCooldown) "…" else "Stop") - } + style = MaterialTheme.typography.titleMedium, + ) } Spacer(Modifier.height(4.dp)) @@ -339,15 +421,24 @@ fun HomeScreen( onClick = { showInstallDialog = true }, modifier = Modifier.fillMaxWidth(), ) { - Text("Install MITM certificate") + Text(stringResource(R.string.btn_install_mitm)) } - CollapsibleSection(title = "Live logs", initiallyExpanded = false) { + CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) { LiveLogPane() } Spacer(Modifier.height(16.dp)) - HowToUseCard(cfg.listenPort) + // Wrapped in a collapsible so the big prose block doesn't + // dominate the form after the user has learned the flow. + // Starts expanded once for a fresh install so the first-run + // instructions are immediately visible. + CollapsibleSection( + title = stringResource(R.string.sec_how_to_use), + initiallyExpanded = cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank(), + ) { + HowToUseBody(cfg.listenPort) + } } } @@ -362,7 +453,7 @@ fun HomeScreen( AlertDialog( onDismissRequest = { showInstallDialog = false }, - title = { Text("Install MITM certificate?") }, + title = { Text(stringResource(R.string.dialog_install_mitm_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -414,6 +505,162 @@ fun HomeScreen( } } +// ========================================================================= +// App splitting — ALL / ONLY / EXCEPT, plus a picker for the package list. +// ========================================================================= + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppSplittingEditor( + cfg: MhrvConfig, + onChange: (MhrvConfig) -> Unit, +) { + val ctx = LocalContext.current + var pickerOpen by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.help_app_splitting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Radio-style mode selector. Using Column-of-Row-with-RadioButton + // instead of a dropdown because all three options deserve to be + // visible simultaneously — the labels explain the contract. + SplitModeRow( + label = stringResource(R.string.split_all), + selected = cfg.splitMode == SplitMode.ALL, + onClick = { onChange(cfg.copy(splitMode = SplitMode.ALL)) }, + ) + SplitModeRow( + label = stringResource(R.string.split_only), + selected = cfg.splitMode == SplitMode.ONLY, + onClick = { onChange(cfg.copy(splitMode = SplitMode.ONLY)) }, + ) + SplitModeRow( + label = stringResource(R.string.split_except), + selected = cfg.splitMode == SplitMode.EXCEPT, + onClick = { onChange(cfg.copy(splitMode = SplitMode.EXCEPT)) }, + ) + + if (cfg.splitMode != SplitMode.ALL) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.sni_selected_count, cfg.splitApps.size), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { pickerOpen = true }) { + Text(stringResource(R.string.split_pick_apps)) + } + } + } + } + + if (pickerOpen) { + AppPickerDialog( + initial = cfg.splitApps.toSet(), + ownPackage = ctx.packageName, + onSave = { picked -> + onChange(cfg.copy(splitApps = picked)) + pickerOpen = false + }, + onDismiss = { pickerOpen = false }, + ) + } +} + +@Composable +private fun SplitModeRow(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + RadioButton(selected = selected, onClick = onClick) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} + +// ========================================================================= +// Connection mode — VPN (TUN) vs Proxy-only. +// ========================================================================= + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnectionModeDropdown( + mode: ConnectionMode, + onChange: (ConnectionMode) -> Unit, + httpPort: Int, + socks5Port: Int, +) { + val labelVpn = stringResource(R.string.mode_vpn_tun) + val labelProxy = stringResource(R.string.mode_proxy_only) + val currentLabel = when (mode) { + ConnectionMode.VPN_TUN -> labelVpn + ConnectionMode.PROXY_ONLY -> labelProxy + } + var expanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + value = currentLabel, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.field_connection_mode)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(labelVpn) }, + onClick = { + onChange(ConnectionMode.VPN_TUN) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(labelProxy) }, + onClick = { + onChange(ConnectionMode.PROXY_ONLY) + expanded = false + }, + ) + } + } + + // Helper text under the dropdown explains what the user is + // signing up for in each mode — especially important for + // PROXY_ONLY, where "tap Connect" alone doesn't route anything + // until they set the Wi-Fi proxy themselves. + val help = when (mode) { + ConnectionMode.VPN_TUN -> + stringResource(R.string.help_mode_vpn_tun) + ConnectionMode.PROXY_ONLY -> + stringResource(R.string.help_mode_proxy_only, httpPort, socks5Port) + } + Text( + help, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + // ========================================================================= // Deployment IDs editor (multi-line, one URL/ID per line). // ========================================================================= @@ -434,15 +681,12 @@ private fun DeploymentIdsField( val parsed = it.split("\n").map(String::trim).filter(String::isNotBlank) onChange(parsed) }, - label = { Text("Deployment URL(s) or script ID(s)") }, + label = { Text(stringResource(R.string.field_deployment_urls)) }, modifier = Modifier.fillMaxWidth(), minLines = 2, maxLines = 6, supportingText = { - Text( - "One per line. Full URLs (https://script.google.com/macros/s/.../exec) " + - "or bare IDs — mix as you like. Multiple IDs are rotated round-robin.", - ) + Text(stringResource(R.string.help_deployment_urls)) }, ) } @@ -498,8 +742,7 @@ private fun SniPoolEditor( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - "Enabled SNIs are rotated when connecting to google_ip. Leaving all unchecked " + - "lets Rust auto-expand the default Google pool.", + stringResource(R.string.help_sni_pool), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -533,7 +776,7 @@ private fun SniPoolEditor( OutlinedTextField( value = custom, onValueChange = { custom = it }, - label = { Text("Add custom SNI") }, + label = { Text(stringResource(R.string.field_add_custom_sni)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.weight(1f), @@ -548,13 +791,13 @@ private fun SniPoolEditor( } }, enabled = custom.isNotBlank(), - ) { Text("Add") } + ) { Text(stringResource(R.string.btn_add)) } } TextButton( onClick = { displayed.forEach { probe(it) } }, modifier = Modifier.align(Alignment.End), - ) { Text("Test all") } + ) { Text(stringResource(R.string.btn_test_all)) } } } @@ -580,7 +823,7 @@ private fun SniRow( ProbeBadge(state) Spacer(Modifier.width(4.dp)) TextButton(onClick = onTest, enabled = state !is ProbeState.InFlight) { - Text("Test") + Text(stringResource(R.string.btn_test)) } } // Show the error reason on its own line when the probe failed — @@ -713,9 +956,9 @@ private fun AdvancedSettings( modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.weight(1f)) { - Text("Verify upstream TLS", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.adv_verify_tls), style = MaterialTheme.typography.bodyMedium) Text( - "Off disables cert checks for the Google edge. Only useful for debugging.", + stringResource(R.string.adv_verify_tls_help), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -737,7 +980,7 @@ private fun AdvancedSettings( value = cfg.logLevel, onValueChange = {}, readOnly = true, - label = { Text("log_level") }, + label = { Text(stringResource(R.string.adv_log_level)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.fillMaxWidth().menuAnchor(), ) @@ -760,7 +1003,7 @@ private fun AdvancedSettings( // parallel_relay slider Column { Text( - "parallel_relay: ${cfg.parallelRelay}", + stringResource(R.string.adv_parallel_relay, cfg.parallelRelay), style = MaterialTheme.typography.bodyMedium, ) Slider( @@ -770,7 +1013,7 @@ private fun AdvancedSettings( steps = 3, // yields 1,2,3,4,5 positions ) Text( - "Fan-out per request. 1 is normal; bump to 2-3 on lossy links.", + stringResource(R.string.adv_parallel_relay_help), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -779,12 +1022,12 @@ private fun AdvancedSettings( OutlinedTextField( value = cfg.upstreamSocks5, onValueChange = { onChange(cfg.copy(upstreamSocks5 = it)) }, - label = { Text("upstream_socks5 (optional)") }, + label = { Text(stringResource(R.string.adv_upstream_socks5)) }, placeholder = { Text("host:port") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { - Text("If set, route upstream via this SOCKS5. Leave blank for direct.") + Text(stringResource(R.string.adv_upstream_socks5_help)) }, ) } @@ -906,11 +1149,12 @@ private fun CollapsibleSection( } @Composable -private fun HowToUseCard(listenPort: Int) { - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("How to use", style = MaterialTheme.typography.titleMedium) - Text( +private fun HowToUseBody(listenPort: Int) { + // Used inside the collapsible "How to use" CollapsibleSection. The + // card + title are provided by the section wrapper, so this body + // just renders the body text. + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( "1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n" + "2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to " + "Downloads/mhrv-ca.crt and the Settings app opens. Use Settings' search bar " + @@ -935,8 +1179,7 @@ private fun HowToUseCard(listenPort: Int) { "egress IP — gets re-challenged. Nothing in this app can fix that; it's inherent " + "to Apps Script as a relay. Sites that only gate the initial page load (not every " + "request) will work after one solve.", - style = MaterialTheme.typography.bodyMedium, - ) - } + style = MaterialTheme.typography.bodyMedium, + ) } } diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..117c5d6 --- /dev/null +++ b/android/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,81 @@ + + + mhrv-rs + + + در حال بررسی… + نسخهٔ + + + رلهٔ Apps Script + شبکه + مجموعهٔ SNI + تستر + پیشرفته + لاگ زنده + راهنمای استفاده + تقسیم برنامه‌ها + + + اتصال + قطع + نصب گواهی MITM + تشخیص خودکار google_ip + تست همه + تست + افزودن + پاک + نصب + انصراف + + + آدرس‌(های) Deployment یا Script ID + کلید احراز (auth_key) + google_ip + دامنهٔ فرانت + نوع اتصال + افزودن SNI سفارشی + + + VPN (TUN) — همهٔ برنامه‌ها رد می‌شوند + فقط پروکسی — تنظیم per-app توسط کاربر + + + همهٔ برنامه‌ها + فقط برنامه‌های انتخاب‌شده + همه به‌جز برنامه‌های انتخاب‌شده + انتخاب برنامه‌ها… + + + نصب گواهی MITM؟ + + + تغییر زبان + + + یکی در هر خط. می‌توانید URL کامل (https://script.google.com/macros/s/.../exec) یا فقط ID خام بگذارید — ترکیبی هم قبول است. چند ID به‌صورت چرخشی (round-robin) استفاده می‌شوند. + همان رمز مشترکی که داخل Apps Script گذاشتید. + هنگام اتصال، مجوز VPN سیستم درخواست می‌شود. تمام ترافیک دستگاه به‌صورت خودکار رد می‌شود. + بدون VPN سیستم. بعد از اتصال، پروکسی Wi-Fi را روی 127.0.0.1:%1$d (HTTP) یا %2$d (SOCKS5) تنظیم کنید. فقط برنامه‌هایی که تنظیمات پروکسی را رعایت می‌کنند رد می‌شوند. + SNIهای فعال‌شده هنگام اتصال به google_ip به‌صورت چرخشی استفاده می‌شوند. اگر همه را غیرفعال بگذارید، Rust خودکار مجموعهٔ پیش‌فرض گوگل را باز می‌کند. + انتخاب کنید چه برنامه‌هایی از VPN استفاده کنند. فقط در حالت VPN (TUN) اعمال می‌شود. برنامهٔ خودمان همیشه مستثنی است تا ترافیک خودش دوباره از تونل رد نشود. + + + %1$d برنامه انتخاب شده + + + بررسی TLS طرف مقابل + خاموش کردن، بررسی گواهی را برای لبهٔ گوگل غیرفعال می‌کند. فقط برای اشکال‌زدایی کاربرد دارد. + log_level + parallel_relay: %1$d + تعداد درخواست‌های موازی هر بار. ۱ عادی است؛ روی لینک‌های با افت، ۲-۳ را امتحان کنید. + upstream_socks5 (اختیاری) + اگر تنظیم شود، ترافیک خروجی از این SOCKS5 رد می‌شود. خالی بگذارید برای اتصال مستقیم. + + + %1$d خط + + + google_ip به %1$s به‌روزرسانی شد + google_ip قبلاً به‌روز است (%1$s) + خطای DNS — اتصال شبکه را بررسی کنید + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 1a1a1f6..8b8bb06 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,81 @@ mhrv-rs + + + checking… + v + + + Apps Script relay + Network + SNI pool + tester + Advanced + Live logs + How to use + App splitting + + + Connect + Disconnect + Install MITM certificate + Auto-detect google_ip + Test all + Test + Add + Clear + Install + Cancel + + + Deployment URL(s) or script ID(s) + auth_key + google_ip + front_domain + Connection mode + Add custom SNI + + + VPN (TUN) — routes every app + Proxy only — configure per-app + + + All apps + Only selected apps + All except selected + Pick apps… + + + Install MITM certificate? + + + Switch language + + + One per line. Full URLs (https://script.google.com/macros/s/.../exec) or bare IDs — mix as you like. Multiple IDs are rotated round-robin. + The shared secret you set in the Apps Script. + Requests the OS VPN grant on Connect. All device traffic is routed automatically. + No OS VPN. Set your Wi-Fi proxy to 127.0.0.1:%1$d (HTTP) or %2$d (SOCKS5) after Connect. Only apps that honour the proxy settings will tunnel. + Enabled SNIs are rotated when connecting to google_ip. Leaving all unchecked lets Rust auto-expand the default Google pool. + Choose which apps go through the VPN. Only applies in VPN (TUN) mode. Our own app is always excluded to avoid routing its own traffic back through the tunnel. + + + %1$d app(s) selected + + + Verify upstream TLS + Off disables cert checks for the Google edge. Only useful for debugging. + log_level + parallel_relay: %1$d + Fan-out per request. 1 is normal; bump to 2-3 on lossy links. + upstream_socks5 (optional) + If set, route upstream via this SOCKS5. Leave blank for direct. + + + %1$d lines + + + google_ip updated to %1$s + google_ip already current (%1$s) + DNS lookup failed — check network diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 4d6c251..aa8de55 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -4,8 +4,13 @@ Compose owns the runtime theme. This is only the pre-Compose splash state (while the activity is starting) — set to a plain dark background so you don't see a flash of white before the first frame. + + Parent switched to `Theme.AppCompat.DayNight.NoActionBar` so the + AppCompatActivity subclass can run its attachBaseContext locale + swap. Platform theme inheritance pulls in the same Material dark + colours we had before. --> - diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..3e236b1 --- /dev/null +++ b/android/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/assets/launchers/run.bat b/assets/launchers/run.bat index e8566ce..11748d9 100644 --- a/assets/launchers/run.bat +++ b/assets/launchers/run.bat @@ -43,16 +43,28 @@ if not "%UI_EXIT%"=="0" ( echo --------------------------------------------------- echo UI exited with error code %UI_EXIT%. echo. - echo If this is the first time and you just saw the UI crash immediately, - echo common causes on Windows are: - echo - missing or outdated graphics drivers (try updating) - echo - running inside RDP or a VM without GPU acceleration - echo - antivirus blocking the exe — whitelist the folder and retry + echo If this is the first time and you saw "egui_glow requires opengl 2.0+" + echo or "PainterError" above, your machine doesn't have a usable OpenGL + echo driver. Retrying once with the DirectX/Vulkan backend... echo. - echo Copy everything above and open an issue on: - echo https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues - echo --------------------------------------------------- - pause + set MHRV_RENDERER=wgpu + "%~dp0mhrv-rs-ui.exe" + set UI_EXIT=%ERRORLEVEL% + set MHRV_RENDERER= + if not "%UI_EXIT%"=="0" ( + echo. + echo --------------------------------------------------- + echo UI still failed with error code %UI_EXIT% even with the DX/Vulkan + echo backend. Likely causes: + echo - missing or outdated graphics drivers (try updating) + echo - running inside RDP or a VM without GPU acceleration + echo - antivirus blocking the exe — whitelist the folder and retry + echo. + echo Copy everything above and open an issue on: + echo https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues + echo --------------------------------------------------- + pause + ) ) endlocal diff --git a/releases/README.md b/releases/README.md index 193e710..d96ede6 100644 --- a/releases/README.md +++ b/releases/README.md @@ -2,11 +2,11 @@ This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page. -Current version: **v1.0.2** +Current version: **v1.1.0** | File | Platform | Contents | |---|---|---| -| `mhrv-rs-android-universal-v1.0.2.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file | +| `mhrv-rs-android-universal-v1.1.0.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file | | `mhrv-rs-linux-amd64.tar.gz` | Linux x86_64 | `mhrv-rs`, `mhrv-rs-ui`, `run.sh` | | `mhrv-rs-linux-arm64.tar.gz` | Linux aarch64 | `mhrv-rs`, `run.sh` (CLI only) | | `mhrv-rs-raspbian-armhf.tar.gz` | Raspberry Pi / ARMv7 hardfloat | `mhrv-rs`, `run.sh` (CLI only) | @@ -45,7 +45,7 @@ Extract `mhrv-rs-windows-amd64.zip`, then double-click `run.bat` inside the extr ### Android -Copy `mhrv-rs-android-universal-v1.0.2.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester). +Copy `mhrv-rs-android-universal-v1.1.0.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester). See the [main README](../README.md) for desktop setup (Apps Script deployment, config, browser proxy settings). @@ -55,7 +55,7 @@ See the [main README](../README.md) for desktop setup (Apps Script deployment, c این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند. -نسخهٔ فعلی: **v1.0.2** +نسخهٔ فعلی: **v1.1.0** ### دانلود از طریق ZIP @@ -73,6 +73,6 @@ cd mhrv-rs-macos-arm64 **ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود). -**اندروید:** فایل `mhrv-rs-android-universal-v1.0.2.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست. +**اندروید:** فایل `mhrv-rs-android-universal-v1.1.0.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست. برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید. diff --git a/releases/mhrv-rs-android-universal-v1.0.2.apk b/releases/mhrv-rs-android-universal-v1.1.0.apk similarity index 85% rename from releases/mhrv-rs-android-universal-v1.0.2.apk rename to releases/mhrv-rs-android-universal-v1.1.0.apk index c48a3ce..417022c 100644 Binary files a/releases/mhrv-rs-android-universal-v1.0.2.apk and b/releases/mhrv-rs-android-universal-v1.1.0.apk differ diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 2796232..3d5389f 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -49,11 +49,26 @@ fn main() -> eframe::Result<()> { let (form, load_err) = load_form(); let initial_toast = load_err.map(|e| (e, Instant::now())); + // Pick the renderer. Default is `glow` (OpenGL 2+) because that's + // what we shipped through v1.0.x and it has the least binary-size + // overhead. Users on older Windows boxes / RDP sessions / headless + // VMs that crashed with `egui_glow requires opengl 2.0+` (issue + // #28) can force the wgpu backend — DX12 on Windows, Vulkan on + // Linux, Metal on macOS — by setting the env var: + // + // MHRV_RENDERER=wgpu mhrv-rs-ui + // + // The launcher scripts (run.bat / run.command / run.sh) honour + // the same variable and forward it through. + let use_wgpu = std::env::var("MHRV_RENDERER") + .map(|v| v.eq_ignore_ascii_case("wgpu")) + .unwrap_or(false); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([WIN_WIDTH, WIN_HEIGHT]) .with_min_inner_size([420.0, 400.0]) .with_title(format!("mhrv-rs {}", VERSION)), + renderer: if use_wgpu { eframe::Renderer::Wgpu } else { eframe::Renderer::Glow }, ..Default::default() }; @@ -458,6 +473,15 @@ struct ConfigWire<'a> { sni_hosts: Option>, #[serde(skip_serializing_if = "is_false")] normalize_x_graphql: bool, + // IP-scan knobs. These used to be missing from the wire struct, so + // every Save-config silently dropped them — the user would toggle + // "fetch from API" on, save, reopen, and find it off again. Add + // them here and keep them in sync if Config ever grows more. + #[serde(skip_serializing_if = "is_false")] + fetch_ips_from_api: bool, + max_ips_to_scan: usize, + scan_batch_size: usize, + google_ip_validation: bool, } fn is_false(b: &bool) -> bool { @@ -500,6 +524,10 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { .as_ref() .map(|v| v.iter().map(String::as_str).collect()), normalize_x_graphql: c.normalize_x_graphql, + fetch_ips_from_api: c.fetch_ips_from_api, + max_ips_to_scan: c.max_ips_to_scan, + scan_batch_size: c.scan_batch_size, + google_ip_validation: c.google_ip_validation, } } }