From 9004ca4e2a7892758ec39a442c31f975f4558a60 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Fri, 19 Jun 2026 02:46:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(qr):=20recover=20from=20transient=20"?= =?UTF-8?q?=E7=99=BB=E5=85=A5=E9=80=BE=E6=9C=9F"=20on=20get-password=20+?= =?UTF-8?q?=20blank=20first-open=20QR=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two QR-login regressions reported in #298 vs the legacy WPF 5.9 client: 1. Pressing "取得密碼" (get OTP) right after a QR login immediately showed "登入逾期" and forced a re-login. The OTP flow's session- expired heuristic (OtpMissingLongPollingKey / OtpMissingSecretCode, added for #264) fired on the *first* attempt — but right after login the game-host portal session often isn't warm yet, so step 1/2 come back as a login-redirect page even though the session is valid. WPF never nuked the session here; it just toasted an error and the user retried successfully. commands/otp.rs now re-warms the portal session (the same auth.aspx cookie-priming GET get_accounts performs) and retries the OTP once before treating the failure as a genuine expiry. Only a second session-expired-looking failure clears auth, preserving the #264 behaviour for real server-side invalidation. 2. On first app open the QR area sometimes stayed blank/white with no error, recoverable only by toggling region (HK → TW). A transient cold-start network flake or a boot navigation-storm race (first loginQrStart rejected by the auth withGuard, a swallowed plain Error) left the QR empty. QrForm.vue now auto-retries loginQrStart once after a short delay when the first attempt yields no bitmap. Note: symptom 1 is a reverse-engineered network protocol that can't be exercised offline — needs live-server verification. --- src-tauri/Cargo.lock | 2 +- src-tauri/src/commands/otp.rs | 77 +++++++++++++++++++++++++++++++---- src/pages/QrForm.vue | 30 ++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 31422a1..8a06bd4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -330,7 +330,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beanfun" -version = "6.0.0" +version = "6.0.1" dependencies = [ "aes", "anyhow", diff --git a/src-tauri/src/commands/otp.rs b/src-tauri/src/commands/otp.rs index 6a7def0..93f31a9 100644 --- a/src-tauri/src/commands/otp.rs +++ b/src-tauri/src/commands/otp.rs @@ -35,7 +35,10 @@ use crate::commands::{ session::{require_auth, SESSION_REQUIRED_CODE, SESSION_REQUIRED_MESSAGE}, state::AppState, }; -use crate::services::beanfun::{get_otp as service_get_otp, LoginError, ServiceAccount}; +use crate::services::beanfun::{ + account::get_accounts as service_get_accounts, get_otp as service_get_otp, LoginError, + ServiceAccount, +}; /// Retrieve the one-time game-launch password for a given service /// account. @@ -111,18 +114,74 @@ pub async fn get_otp( { Ok(otp) => Ok(otp), Err(e) if is_likely_session_expired(&e) => { + // Issue #298: pressing "get password" immediately after a QR + // (or any) login frequently trips the session-expired + // heuristic even though the session is perfectly valid — the + // server-side portal session for the game host just isn't + // warm yet on the very first OTP attempt, so step 1/2 come + // back as a login-redirect page. + // + // WPF (5.9) tolerates this: `GetOTP` failures only show a + // "GetOtpError" toast and the user retries successfully. The + // earlier #264 fix over-corrected by nuking the auth context + // on the *first* failure, which surfaces as the user-visible + // "登入逾期" → forced re-login regression reported in #298. + // + // Re-warm the portal session exactly the way `get_accounts` + // does (the `auth.aspx` cookie-priming GET it runs first) and + // retry the OTP once. Only a *second* session-expired-looking + // failure is treated as a genuine expiry that clears auth. tracing::warn!( error = %e, - "OTP flow detected likely server-side session expiry; clearing local auth context" + "OTP first attempt looked session-expired; re-warming portal session and retrying once" ); - let taken = state.auth.write().await.take(); - if let Some(ctx) = taken { - ctx.ping_cancel.cancel(); + + if let Err(rewarm_err) = service_get_accounts( + &client, + &session, + &session.service_code, + &session.service_region, + ) + .await + { + // Non-fatal: the retry below may still succeed off the + // cookies the warm-up GET primed even if list parsing + // failed. Log for live-test fault isolation. + tracing::warn!( + error = %rewarm_err, + "OTP re-warm get_accounts failed; retrying OTP anyway" + ); + } + + match service_get_otp( + &client, + &session, + &account, + &session.service_code, + &session.service_region, + ) + .await + { + Ok(otp) => { + tracing::info!("OTP succeeded after portal-session re-warm"); + Ok(otp) + } + Err(e2) if is_likely_session_expired(&e2) => { + tracing::warn!( + error = %e2, + "OTP still session-expired after re-warm; clearing local auth context" + ); + let taken = state.auth.write().await.take(); + if let Some(ctx) = taken { + ctx.ping_cancel.cancel(); + } + Err(CommandError::new( + SESSION_REQUIRED_CODE, + SESSION_REQUIRED_MESSAGE, + )) + } + Err(e2) => Err(CommandError::from(e2)), } - Err(CommandError::new( - SESSION_REQUIRED_CODE, - SESSION_REQUIRED_MESSAGE, - )) } Err(e) => Err(CommandError::from(e)), } diff --git a/src/pages/QrForm.vue b/src/pages/QrForm.vue index d22d5bf..204dc90 100644 --- a/src/pages/QrForm.vue +++ b/src/pages/QrForm.vue @@ -237,6 +237,30 @@ async function doStart(): Promise { } } +/** + * Cold-start safety net (issue #298). + * + * On the very first app open the QR area sometimes stayed blank/white + * with no error banner, and the only recovery was manually toggling + * region (HK → TW) to re-trigger a fresh `loginQrStart`. Two distinct + * first-open hazards produce that symptom: + * + * 1. A transient cold-start network failure on the very first HTTPS + * round-trip to beanfun (DNS / TLS / proxy warm-up). `doStart` + * surfaces that as a toast + `connectionLost`, but a single flake + * shouldn't force the user to fiddle with the region picker. + * 2. A navigation-storm race (the boot `/ → /login/ → /login/qr` + * redirect chain) where the first `loginQrStart` is rejected by the + * auth-store `withGuard` (a plain `Error`, deliberately swallowed by + * `doStart`) — leaving the QR blank with no banner at all. + * + * One automatic retry after a short delay covers both: if we still + * have no bitmap shortly after the initial attempt, re-run `doStart` + * (which mints a fresh backend client + challenge). The manual Refresh + * button remains the user-driven fallback. + */ +const COLD_START_RETRY_DELAY_MS = 800 + onMounted(async () => { const region = readRegion() if (region !== 'TW') { @@ -246,6 +270,12 @@ onMounted(async () => { return } await doStart() + + // Auto-retry once if the first attempt produced no QR (see docblock). + if (disposed || bitmap.value) return + await new Promise((resolve) => setTimeout(resolve, COLD_START_RETRY_DELAY_MS)) + if (disposed || bitmap.value) return + await doStart() }) onBeforeUnmount(() => {