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,
}
}
}