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>
Version bump reflects the scope — a unified Rust core that now ships
for desktop (Linux/macOS/Windows) AND Android from the same crate.
Android changes:
- build.gradle.kts: ABI filters expanded to arm64-v8a + armeabi-v7a
+ x86_64 + x86. cargoBuild{Debug,Release} pass all four ABIs to
cargo-ndk in a single invocation. normalizeTun2proxySo() walks every
ABI dir now (was arm64-only).
- Release buildType signs with the debug keystore — no Play Store
target, so signature identity doesn't matter, installability does.
Gradle auto-provisions ~/.android/debug.keystore if absent, so CI
runners inherit this without extra setup.
- versionName 1.0.0, versionCode 100 (room to bump monotonically).
CI:
- release.yml gets a dedicated `android:` job that sets up JDK 17,
Android SDK/NDK 26, all four rust-android targets, installs
cargo-ndk, runs assembleRelease, and uploads a single universal APK
named `mhrv-rs-android-universal-v<version>.apk` into the same
`dist/` collected by the release job downstream.
- `release:` job now gates on `needs: [build, android]` so tagging
v1.0.0 triggers both build matrices before cutting the GitHub
release.
Docs:
- docs/android.md — full 10-step install walk-through: APK sideload,
Apps Script deployment (with "Advanced → Go to (unsafe) → Allow"
reality check), config paste, SNI reachability test, MITM CA
install with OEM-specific nav paths (Pixel / Samsung / Xiaomi),
Start, troubleshooting common failure modes. Also documents the
known limitations — Cloudflare Turnstile loops (inherent to the
Apps Script egress IP pool), UDP/QUIC not tunnelled, IPv6 leaks,
Apps Script daily quota — so users know what to expect before
trying it on a site that won't work.
- releases/README.md — APK row added to the English and Persian
tables, version bumped everywhere to v1.0.0.
- Top-level README — Android listed under Platforms with a link
to docs/android.md.
Release artifact:
- releases/mhrv-rs-android-universal-v1.0.0.apk — 38 MB universal
APK built locally from this tree. Installs + launches on API 24+.
The CI job will regenerate it on tag push; this is the copy
committed for users who can't reach GitHub Releases.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
=== PR #14 follow-up: armhf build runs on Pi Bookworm/Bullseye ===
PR #14 (merged earlier) added arm-unknown-linux-gnueabihf to the
release matrix but pinned os=ubuntu-latest, which is 24.04 with GLIBC
2.39. Target armhf sysroot on 24.04 is Debian Trixie (GLIBC 2.39),
far too new for a Raspberry Pi 2/3 on Bookworm (2.36) or Bullseye
(2.31) — users would get 'GLIBC_2.39 not found' the same way the
Linux-amd64 issue #2 folks did before we pinned them to 22.04.
Fix: pin the armhf matrix entry to ubuntu-22.04, matching our other
linux-gnu targets. Binary will link against GLIBC 2.35 max, which
loads on Pi Bookworm and Bullseye. Also trimmed two trailing spaces.
Locally verified the cross-compile: rust:latest + gcc-arm-linux-
gnueabihf + proper CARGO_HOME config.toml produces a valid ARM 32-bit
ELF (2.9 MB, armhf EABI5).
=== Issue #15: 'Check for updates' button in the UI ===
New src/update_check.rs module. On the user's click (no polling):
1. Tcp-probes github.com:443 with a 5s budget. If unreachable, we
return Offline(reason) instead of a confusing 'update check
failed' — distinguishes 'you're offline' from 'GitHub API
misbehaved'.
2. HTTPS GET api.github.com/repos/.../releases/latest via the
tokio + rustls stack (same hand-rolled HTTP pattern as
domain_fronter — no new crate deps). Parses tag_name, strips
the v-prefix, loose-semver-compares to env!(CARGO_PKG_VERSION).
3. Returns one of four UpdateCheck variants: Offline / Error /
UpToDate / UpdateAvailable { release_url }.
New UI wiring (src/bin/ui.rs):
- Cmd::CheckUpdate enqueue variant
- UiState::last_update_check { InFlight, Done(result) }
- 'Check for updates' button next to the CA buttons
- Result displayed as a colored small-text line under the CA info:
green 'up to date', amber 'update available v0.8.5 → v0.8.6' with
a clickable release-page hyperlink, red for offline/error.
Verified end-to-end with a live github.com fetch (got a rate-limit
HTTP 403 from my IP because I've been hitting the API a lot, but
that's the expected Error() state — response classification was
correct). Three unit tests for is_newer() and a gated live test for
the full round-trip.
43 tests pass.
Two user-reported issues.
=== GLIBC too new (reported via twitter) ===
Our linux-amd64 and linux-arm64 gnu builds were compiled on
ubuntu-latest (24.04, GLIBC 2.39), which means the resulting binaries
refuse to load on anything older:
./mhrv-rs: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.39'
not found (required by ./mhrv-rs)
Users on Ubuntu 22.04 / Mint 21 (GLIBC 2.35) — the typical user in Iran
where this project's target audience lives, and where they can't
dist-upgrade because they're behind exactly the kind of network
restriction this tool exists to bypass — could not run the gnu builds
at all.
Fix: pin the linux-gnu matrix entries to ubuntu-22.04 runners. GLIBC
2.35 is now the minimum; binaries load on Ubuntu 22.04, Mint 21,
Debian 12, Fedora 36+, RHEL 9+ and everything newer.
Users on older distros (Ubuntu 20.04, CentOS 7) can still use the
static musl builds (mhrv-rs-linux-musl-amd64.tar.gz et al.) which
have no GLIBC dependency at all.
=== Short-screen laptops — main window content clipped (PR #6) ===
Co-authored fix from @v4g4b0nd-0x76 in PR #6 (manually applied to
avoid pulling in 400 lines of unrelated cargo-fmt churn):
- Wrap the CentralPanel body in ScrollArea::vertical()
.auto_shrink([false; 2]) so everything stays reachable on short
screens.
- Lower the min_inner_size from [420, 540] to [420, 400] so laptops
with ~13" screens at default scaling can shrink the window without
clipping UI elements.
Closes#6.
Co-authored-by: v4g4b0nd-0x76 <v4g4b0nd-0x76@users.noreply.github.com>
The messense/rust-musl-cross images expect to run as root so cargo can
write to /root/.cargo. Overriding the container user to match the host
UID broke cargo's registry cache with 'Permission denied' before a
single file compiled. Drop the flag and chown the target/ tree after
the build instead.
A user on OpenWRT x86_64 reported the linux release doesn't run there —
root cause was glibc vs musl mismatch (our gnu binary was looking for a
dynamic linker that doesn't exist on router userlands). Add two musl
targets that produce fully static PIE binaries:
- x86_64-unknown-linux-musl -> mhrv-rs-linux-musl-amd64.tar.gz
- aarch64-unknown-linux-musl -> mhrv-rs-linux-musl-arm64.tar.gz
CI uses the messense/rust-musl-cross docker images (better-maintained
than cargo-zigbuild with a pinned zig, which has version regressions
on the ar wrapper between 0.13 and 0.16).
Locally verified:
- both archs cross-compile green in docker
- resulting x86_64 binary (3.3 MB) runs in an alpine:latest container,
--version / --help work, no dynamic lib requirements
The musl archive skips the UI (routers are headless) and swaps run.sh
for a procd init script (assets/openwrt/mhrv-rs.init) expecting the
binary at /usr/bin/mhrv-rs and config at /etc/mhrv-rs/config.json.
Side effect: switched tokio-rustls to default-features=false + ring
(was pulling aws-lc-rs transitively, which can't easily cross-compile
for musl). The main crate already uses ring explicitly, so no runtime
behavior change.
README gets a 'Running on OpenWRT (or any musl distro)' section in
both English and Persian with scp + procd enable/start recipe.
Closes#2.
First run needs the CLI to install the MITM CA into the system trust
store (sudo/admin prompt), which the UI alone can't do reliably from a
double-click. Add a small launcher for each platform that runs the CLI
with --install-cert once, then starts the UI. Each release archive now
contains a run.* script alongside the binaries.
The arm64 multiarch apt setup was failing because the Azure mirror on
GitHub runners only serves amd64. Since we don't build the UI for
aarch64-linux (server target), we only need the cross-compiler itself,
which is amd64-native. Drop the arm64 system libs and the best-effort
UI build step.
New bin 'mhrv-rs-ui' behind the 'ui' feature flag. CLI users pay
zero egui compile cost; UI users get a single static binary.
UI features:
- Config form (Apps Script ID, auth key, Google IP, front domain,
ports, log level, verify_ssl)
- Start/Stop buttons that spawn the proxy on a dedicated tokio thread
- Live stats (relay calls, failures, cache hit rate, bytes relayed,
blacklisted scripts) polled every ~700ms
- Test button (end-to-end relay probe)
- Install CA / Check CA buttons
- Recent log panel (last 200 lines)
- Dense, dark, utility-look: no emojis, no cards, no gradients
Architecture:
- Refactored crate into lib + two bins (mhrv-rs, mhrv-rs-ui).
src/lib.rs exposes all modules, main.rs uses them via 'use mhrv_rs::...'
- New src/data_dir.rs: platform-appropriate user data dir
(~/Library/Application Support/mhrv-rs on macOS,
~/.config/mhrv-rs on Linux, %APPDATA%\mhrv-rs on Windows).
CLI falls back to ./config.json for backward compat.
- CA moves to {data_dir}/ca/ca.crt (was ./ca/ca.crt).
- UI background thread owns the tokio runtime and proxy handle;
communicates with UI via std::mpsc commands + Arc<Mutex<UiState>>.
- macOS .app bundle: assets/macos/Info.plist template + build-app.sh
that assembles .app from the binary. Bundled into release zips.
- CI: Linux system libs (libxkbcommon, libwayland, libxcb*, libx11,
libgl, libgtk-3) installed on Ubuntu runners for eframe. aarch64
Linux UI is best-effort cross-compile. Windows MinGW, macOS native.
25 lib tests still pass. 5MB release UI binary on macOS.