Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 68 additions & 9 deletions src-tauri/src/commands/otp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)),
}
Expand Down
30 changes: 30 additions & 0 deletions src/pages/QrForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ async function doStart(): Promise<void> {
}
}

/**
* 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') {
Expand All @@ -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(() => {
Expand Down