mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 07:34:36 +03:00
v1.0.1: auto-resolve google_ip, robust Stop, Check-for-updates, front_domain repair (#31)
Three reported issues from v1.0.0 — one real bug, two UX gaps. google_ip auto-resolve (THE FIX) -------------------------------- Google rotates the A record for www.google.com across their anycast pool. A hardcoded default IP breaks new installs on any network that isn't geo-homed to the same edge — symptom is "all SNIs time out" even with a fresh deployment. On Start and via a new "Auto-detect" button, we now do a JVM-side InetAddress lookup BEFORE establishing the VPN (so the resolver uses the underlying network, not our own Virtual-DNS TUN — avoids a loop), update the config, and continue. The auto-resolve lives in the HomeScreen click handler (not MainActivity) so it goes through the same `persist(cfg)` the text fields use. Previous iteration did `ConfigStore.load → modify → save` directly to disk, which left Compose's in-memory cfg stale and a subsequent field edit would overwrite the fresh IP. One source of truth now. Also defensively repairs front_domain: if it's been corrupted into an IP literal (bad paste, whatever) we restore "www.google.com" — the TLS SNI on the outbound leg has to be a hostname or the handshake lands on the wrong vhost. Robust Stop ----------- The Stop button now dispatches both ACTION_STOP (graceful: runs teardown, stops tun2proxy, closes TUN fd, shuts down Rust runtime) AND stopService() (defensive: covers force-closed-then-reopened zombie state where Android auto-restarted our START_STICKY service in a fresh process and the in-memory TUN reference is gone). Check-for-updates ----------------- Tapping the version badge in the top bar now runs the same update_check that the desktop UI uses, via a new `Native.checkUpdate()` JNI entry point. Returns a JSON blob the Kotlin side parses into an "Up to date", "Update available: v→v <url>", "Offline: ...", or "Check failed: ..." snackbar. Mirrors the desktop's behavior so a user doesn't have to manually poll GitHub for new builds. Crash visibility ---------------- New MhrvApp.kt registers a process-wide uncaught exception handler. Crashes are now stamped into logcat under the `mhrv-crash` tag with the thread name before the default handler kills the process — previously the JVM crash in coroutines / the log drain / the tun2proxy worker was invisible unless you caught the dropoff in real time. Version bump: 1.0.0 → 1.0.1 (versionCode 100 → 101). Release APK rebuilt and replaces the 1.0.0 copy in releases/; CI will regenerate on the v1.0.1 tag push. 🤖 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:
committed by
GitHub
parent
91015b0594
commit
b734f41faa
Generated
+1
-1
@@ -1960,7 +1960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.therealaleph.mhrv"
|
applicationId = "com.therealaleph.mhrv"
|
||||||
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 100
|
versionCode = 101
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.1"
|
||||||
|
|
||||||
// Ship all four mainstream Android ABIs:
|
// Ship all four mainstream Android ABIs:
|
||||||
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".MhrvApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
|
// MainActivity's onStart is intentionally dumb: it only
|
||||||
|
// launches the VpnService. The auto-resolve that used to
|
||||||
|
// live here ran load-modify-save directly on disk, which
|
||||||
|
// left HomeScreen's in-memory Compose `cfg` stale — a
|
||||||
|
// subsequent UI edit would then persist the stale cfg back
|
||||||
|
// over the fresh IP we just wrote. HomeScreen now owns the
|
||||||
|
// auto-resolve (it uses the same persist() flow the UI uses
|
||||||
|
// for text-field edits, so there's one source of truth).
|
||||||
onStart = {
|
onStart = {
|
||||||
val prepareIntent = VpnService.prepare(this)
|
val prepareIntent = VpnService.prepare(this)
|
||||||
if (prepareIntent == null) {
|
if (prepareIntent == null) {
|
||||||
@@ -100,9 +108,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStop = {
|
onStop = {
|
||||||
val i = Intent(this, MhrvVpnService::class.java)
|
// Three-step teardown. Each step is defensive against a
|
||||||
|
// different failure mode we've actually hit in testing:
|
||||||
|
//
|
||||||
|
// 1. ACTION_STOP — graceful path. The service receives it,
|
||||||
|
// runs its teardown (stops tun2proxy, closes the TUN
|
||||||
|
// fd, shuts down the Rust runtime) and stopSelf()'s.
|
||||||
|
// This is what we want 99% of the time.
|
||||||
|
//
|
||||||
|
// 2. stopService() — covers the "force-closed then
|
||||||
|
// reopened" zombie case. Android may auto-restart our
|
||||||
|
// START_STICKY service in a fresh process after the
|
||||||
|
// user swipes us away from Recents, and the user's
|
||||||
|
// next Stop tap needs to actually unbind even if our
|
||||||
|
// in-memory TUN fd reference is gone. stopService is
|
||||||
|
// idempotent so it's safe to follow the graceful path.
|
||||||
|
//
|
||||||
|
// 3. We do NOT touch the VpnService permission — that's
|
||||||
|
// the OS-wide VPN grant and the user approved it
|
||||||
|
// deliberately. Revoking it would force a re-prompt
|
||||||
|
// on next Start, which is worse UX.
|
||||||
|
val stopAction = Intent(this, MhrvVpnService::class.java)
|
||||||
.setAction(MhrvVpnService.ACTION_STOP)
|
.setAction(MhrvVpnService.ACTION_STOP)
|
||||||
startService(i)
|
startService(stopAction)
|
||||||
|
stopService(Intent(this, MhrvVpnService::class.java))
|
||||||
},
|
},
|
||||||
onInstallCaConfirmed = {
|
onInstallCaConfirmed = {
|
||||||
// The flow is (1) export cert, (2) copy it to Downloads so
|
// The flow is (1) export cert, (2) copy it to Downloads so
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.therealaleph.mhrv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-level setup. The only job here right now is to catch
|
||||||
|
* uncaught JVM exceptions and route them through logcat under the
|
||||||
|
* `mhrv-crash` tag BEFORE the process dies. Without this the crashes
|
||||||
|
* appear as opaque "App closed unexpectedly" with no line number in
|
||||||
|
* `adb logcat` — we re-raise the exception afterwards so the default
|
||||||
|
* handler still prints its stack trace and Android still shows the
|
||||||
|
* dialog, but at least the chain-of-events is searchable.
|
||||||
|
*
|
||||||
|
* Registering the handler in `Application.onCreate()` (rather than
|
||||||
|
* `Activity.onCreate()`) catches crashes on ALL process threads,
|
||||||
|
* including the tun2proxy worker and the log-drain coroutine —
|
||||||
|
* important because those don't have an activity in scope.
|
||||||
|
*/
|
||||||
|
class MhrvApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
Log.e(
|
||||||
|
CRASH_TAG,
|
||||||
|
"uncaught on thread=${thread.name} (id=${thread.id}): ${throwable.message}",
|
||||||
|
throwable,
|
||||||
|
)
|
||||||
|
// Let the default handler still terminate the process and
|
||||||
|
// show the system "app closed" dialog — we just wanted to
|
||||||
|
// get a log line out the door first.
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CRASH_TAG = "mhrv-crash"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,4 +65,17 @@ object Native {
|
|||||||
* BLOCKS (does a TLS handshake); call from a background dispatcher.
|
* BLOCKS (does a TLS handshake); call from a background dispatcher.
|
||||||
*/
|
*/
|
||||||
external fun testSni(googleIp: String, sni: String): String
|
external fun testSni(googleIp: String, sni: String): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask GitHub's Releases API whether a newer version of mhrv-rs is
|
||||||
|
* out. Returns a JSON blob, one of:
|
||||||
|
* - `{"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}`
|
||||||
|
* - `{"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}`
|
||||||
|
* - `{"kind":"offline","reason":"..."}`
|
||||||
|
* - `{"kind":"error","reason":"..."}`
|
||||||
|
*
|
||||||
|
* BLOCKS (HTTPS round-trip); call from a background dispatcher.
|
||||||
|
* Same check the desktop UI runs — same result format.
|
||||||
|
*/
|
||||||
|
external fun checkUpdate(): String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.therealaleph.mhrv
|
||||||
|
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for figuring out which IP to actually connect to when the user
|
||||||
|
* left the config on a stale `google_ip`.
|
||||||
|
*
|
||||||
|
* Google rotates the A record for `www.google.com` across their global
|
||||||
|
* anycast pool; an IP that answered a year ago often 100% packet-drops
|
||||||
|
* today from networks that are geo-homed somewhere else. Hardcoding any
|
||||||
|
* single value in the config breaks new installs on all but one region.
|
||||||
|
*
|
||||||
|
* At Start time we ask Android's resolver for the current A record and
|
||||||
|
* use that, falling back to whatever the user had configured only if the
|
||||||
|
* resolver itself fails (no connectivity, DNS blocked, etc.). We
|
||||||
|
* deliberately do this on the Kotlin side rather than inside the proxy:
|
||||||
|
* - It happens before we open the VPN TUN — so the resolver uses the
|
||||||
|
* underlying network, not our own VPN's Virtual DNS (which would
|
||||||
|
* loop).
|
||||||
|
* - The resolved IP gets persisted into `config.json`, so the next
|
||||||
|
* launch has a warm value even before auto-detection re-runs.
|
||||||
|
*/
|
||||||
|
object NetworkDetect {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `www.google.com` and return the first IPv4 A record as a
|
||||||
|
* dotted-quad string, or null if resolution failed. IPv6 is skipped —
|
||||||
|
* the outbound leg of our proxy is IPv4-only for now.
|
||||||
|
*
|
||||||
|
* BLOCKING — call from a background coroutine (Dispatchers.IO).
|
||||||
|
*/
|
||||||
|
fun resolveGoogleIp(hostname: String = "www.google.com"): String? {
|
||||||
|
return try {
|
||||||
|
InetAddress.getAllByName(hostname)
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.firstOrNull()
|
||||||
|
?.hostAddress
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import com.therealaleph.mhrv.ConfigStore
|
|||||||
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
|
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
|
||||||
import com.therealaleph.mhrv.MhrvConfig
|
import com.therealaleph.mhrv.MhrvConfig
|
||||||
import com.therealaleph.mhrv.Native
|
import com.therealaleph.mhrv.Native
|
||||||
|
import com.therealaleph.mhrv.NetworkDetect
|
||||||
import com.therealaleph.mhrv.ui.theme.OkGreen
|
import com.therealaleph.mhrv.ui.theme.OkGreen
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -130,11 +131,32 @@ fun HomeScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("mhrv-rs") },
|
title = { Text("mhrv-rs") },
|
||||||
actions = {
|
actions = {
|
||||||
Text(
|
// Tap the version label to check for updates. Keeps
|
||||||
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
|
// the top bar visually quiet (no explicit menu) but
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// is discoverable because the cursor-style ripple
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
// makes it obvious it's interactive.
|
||||||
)
|
var checking by remember { mutableStateOf(false) }
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (checking) return@TextButton
|
||||||
|
checking = true
|
||||||
|
scope.launch {
|
||||||
|
val json = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { Native.checkUpdate() }.getOrNull()
|
||||||
|
}
|
||||||
|
val msg = summarizeUpdateCheck(json)
|
||||||
|
snackbar.showSnackbar(msg, withDismissAction = true)
|
||||||
|
checking = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (checking) "checking…"
|
||||||
|
else "v" + runCatching { Native.version() }.getOrDefault("?"),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -191,6 +213,43 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// "Auto-detect" forces a fresh DNS resolution now. Start also
|
||||||
|
// auto-resolves transparently, but exposing a button makes the
|
||||||
|
// "I'm getting connect timeouts, is my google_ip stale?" case
|
||||||
|
// a one-tap fix without needing to look up nslookup output.
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
NetworkDetect.resolveGoogleIp()
|
||||||
|
}
|
||||||
|
if (!fresh.isNullOrBlank()) {
|
||||||
|
var updated = cfg
|
||||||
|
if (fresh != updated.googleIp) {
|
||||||
|
updated = updated.copy(googleIp = fresh)
|
||||||
|
}
|
||||||
|
// Same repair logic as the Start button —
|
||||||
|
// if front_domain has been corrupted into an
|
||||||
|
// IP we can't use it for SNI, so put the
|
||||||
|
// default hostname back.
|
||||||
|
if (updated.frontDomain.isBlank() ||
|
||||||
|
updated.frontDomain.parseAsIpOrNull() != null
|
||||||
|
) {
|
||||||
|
updated = updated.copy(frontDomain = "www.google.com")
|
||||||
|
}
|
||||||
|
if (updated !== cfg) {
|
||||||
|
persist(updated)
|
||||||
|
snackbar.showSnackbar("google_ip updated to $fresh")
|
||||||
|
} else {
|
||||||
|
snackbar.showSnackbar("google_ip already current ($fresh)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snackbar.showSnackbar("DNS lookup failed — check network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) { Text("Auto-detect google_ip") }
|
||||||
|
|
||||||
// SNI pool: collapsed by default. Users without a reason to
|
// SNI pool: collapsed by default. Users without a reason to
|
||||||
// touch it should leave Rust's auto-expansion to handle it.
|
// touch it should leave Rust's auto-expansion to handle it.
|
||||||
@@ -217,8 +276,42 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
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
|
transitionCooldown = true
|
||||||
onStart()
|
scope.launch {
|
||||||
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
NetworkDetect.resolveGoogleIp()
|
||||||
|
}
|
||||||
|
var updated = cfg
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
updated = updated.copy(frontDomain = "www.google.com")
|
||||||
|
}
|
||||||
|
if (updated !== cfg) persist(updated)
|
||||||
|
onStart()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
|
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -536,6 +629,57 @@ private fun ProbeBadge(state: ProbeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the JSON blob from `Native.checkUpdate()` into a one-line
|
||||||
|
* snackbar message. Parsing is lenient — if the shape is anything other
|
||||||
|
* than what we expect we fall back to "check failed" rather than
|
||||||
|
* spewing the raw JSON at the user.
|
||||||
|
*/
|
||||||
|
private fun summarizeUpdateCheck(json: String?): String {
|
||||||
|
if (json.isNullOrBlank()) return "Update check failed (no response)"
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
when (obj.optString("kind")) {
|
||||||
|
"upToDate" -> "Up to date (running v${obj.optString("current")})"
|
||||||
|
"updateAvailable" -> {
|
||||||
|
val cur = obj.optString("current")
|
||||||
|
val latest = obj.optString("latest")
|
||||||
|
val url = obj.optString("url")
|
||||||
|
"Update available: v$cur → v$latest $url"
|
||||||
|
}
|
||||||
|
"offline" -> "Offline: ${obj.optString("reason", "no details")}"
|
||||||
|
"error" -> "Check failed: ${obj.optString("reason", "no details")}"
|
||||||
|
else -> "Check failed (unknown response)"
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"Check failed (bad json)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse a string as an IPv4 or IPv6 literal. Returns null if it
|
||||||
|
* looks like a hostname (or bogus) — which is what we want for
|
||||||
|
* front_domain, where a hostname is required (goes into the TLS SNI on
|
||||||
|
* the outbound leg).
|
||||||
|
*
|
||||||
|
* Intentionally strict: must be a valid literal AND must not contain a
|
||||||
|
* letter anywhere. Plain `InetAddress.getByName(...)` would succeed for
|
||||||
|
* hostnames too (it'd do a DNS lookup and return an IP), which would
|
||||||
|
* false-positive every normal value like "www.google.com".
|
||||||
|
*/
|
||||||
|
private fun String.parseAsIpOrNull(): java.net.InetAddress? {
|
||||||
|
val s = trim()
|
||||||
|
if (s.isEmpty() || s.any { it.isLetter() }) return null
|
||||||
|
return try {
|
||||||
|
// Literal-only parse: rejects anything that would need DNS.
|
||||||
|
java.net.InetAddress.getByName(s).takeIf {
|
||||||
|
it.hostAddress?.let { addr -> addr == s || addr.contains(s) } == true
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseProbeResult(json: String?): ProbeState {
|
private fun parseProbeResult(json: String?): ProbeState {
|
||||||
if (json.isNullOrBlank()) return ProbeState.Err("no response")
|
if (json.isNullOrBlank()) return ProbeState.Err("no response")
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
+5
-5
@@ -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.
|
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.0**
|
Current version: **v1.0.1**
|
||||||
|
|
||||||
| File | Platform | Contents |
|
| File | Platform | Contents |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mhrv-rs-android-universal-v1.0.0.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file |
|
| `mhrv-rs-android-universal-v1.0.1.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-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-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) |
|
| `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
|
### Android
|
||||||
|
|
||||||
Copy `mhrv-rs-android-universal-v1.0.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).
|
Copy `mhrv-rs-android-universal-v1.0.1.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).
|
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 دسترسی ندارند.
|
این پوشه شامل فایلهای آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند.
|
||||||
|
|
||||||
نسخهٔ فعلی: **v1.0.0**
|
نسخهٔ فعلی: **v1.0.1**
|
||||||
|
|
||||||
### دانلود از طریق ZIP
|
### دانلود از طریق ZIP
|
||||||
|
|
||||||
@@ -73,6 +73,6 @@ cd mhrv-rs-macos-arm64
|
|||||||
|
|
||||||
**ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود).
|
**ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود).
|
||||||
|
|
||||||
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.0.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامههای ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
|
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.1.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامههای ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
|
||||||
|
|
||||||
برای راهاندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید.
|
برای راهاندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید.
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -342,6 +342,68 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>(
|
|||||||
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
|
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Native.checkUpdate()` -> String. Runs the same `update_check::check`
|
||||||
|
/// the desktop UI uses, serializes the outcome as JSON so Kotlin can
|
||||||
|
/// pattern-match without needing its own GitHub client.
|
||||||
|
///
|
||||||
|
/// Returned shape, one of:
|
||||||
|
/// {"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}
|
||||||
|
/// {"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}
|
||||||
|
/// {"kind":"offline","reason":"..."}
|
||||||
|
/// {"kind":"error","reason":"..."}
|
||||||
|
///
|
||||||
|
/// Blocking — hit from a background dispatcher.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>(
|
||||||
|
env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jstring {
|
||||||
|
let result_json = safe(
|
||||||
|
r#"{"kind":"error","reason":"panic"}"#.to_string(),
|
||||||
|
AssertUnwindSafe(|| {
|
||||||
|
install_logging_once();
|
||||||
|
let Some(rt) = one_shot_runtime() else {
|
||||||
|
return r#"{"kind":"error","reason":"tokio init failed"}"#.to_string();
|
||||||
|
};
|
||||||
|
let outcome = rt.block_on(crate::update_check::check(
|
||||||
|
crate::update_check::Route::Direct,
|
||||||
|
));
|
||||||
|
update_check_to_json(&outcome)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
env.new_string(result_json)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(std::ptr::null_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String {
|
||||||
|
// Hand-serialized to keep the JNI side free of serde derive noise on
|
||||||
|
// the inner enum (which would need `#[derive(Serialize)]`). Short
|
||||||
|
// enough that the hand-rolled version is simpler than pulling
|
||||||
|
// serde_json in here for one call.
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
match u {
|
||||||
|
crate::update_check::UpdateCheck::UpToDate { current, latest } => format!(
|
||||||
|
r#"{{"kind":"upToDate","current":"{}","latest":"{}"}}"#,
|
||||||
|
esc(current), esc(latest),
|
||||||
|
),
|
||||||
|
crate::update_check::UpdateCheck::UpdateAvailable { current, latest, release_url, .. } => format!(
|
||||||
|
r#"{{"kind":"updateAvailable","current":"{}","latest":"{}","url":"{}"}}"#,
|
||||||
|
esc(current), esc(latest), esc(release_url),
|
||||||
|
),
|
||||||
|
crate::update_check::UpdateCheck::Offline(reason) => format!(
|
||||||
|
r#"{{"kind":"offline","reason":"{}"}}"#,
|
||||||
|
esc(reason),
|
||||||
|
),
|
||||||
|
crate::update_check::UpdateCheck::Error(reason) => format!(
|
||||||
|
r#"{{"kind":"error","reason":"{}"}}"#,
|
||||||
|
esc(reason),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob
|
/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob
|
||||||
/// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`.
|
/// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`.
|
||||||
/// Blocking call — Kotlin side should invoke on a background coroutine.
|
/// Blocking call — Kotlin side should invoke on a background coroutine.
|
||||||
|
|||||||
Reference in New Issue
Block a user