v1.1.0: unified Connect button, proxy mode, app splitting, Persian UI, MIPS build (#41)

Major feature release across Android + desktop. Six items the user
asked for, verified end-to-end on the emulator.

Android
-------
* Unified Connect/Disconnect button. Single large button swaps
  between green "Connect" (when the service is down) and red
  "Disconnect" (when it's up). Tracks the real service state via a
  new process-wide `VpnState` singleton flipped from the service's
  startEverything() / teardown() — not optimistic, the button only
  reports what the service actually did.

* Connection mode dropdown (issue #37). Two options: VPN (TUN) —
  routes every app — and Proxy only — user configures per-app via
  Wi-Fi proxy to 127.0.0.1:8080 (HTTP) / :1081 (SOCKS5). PROXY_ONLY
  skips VpnService.prepare() entirely (no OS VPN grant prompt) and
  the service just keeps the foreground listeners up. Default is
  VPN_TUN so existing behaviour is preserved for users who upgrade
  without looking at the dropdown.

* App splitting. In VPN_TUN mode you can pick All / Only selected /
  All except selected, with a picker dialog that lists installed
  user-visible apps (LazyColumn with search, "show system apps"
  toggle, multi-select checkboxes). ONLY calls
  `Builder.addAllowedApplication()` for each chosen package;
  EXCEPT calls `addDisallowedApplication()` additive to the
  mandatory self-exclude. Requires QUERY_ALL_PACKAGES — added to
  the manifest along with a `<queries>` launcher-intent filter so
  the picker rows can render app labels, not just package strings.

* Persian/English UI toggle with RTL. Top-bar TextButton cycles
  AUTO → FA → EN → AUTO. Persian strings live in
  `res/values-fa/strings.xml`; English in `res/values/strings.xml`.
  `AppCompatDelegate.setApplicationLocales()` is used as the
  persistence layer (plus `AppLocalesMetadataHolderService` meta
  and `locales_config.xml` for the per-app-language OS entry on
  API 33+). MainActivity overrides `attachBaseContext` to wrap the
  context with the right locale at the earliest possible moment —
  otherwise a saved preference wouldn't apply until the SECOND
  process after toggling. RTL swaps automatically because Persian
  is script="Arab" in Android's locale database.

* Collapsible How-to-use card. The big instruction block that used
  to dominate the bottom of the screen now lives inside a
  CollapsibleSection that starts expanded for a fresh install
  (empty deployment URLs / auth_key) and collapsed otherwise.

* Update check auto-fires on first composition, silent-on-up-to-date,
  snackbar-only-if-available. Still surfaces via the version badge
  tap for manual checks.

* MhrvVpnService teardown guard was kept from v1.0.2 —
  `AtomicBoolean` makes the second caller a no-op, which is the
  SIGSEGV fix for "tap Stop, app closes" from before. Stress-tested
  under rapid Connect/Disconnect cycles.

Desktop
-------
* Fix: Advanced section silently resetting on every Save. `ConfigWire`
  was missing `fetch_ips_from_api` / `max_ips_to_scan` /
  `scan_batch_size` / `google_ip_validation` — every persist dropped
  them, every reload fell back to the serde defaults, user saw their
  Advanced toggles reset. Added the fields to the wire struct (issue
  surfaced by the user as "Advanced resets after reopening the app").

* Windows renderer fallback (issue #28). `eframe` is now built with
  BOTH `glow` (OpenGL 2+) and `wgpu` (DX12/Vulkan/Metal); runtime
  defaults to glow for compat but honours `MHRV_RENDERER=wgpu` for
  boxes that crash with "egui_glow requires opengl 2.0+" — old
  Windows hardware, RDP sessions, VMs without GPU acceleration.
  `run.bat` auto-retries the UI with `MHRV_RENDERER=wgpu` if the
  first launch exits non-zero, so users don't need to know about
  the flag.

CI
--
* Added OpenWRT mipsel-softfloat build target (issue #26). MT7621
  routers specifically need soft-float because the CPU has no FPU;
  a hard-float binary segfaults on first fp op. Built via
  `messense/rust-musl-cross:mipsel-musl-softfloat` docker image +
  nightly Rust with `-Z build-std` (mipsel is Rust tier 3 since
  1.72, no pre-built std). Marked `continue-on-error: true` — the
  tier-3 target occasionally regresses and we'd rather ship the
  rest of the release than block on MT7621 support.

Signature / versioning
----------------------
* versionCode 110, versionName 1.1.0; Cargo bumped to 1.1.0.
* Release APK signed with the committed `release.jks` (same as
  v1.0.2), so v1.0.2 → v1.1.0 upgrades install in-place without
  the uninstall-first dance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shin (Former Aleph)
2026-04-23 09:38:10 +03:00
committed by GitHub
parent be698f4928
commit 28be8f67d5
20 changed files with 1624 additions and 144 deletions
+33
View File
@@ -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.
Generated
+498 -31
View File
@@ -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]]
+9 -1
View File
@@ -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 }
+6 -2
View File
@@ -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")
+42
View File
@@ -7,6 +7,29 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Required so the App-splitting picker can enumerate user-visible
apps. Without this on API 30+ (targetSdk 34), PackageManager
returns an empty list for CATEGORY_LAUNCHER queries against other
apps' labels/icons — result: an empty picker dialog. Declaring
it makes Android show a "com.therealaleph.mhrv wants to see which
apps you have installed" note in Play Protect but no runtime
prompt.
-->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!--
App-launcher visibility filter. Complements QUERY_ALL_PACKAGES:
the system uses `<queries>` as the allowlist for metadata reads
(labels, icons) so we can render the picker rows with the app
name the user recognizes, rather than a bare package string.
-->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".MhrvApp"
@@ -15,6 +38,7 @@
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Mhrv"
@@ -50,6 +74,24 @@
android:value="vpn_relay" />
</service>
<!--
AppCompat runtime-locale bootstrap. AppCompatDelegate.setApplicationLocales()
is a no-op on API < 33 without this service declaration — it's how
AppCompat signals to itself that the app opted into the per-app
language API and wants AppCompat to persist the preference across
cold starts. `autoStoreLocales=true` ON older Android makes it
survive process death without us doing our own file I/O.
https://developer.android.com/guide/topics/resources/app-languages
-->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>
@@ -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<String> = 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()
@@ -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()
},
)
}
@@ -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"
}
}
@@ -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")
}
@@ -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<Boolean> = _isRunning.asStateFlow()
fun setRunning(running: Boolean) {
_isRunning.value = running
}
}
@@ -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<String>,
ownPackage: String,
onSave: (List<String>) -> 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<List<AppEntry>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var showSystem by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val selected = remember { mutableStateListOf<String>().apply { addAll(initial) } }
LaunchedEffect(showSystem) {
loading = true
apps = withContext(Dispatchers.IO) {
loadInstalledApps(ctx.packageManager, includeSystem = showSystem, ownPackage = ownPackage)
}
loading = false
}
val filtered: List<AppEntry> = 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<AppEntry> {
// 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()
}
@@ -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,
)
}
}
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">mhrv-rs</string>
<!-- Top-bar -->
<string name="tb_check_update_checking">در حال بررسی…</string>
<string name="tb_version_prefix">نسخهٔ </string>
<!-- Section headers -->
<string name="sec_apps_script_relay">رلهٔ Apps Script</string>
<string name="sec_network">شبکه</string>
<string name="sec_sni_pool_tester">مجموعهٔ SNI + تستر</string>
<string name="sec_advanced">پیشرفته</string>
<string name="sec_live_logs">لاگ زنده</string>
<string name="sec_how_to_use">راهنمای استفاده</string>
<string name="sec_app_splitting">تقسیم برنامه‌ها</string>
<!-- Primary actions -->
<string name="btn_connect">اتصال</string>
<string name="btn_disconnect">قطع</string>
<string name="btn_install_mitm">نصب گواهی MITM</string>
<string name="btn_auto_detect_google_ip">تشخیص خودکار google_ip</string>
<string name="btn_test_all">تست همه</string>
<string name="btn_test">تست</string>
<string name="btn_add">افزودن</string>
<string name="btn_clear">پاک</string>
<string name="btn_install">نصب</string>
<string name="btn_cancel">انصراف</string>
<!-- Field labels -->
<string name="field_deployment_urls">آدرس‌(های) Deployment یا Script ID</string>
<string name="field_auth_key">کلید احراز (auth_key)</string>
<string name="field_google_ip">google_ip</string>
<string name="field_front_domain">دامنهٔ فرانت</string>
<string name="field_connection_mode">نوع اتصال</string>
<string name="field_add_custom_sni">افزودن SNI سفارشی</string>
<!-- Connection mode -->
<string name="mode_vpn_tun">VPN (TUN) — همهٔ برنامه‌ها رد می‌شوند</string>
<string name="mode_proxy_only">فقط پروکسی — تنظیم per-app توسط کاربر</string>
<!-- App splitting -->
<string name="split_all">همهٔ برنامه‌ها</string>
<string name="split_only">فقط برنامه‌های انتخاب‌شده</string>
<string name="split_except">همه به‌جز برنامه‌های انتخاب‌شده</string>
<string name="split_pick_apps">انتخاب برنامه‌ها…</string>
<!-- Install dialog title -->
<string name="dialog_install_mitm_title">نصب گواهی MITM؟</string>
<!-- Language toggle -->
<string name="lang_toggle_cd">تغییر زبان</string>
<!-- Supporting / helper text -->
<string name="help_deployment_urls">یکی در هر خط. می‌توانید URL کامل (https://script.google.com/macros/s/.../exec) یا فقط ID خام بگذارید — ترکیبی هم قبول است. چند ID به‌صورت چرخشی (round-robin) استفاده می‌شوند.</string>
<string name="help_auth_key">همان رمز مشترکی که داخل Apps Script گذاشتید.</string>
<string name="help_mode_vpn_tun">هنگام اتصال، مجوز VPN سیستم درخواست می‌شود. تمام ترافیک دستگاه به‌صورت خودکار رد می‌شود.</string>
<string name="help_mode_proxy_only">بدون VPN سیستم. بعد از اتصال، پروکسی Wi-Fi را روی 127.0.0.1:%1$d (HTTP) یا %2$d (SOCKS5) تنظیم کنید. فقط برنامه‌هایی که تنظیمات پروکسی را رعایت می‌کنند رد می‌شوند.</string>
<string name="help_sni_pool">SNIهای فعال‌شده هنگام اتصال به google_ip به‌صورت چرخشی استفاده می‌شوند. اگر همه را غیرفعال بگذارید، Rust خودکار مجموعهٔ پیش‌فرض گوگل را باز می‌کند.</string>
<string name="help_app_splitting">انتخاب کنید چه برنامه‌هایی از VPN استفاده کنند. فقط در حالت VPN (TUN) اعمال می‌شود. برنامهٔ خودمان همیشه مستثنی است تا ترافیک خودش دوباره از تونل رد نشود.</string>
<!-- SNI pool extras -->
<string name="sni_selected_count">%1$d برنامه انتخاب شده</string>
<!-- Advanced section -->
<string name="adv_verify_tls">بررسی TLS طرف مقابل</string>
<string name="adv_verify_tls_help">خاموش کردن، بررسی گواهی را برای لبهٔ گوگل غیرفعال می‌کند. فقط برای اشکال‌زدایی کاربرد دارد.</string>
<string name="adv_log_level">log_level</string>
<string name="adv_parallel_relay">parallel_relay: %1$d</string>
<string name="adv_parallel_relay_help">تعداد درخواست‌های موازی هر بار. ۱ عادی است؛ روی لینک‌های با افت، ۲-۳ را امتحان کنید.</string>
<string name="adv_upstream_socks5">upstream_socks5 (اختیاری)</string>
<string name="adv_upstream_socks5_help">اگر تنظیم شود، ترافیک خروجی از این SOCKS5 رد می‌شود. خالی بگذارید برای اتصال مستقیم.</string>
<!-- Live logs -->
<string name="logs_lines_count">%1$d خط</string>
<!-- Snackbar -->
<string name="snack_google_ip_updated">google_ip به %1$s به‌روزرسانی شد</string>
<string name="snack_google_ip_current">google_ip قبلاً به‌روز است (%1$s)</string>
<string name="snack_dns_lookup_failed">خطای DNS — اتصال شبکه را بررسی کنید</string>
</resources>
@@ -1,4 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">mhrv-rs</string>
<!-- Top-bar -->
<string name="tb_check_update_checking">checking…</string>
<string name="tb_version_prefix">v</string>
<!-- Section headers -->
<string name="sec_apps_script_relay">Apps Script relay</string>
<string name="sec_network">Network</string>
<string name="sec_sni_pool_tester">SNI pool + tester</string>
<string name="sec_advanced">Advanced</string>
<string name="sec_live_logs">Live logs</string>
<string name="sec_how_to_use">How to use</string>
<string name="sec_app_splitting">App splitting</string>
<!-- Primary actions -->
<string name="btn_connect">Connect</string>
<string name="btn_disconnect">Disconnect</string>
<string name="btn_install_mitm">Install MITM certificate</string>
<string name="btn_auto_detect_google_ip">Auto-detect google_ip</string>
<string name="btn_test_all">Test all</string>
<string name="btn_test">Test</string>
<string name="btn_add">Add</string>
<string name="btn_clear">Clear</string>
<string name="btn_install">Install</string>
<string name="btn_cancel">Cancel</string>
<!-- Field labels -->
<string name="field_deployment_urls">Deployment URL(s) or script ID(s)</string>
<string name="field_auth_key">auth_key</string>
<string name="field_google_ip">google_ip</string>
<string name="field_front_domain">front_domain</string>
<string name="field_connection_mode">Connection mode</string>
<string name="field_add_custom_sni">Add custom SNI</string>
<!-- Connection mode -->
<string name="mode_vpn_tun">VPN (TUN) — routes every app</string>
<string name="mode_proxy_only">Proxy only — configure per-app</string>
<!-- App splitting -->
<string name="split_all">All apps</string>
<string name="split_only">Only selected apps</string>
<string name="split_except">All except selected</string>
<string name="split_pick_apps">Pick apps…</string>
<!-- Install dialog title -->
<string name="dialog_install_mitm_title">Install MITM certificate?</string>
<!-- Language toggle -->
<string name="lang_toggle_cd">Switch language</string>
<!-- Supporting / helper text -->
<string name="help_deployment_urls">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.</string>
<string name="help_auth_key">The shared secret you set in the Apps Script.</string>
<string name="help_mode_vpn_tun">Requests the OS VPN grant on Connect. All device traffic is routed automatically.</string>
<string name="help_mode_proxy_only">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.</string>
<string name="help_sni_pool">Enabled SNIs are rotated when connecting to google_ip. Leaving all unchecked lets Rust auto-expand the default Google pool.</string>
<string name="help_app_splitting">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.</string>
<!-- SNI pool extras -->
<string name="sni_selected_count">%1$d app(s) selected</string>
<!-- Advanced section -->
<string name="adv_verify_tls">Verify upstream TLS</string>
<string name="adv_verify_tls_help">Off disables cert checks for the Google edge. Only useful for debugging.</string>
<string name="adv_log_level">log_level</string>
<string name="adv_parallel_relay">parallel_relay: %1$d</string>
<string name="adv_parallel_relay_help">Fan-out per request. 1 is normal; bump to 2-3 on lossy links.</string>
<string name="adv_upstream_socks5">upstream_socks5 (optional)</string>
<string name="adv_upstream_socks5_help">If set, route upstream via this SOCKS5. Leave blank for direct.</string>
<!-- Live logs -->
<string name="logs_lines_count">%1$d lines</string>
<!-- Snackbar -->
<string name="snack_google_ip_updated">google_ip updated to %1$s</string>
<string name="snack_google_ip_current">google_ip already current (%1$s)</string>
<string name="snack_dns_lookup_failed">DNS lookup failed — check network</string>
</resources>
+6 -1
View File
@@ -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.
-->
<style name="Theme.Mhrv" parent="android:Theme.Material.NoActionBar">
<style name="Theme.Mhrv" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
</style>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Declares the locales this app ships translations for. On API 33+ this
file is what enables Android's per-app language Settings entry and
what AppCompatDelegate.setApplicationLocales() reconciles against;
locales NOT listed here are silently dropped from the UI preference.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="fa" />
</locale-config>
+21 -9
View File
@@ -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
+5 -5
View File
@@ -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) مراجعه کنید.
+28
View File
@@ -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<Vec<&'a str>>,
#[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,
}
}
}