mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-19 08:04:39 +03:00
v1.2.14: Usage today (estimated) card + Persian guide localization
Adds a daily-budget visualization for users worried about hitting the
Apps Script free-tier quota (20,000 UrlFetchApp calls/day).
Usage today card (desktop + Android):
- today_calls / today_bytes / today_key / today_reset_secs atomics on
DomainFronter, hooked into the bytes_relayed fetch_add path so we only
count successful relays (matching what Google actually billed)
- Daily rollover at 00:00 UTC, std-only date math (Hinnant's
civil_from_days) — no chrono/time dep pull
- StatsSnapshot extended with the four new fields + to_json() for the
Android JNI bridge
- Desktop UI renders the card right under the existing Traffic stats
with a hyperlink to https://script.google.com/home/usage for the
authoritative Google-side number
- Android UI renders the same card via Compose, polling
Native.statsJson(handle) once a second only while the proxy is up,
with an Intent(ACTION_VIEW, …) opening the dashboard URL
JNI / state plumbing:
- New Java_…_statsJson reads the Arc<DomainFronter> kept in slot_map
- VpnState.proxyHandle StateFlow so HomeScreen knows which handle to
poll without poking into the service's internal state
- MhrvVpnService publishes the handle on start, zeroes on teardown
Persian localization:
- HowToUseBody (5-step guide + Cloudflare Turnstile note) was
hardcoded English even when locale=FA. Ported to a string resource
with a full FA translation in values-fa/strings.xml. Persian users
no longer drop to English at the bottom of the screen.
Also lands the deferred Android ConfigStore.kt wiring for
passthrough_hosts (commit fe9328e shipped the Rust + desktop side).
82 tests pass (added: unix_to_ymd_utc_handles_known_epochs,
seconds_until_utc_midnight_is_bounded). Built and visually verified on
both desktop and Android emulator (mhrv_test AVD).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.therealaleph.mhrv"
|
||||
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
||||
targetSdk = 34
|
||||
versionCode = 133
|
||||
versionName = "1.2.13"
|
||||
versionCode = 134
|
||||
versionName = "1.2.14"
|
||||
|
||||
// Ship all four mainstream Android ABIs:
|
||||
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
||||
|
||||
@@ -94,6 +94,16 @@ data class MhrvConfig(
|
||||
val parallelRelay: Int = 1,
|
||||
val upstreamSocks5: String = "",
|
||||
|
||||
/**
|
||||
* User-configured hostnames that bypass Apps Script relay entirely
|
||||
* and plain-TCP passthrough (via upstreamSocks5 if set). Each entry
|
||||
* is either an exact hostname ("example.com") or a leading-dot
|
||||
* suffix (".example.com" → matches example.com + any subdomain).
|
||||
* See `src/config.rs` `passthrough_hosts` for semantics.
|
||||
* Issues #39, #127.
|
||||
*/
|
||||
val passthroughHosts: List<String> = emptyList(),
|
||||
|
||||
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
|
||||
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
|
||||
|
||||
@@ -173,6 +183,9 @@ data class MhrvConfig(
|
||||
if (upstreamSocks5.isNotBlank()) {
|
||||
put("upstream_socks5", upstreamSocks5.trim())
|
||||
}
|
||||
if (passthroughHosts.isNotEmpty()) {
|
||||
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
|
||||
}
|
||||
|
||||
// Phone-scoped scan defaults. We don't expose these in the UI
|
||||
// because a phone isn't where you'd run a full /16 scan; users
|
||||
@@ -249,6 +262,9 @@ object ConfigStore {
|
||||
logLevel = obj.optString("log_level", "info"),
|
||||
parallelRelay = obj.optInt("parallel_relay", 1),
|
||||
upstreamSocks5 = obj.optString("upstream_socks5", ""),
|
||||
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
|
||||
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
|
||||
}?.filter { it.isNotBlank() }.orEmpty(),
|
||||
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
|
||||
"proxy_only" -> ConnectionMode.PROXY_ONLY
|
||||
else -> ConnectionMode.VPN_TUN // default for unknown/missing
|
||||
|
||||
@@ -137,6 +137,7 @@ class MhrvVpnService : VpnService() {
|
||||
// backgrounding. Issue #37.
|
||||
if (cfg.connectionMode == ConnectionMode.PROXY_ONLY) {
|
||||
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
|
||||
VpnState.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
return
|
||||
}
|
||||
@@ -258,6 +259,7 @@ class MhrvVpnService : VpnService() {
|
||||
// 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.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
}
|
||||
|
||||
@@ -339,6 +341,7 @@ class MhrvVpnService : VpnService() {
|
||||
}
|
||||
// Flip UI state last — the button reverts to Connect only after
|
||||
// the native-side cleanup actually happened, not optimistically.
|
||||
VpnState.setProxyHandle(0L)
|
||||
VpnState.setRunning(false)
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
@@ -78,4 +78,21 @@ object Native {
|
||||
* Same check the desktop UI runs — same result format.
|
||||
*/
|
||||
external fun checkUpdate(): String
|
||||
|
||||
/**
|
||||
* Live traffic/usage counters for a running proxy handle. Returns a
|
||||
* JSON blob with the StatsSnapshot fields — or an empty string if the
|
||||
* handle is unknown or the proxy isn't using the Apps Script relay
|
||||
* (google_only / full-only modes).
|
||||
*
|
||||
* Schema (all integer fields unless noted):
|
||||
* relay_calls, relay_failures, coalesced, bytes_relayed,
|
||||
* cache_hits, cache_misses, cache_bytes,
|
||||
* blacklisted_scripts, total_scripts,
|
||||
* today_calls, today_bytes, today_key (string "YYYY-MM-DD"),
|
||||
* today_reset_secs (seconds until 00:00 UTC rollover)
|
||||
*
|
||||
* Cheap — just reads atomics. Safe to poll on a second-scale timer.
|
||||
*/
|
||||
external fun statsJson(handle: Long): String
|
||||
}
|
||||
|
||||
@@ -28,7 +28,20 @@ object VpnState {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
/**
|
||||
* Current native proxy handle for stats polling, or 0 if nothing is up.
|
||||
* The service publishes this alongside `isRunning` so the Compose UI can
|
||||
* call `Native.statsJson(handle)` without poking into the service's
|
||||
* internal state. Reset to 0 on teardown so polling stops cleanly.
|
||||
*/
|
||||
private val _proxyHandle = MutableStateFlow(0L)
|
||||
val proxyHandle: StateFlow<Long> = _proxyHandle.asStateFlow()
|
||||
|
||||
fun setRunning(running: Boolean) {
|
||||
_isRunning.value = running
|
||||
}
|
||||
|
||||
fun setProxyHandle(handle: Long) {
|
||||
_proxyHandle.value = handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +450,16 @@ fun HomeScreen(
|
||||
Text(stringResource(R.string.btn_install_mitm))
|
||||
}
|
||||
|
||||
// "Usage today (estimated)" — visible only while a proxy is
|
||||
// actually running (the handle is non-zero). Polls the native
|
||||
// stats counter once a second; cheap (just reads atomics on
|
||||
// the Rust side) and gives users a live feel for how close
|
||||
// they are to the Apps Script daily quota. Also links out to
|
||||
// Google's dashboard for the authoritative number — the
|
||||
// client-side estimate only sees what this device relayed,
|
||||
// not what other devices on the same deployment consumed.
|
||||
UsageTodayCard()
|
||||
|
||||
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
|
||||
LiveLogPane()
|
||||
}
|
||||
@@ -1311,37 +1321,166 @@ private fun CollapsibleSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
|
||||
* second while the proxy is up and renders today's relay calls vs. the
|
||||
* Apps Script free-tier quota (20,000/day), today's bytes, UTC day key,
|
||||
* and a countdown to the 00:00 UTC reset. Also shows a "View quota on
|
||||
* Google" button that opens Google's Apps Script dashboard — the
|
||||
* authoritative number, since the client-side estimate only sees what
|
||||
* this device relayed.
|
||||
*
|
||||
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
|
||||
* empty (google_only / full-only configs don't run a DomainFronter and so
|
||||
* have nothing to report).
|
||||
*/
|
||||
@Composable
|
||||
private fun UsageTodayCard() {
|
||||
// Free-tier Apps Script UrlFetchApp daily quota. Workspace / paid
|
||||
// tiers get 100k but most users are on free.
|
||||
val freeQuotaPerDay = 20_000
|
||||
|
||||
val handle by VpnState.proxyHandle.collectAsState()
|
||||
val isRunning by VpnState.isRunning.collectAsState()
|
||||
|
||||
// Nothing to poll until the proxy is up.
|
||||
if (!isRunning || handle == 0L) return
|
||||
|
||||
var statsJson by remember { mutableStateOf("") }
|
||||
LaunchedEffect(handle) {
|
||||
// Drop any stale snapshot from a previous run.
|
||||
statsJson = ""
|
||||
while (true) {
|
||||
statsJson = withContext(Dispatchers.IO) {
|
||||
runCatching { Native.statsJson(handle) }.getOrDefault("")
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val obj = remember(statsJson) {
|
||||
if (statsJson.isBlank()) null
|
||||
else runCatching { JSONObject(statsJson) }.getOrNull()
|
||||
}
|
||||
// Still booting / not an apps-script config — stay silent.
|
||||
if (obj == null) return
|
||||
|
||||
val todayCalls = obj.optLong("today_calls", 0L)
|
||||
val todayBytes = obj.optLong("today_bytes", 0L)
|
||||
val todayKey = obj.optString("today_key", "")
|
||||
val resetSecs = obj.optLong("today_reset_secs", 0L)
|
||||
val pct = if (freeQuotaPerDay > 0) {
|
||||
(todayCalls.toDouble() / freeQuotaPerDay) * 100.0
|
||||
} else 0.0
|
||||
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.sec_usage_today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_calls_today),
|
||||
value = stringResource(
|
||||
R.string.usage_calls_of_quota,
|
||||
todayCalls.toInt(),
|
||||
freeQuotaPerDay,
|
||||
pct,
|
||||
),
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_bytes_today),
|
||||
value = fmtBytes(todayBytes),
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_utc_day),
|
||||
value = todayKey,
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_resets_in),
|
||||
value = stringResource(
|
||||
R.string.usage_resets_hm,
|
||||
(resetSecs / 3600).toInt(),
|
||||
((resetSecs / 60) % 60).toInt(),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Open the Google-side Apps Script quota dashboard in
|
||||
// the user's browser. Uses ACTION_VIEW with a https://
|
||||
// URI — the OS picks whatever default browser is set.
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse("https://script.google.com/home/usage"),
|
||||
)
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { ctx.startActivity(intent) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.btn_view_quota_on_google))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.usage_today_note),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmtBytes(b: Long): String {
|
||||
val k = 1024L
|
||||
val m = k * k
|
||||
val g = m * k
|
||||
return when {
|
||||
b >= g -> String.format("%.2f GB", b.toDouble() / g)
|
||||
b >= m -> String.format("%.2f MB", b.toDouble() / m)
|
||||
b >= k -> String.format("%.1f KB", b.toDouble() / k)
|
||||
else -> "$b B"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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.
|
||||
//
|
||||
// Text is sourced from string resources (values/strings.xml +
|
||||
// values-fa/strings.xml) so the Persian locale gets a translated
|
||||
// guide instead of falling back to English.
|
||||
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 " +
|
||||
"to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" " +
|
||||
"or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You'll be asked to set a " +
|
||||
"screen lock if you don't have one (Android requirement).\n" +
|
||||
"3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If " +
|
||||
"every entry times out, your google_ip is unreachable — replace it with one that " +
|
||||
"resolves locally (e.g. `nslookup www.google.com` on any working device).\n" +
|
||||
"4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the " +
|
||||
"device through the proxy — no per-app setup needed.\n" +
|
||||
"5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn't " +
|
||||
"responding. Redeploy the script, grab the new /exec URL, and paste it above. " +
|
||||
"Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer " +
|
||||
"is failing.\n" +
|
||||
"\n" +
|
||||
"Known limitation — Cloudflare Turnstile (\"Verify you are human\") will loop " +
|
||||
"endlessly on most CF-protected sites. Every Apps Script request uses a rotating " +
|
||||
"Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a " +
|
||||
"Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) " +
|
||||
"tuple the challenge was solved against, so the NEXT request — from a different " +
|
||||
"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.",
|
||||
text = stringResource(R.string.help_how_to_use),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,4 +78,18 @@
|
||||
<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>
|
||||
|
||||
<!-- Usage today card -->
|
||||
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
|
||||
<string name="label_calls_today">درخواستهای امروز</string>
|
||||
<string name="label_bytes_today">بایت امروز</string>
|
||||
<string name="label_utc_day">روز (UTC)</string>
|
||||
<string name="label_resets_in">ریست تا</string>
|
||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
|
||||
<string name="btn_view_quota_on_google">مشاهدهٔ سهمیه در گوگل ←</string>
|
||||
<string name="usage_today_note">تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است.</string>
|
||||
|
||||
<!-- "How to use" guide body. Localized — EN copy lives in values. -->
|
||||
<string name="help_how_to_use">۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جایگذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره میشود و برنامهٔ Settings باز میشود. داخل Settings از نوار جستوجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید میخواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایماوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve میشود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامههای دستگاه را خودکار از پروکسی رد میکند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمیدهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جایگذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص میکند کدام لایه مقصر است.\n\nمحدودیت شناختهشده — Cloudflare Turnstile («Verify you are human») روی اکثر سایتهای پشت Cloudflare بهطور بیپایان loop میزند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور میکند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش میخورد. این مسئله در این برنامه قابلحل نیست؛ ذات رلهٔ Apps Script است. سایتهایی که فقط بارگذاری اولیه را gate میکنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد.</string>
|
||||
</resources>
|
||||
|
||||
@@ -78,4 +78,18 @@
|
||||
<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>
|
||||
|
||||
<!-- Usage today card -->
|
||||
<string name="sec_usage_today">Usage today (estimated)</string>
|
||||
<string name="label_calls_today">calls today</string>
|
||||
<string name="label_bytes_today">bytes today</string>
|
||||
<string name="label_utc_day">UTC day</string>
|
||||
<string name="label_resets_in">resets in</string>
|
||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||
<string name="usage_resets_hm">%1$dh %2$dm</string>
|
||||
<string name="btn_view_quota_on_google">View quota on Google →</string>
|
||||
<string name="usage_today_note">Estimate — this is what this device relayed. Google\'s dashboard has the authoritative number.</string>
|
||||
|
||||
<!-- "How to use" guide body. Localized — FA copy lives in values-fa. -->
|
||||
<string name="help_how_to_use">1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. 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 to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If every entry times out, your google_ip is unreachable — replace it with one that resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different 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.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user