From e5f21caf0d30dc18551e719aa6624d47bdf61bd7 Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Thu, 21 May 2026 22:32:45 +0800 Subject: [PATCH 1/2] fix(account): invalidate prefetched accounts on service switch (#286) --- src-tauri/src/commands/account.rs | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index e58c20e..90559c2 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -205,6 +205,12 @@ async fn set_active_service_internal( Some(ctx) => { ctx.session.service_code = service_code; ctx.session.service_region = service_region; + drop(guard); + // Invalidate prefetched accounts (#286): the prefetch was + // performed for the login-time default game; switching the + // active service means the next `get_accounts` must fetch + // fresh data for the newly selected game. + *state.prefetched_accounts.write().await = None; Ok(()) } None => Err(CommandError::new( @@ -1104,4 +1110,34 @@ mod tests { assert_eq!(ctx.session.service_code, ""); assert_eq!(ctx.session.service_region, ""); } + + /// #286: Switching active service must invalidate the + /// `prefetched_accounts` cache so the next `get_accounts` call + /// fetches fresh data for the new game instead of returning stale + /// prefetched accounts from the login-time default game. + #[tokio::test] + async fn set_active_service_clears_prefetched_accounts() { + let app = empty_state(); + { + let mut guard = app.auth.write().await; + *guard = Some(seeded_auth_context()); + } + { + let mut guard = app.prefetched_accounts.write().await; + *guard = Some(AccountListResult { + accounts: vec![], + amount_limit_notice: crate::services::beanfun::AmountLimitNotice::None, + }); + } + + set_active_service_internal(&app, "610153".into(), "TN".into()) + .await + .expect("switch succeeds"); + + let guard = app.prefetched_accounts.read().await; + assert!( + guard.is_none(), + "prefetched_accounts must be cleared after service switch" + ); + } } From 6f4963871db1edd22c197020d5e833cd0d28603f Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 2 Jun 2026 01:45:48 +0800 Subject: [PATCH 2/2] fix(auth): clear stale WebView2 cookies before GamePass re-login (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebView2 keeps a single cookie store per user-data-folder, shared by every window for the lifetime of the host process. After a GamePass logout the server-side session is invalidated but its bfWebToken / ASP.NET_SessionId cookies linger in that store, so the next GamePass login (a new window, same process) inherits the stale token, the portal short-circuits the OAuth round-trip, and the harvest lifts the dead session — surfacing wrong/empty account data. Only restarting the .exe recovered, because that ends the WebView2 browser session. Clear the WebView2 cookie store before seeding the fresh session cookies so every attempt starts from a clean, process-restart- equivalent state. The clear and seed run as two separate native COM passes with a flush gap between them: DeleteAllCookies and AddOrUpdateCookie are both fire-and-return calls with no documented ordering guarantee, so fusing them into one pass risks the pending delete wiping the freshly-seeded cookies. Add a per-page-load diagnostic that logs the WebView's cookie names (never values) so the clear can be verified on a live run. --- src-tauri/Cargo.lock | 2 +- src-tauri/src/commands/auth.rs | 197 +++++++++++++++++++----- src-tauri/src/commands/cookie_native.rs | 86 +++++++++++ 3 files changed, 243 insertions(+), 42 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2b06fa2..31422a1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -330,7 +330,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beanfun" -version = "5.9.7" +version = "6.0.0" dependencies = [ "aes", "anyhow", diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 66f4aee..e2447a7 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -105,8 +105,8 @@ use crate::services::beanfun::{ login::{ finalize_qr_login, get_session_key, init_qr_login, inject_webview_cookies, login_registered_device, login_totp as login_totp_service, login_with, - logout as logout_service, poll_qr_login_status, seed_webview_cookies_from_client, - try_complete_gamepass_login, LoginMethod, QrPollOutcome, + logout as logout_service, poll_qr_login_status, try_complete_gamepass_login, LoginMethod, + QrPollOutcome, }, session::Credentials, verify::{ @@ -117,6 +117,13 @@ use crate::services::beanfun::{ LoginError, Session, }; +// Only the non-Windows GamePass seed path uses the wry `set_cookie` +// helper; Windows seeds (and clears, issue #296) through the native +// COM `cookie_native::seed_cookies_native`, so importing this on +// Windows would be a dead `use`. +#[cfg(not(target_os = "windows"))] +use crate::services::beanfun::login::seed_webview_cookies_from_client; + // ═══════════════════════════════════════════════════════════════════════ // Session keep-alive (WPF pingWorker parity) // ═══════════════════════════════════════════════════════════════════════ @@ -975,6 +982,63 @@ fn parse_harvest_url(raw: &str) -> Url { Url::parse(raw).expect("GAMEPASS_HARVEST_URLS entry must be a valid absolute URL") } +/// Diagnostic — log the **names** (never the values) of the cookies +/// the GamePass WebView currently exposes for each +/// [`GAMEPASS_HARVEST_URLS`] origin. +/// +/// # Why this exists (issue #296) +/// +/// The re-login fix wipes the WebView2 cookie store before seeding a +/// fresh session (see [`open_gamepass_window`]). Because the clear +/// happens inside a native COM closure with no return-value cookie +/// dump, the only way to *prove on a live run* that no stale +/// `bfWebToken` survived a logout → re-login cycle is to read the +/// WebView's own view of its cookies on the first page load (the +/// `Login/Index` entry page, before the user authenticates). +/// +/// Expected traces on a healthy re-login: +/// +/// - On the entry page: only the freshly-seeded portal-session +/// cookies (e.g. `ASP.NET_SessionId`) — **no** `bfWebToken`. A +/// lingering `bfWebToken` here means the clear failed (or WebView2 +/// restored it from disk) and is the smoking gun for a #296 +/// regression. +/// - After a real GamePass authentication: `bfWebToken` appears, +/// which is what [`try_complete_gamepass_login`] then harvests. +/// +/// Values are deliberately omitted — session cookies are +/// credentials-equivalent and structured log sinks would capture +/// them (same stance as [`trace_cookie_jar`]). +/// +/// Read errors per origin are logged at WARN but never abort the +/// caller; this is a best-effort observability hook, not a control- +/// flow gate. +fn trace_webview_cookies(step: &'static str, window: &WebviewWindow) { + for raw_origin in GAMEPASS_HARVEST_URLS { + let origin = parse_harvest_url(raw_origin); + match window.cookies_for_url(origin.clone()) { + Ok(cookies) => { + let names: Vec<&str> = cookies.iter().map(|c| c.name()).collect(); + tracing::info!( + step = step, + origin = %origin, + count = names.len(), + names = ?names, + "webview cookie names for origin" + ); + } + Err(err) => { + tracing::warn!( + step = step, + origin = %origin, + error = ?err, + "failed to read webview cookies for diagnostic dump" + ); + } + } + } +} + /// Diagnostic — dump every unexpired cookie in `client`'s jar to the /// tracing pipeline as structured `info!` records, one per cookie /// plus a summary line. @@ -1008,9 +1072,8 @@ fn parse_harvest_url(raw: &str) -> Url { /// returns. Captures what the portal's redirect chain left /// behind in the client jar (the "what WPF's `bfClient` would /// be holding at this point" snapshot). -/// 2. [`open_gamepass_window`] — right before -/// [`seed_webview_cookies_from_client`] runs. Captures what we -/// actually hand off to the WebView. +/// 2. [`open_gamepass_window`] — right before the WebView cookie +/// seed runs. Captures what we actually hand off to the WebView. /// /// Both dumps should be identical in the happy path (no HTTP /// happens between them), but pinning both makes any unexpected @@ -1157,6 +1220,13 @@ async fn handle_gamepass_page_load( } }; + // Issue #296 diagnostic — dump the WebView's cookie names on EVERY + // page load (this runs *before* the completion-URL filter below so + // it also fires on the `Login/Index` entry page). A re-login that + // shows a stale `bfWebToken` here means the pre-seed + // `DeleteAllCookies` in `open_gamepass_window` did not take effect. + trace_webview_cookies("GamepassPageLoad.WebViewCookies", &window); + if !should_try_gamepass_completion(&url) { tracing::info!( step = "GamepassPageLoad.SkipUrl", @@ -1626,43 +1696,88 @@ pub async fn open_gamepass_window( // from another tauri task) becomes obvious. trace_cookie_jar("GamepassWebViewSeed.JarDump", &client); - // ── Seed every unexpired session cookie from the BeanfunClient - // jar into the newly-created WebView. Best-effort per-cookie: - // if one `set_cookie` fails (platform regression, corrupted - // attributes) we log and continue — identical stance to WPF's - // `AddOrUpdateCookie` loop which has no per-cookie try/catch. - // If seeding fails *catastrophically* (e.g. the sink closure - // errors out by returning `Err` early — our closure never does, - // it's infallible-by-construction), propagation would go here; - // the current closure can only return `Ok(())`, so the unwrap - // at `.expect("seed closure never errors")` is a compile-time - // assertion the helper stays fire-and-forget from this call - // site. - let mut seed_failures = 0usize; - let seeded = seed_webview_cookies_from_client(&client, |cookie| { - if let Err(err) = window.set_cookie(cookie.clone()) { - seed_failures += 1; - tracing::warn!( - step = "GamepassWebViewSeed.CookieError", - cookie_name = %cookie.name(), - cookie_domain = ?cookie.domain(), - error = ?err, - "failed to seed cookie into GamePass WebView; continuing with remaining cookies" - ); - } - // Explicit `Ok` so the helper's fail-fast short-circuit - // semantics never fire — we want a best-effort full pass - // matching WPF. - Ok::<(), std::convert::Infallible>(()) - }) - .expect("seed closure is infallible"); + // ── Reset the shared WebView2 cookie store, then seed the fresh + // session cookies (issue #296). + // + // WebView2 keeps ONE cookie store per user-data-folder, shared by + // every window for the lifetime of the host *process*. A prior + // GamePass login therefore leaves its (now logged-out, server- + // invalidated) `bfWebToken` / `ASP.NET_SessionId` behind. On a + // second attempt within the same process the portal sees that + // stale token, short-circuits the OAuth round-trip, and the + // harvest lifts the dead session — surfacing the wrong / empty + // account data. Only restarting the .exe (which ends the WebView2 + // browser session and drops the session cookies) recovered. + // + // Wiping the store before seeding makes every attempt start from a + // fresh-browser state, equivalent to a process restart. + #[cfg(target_os = "windows")] + { + // Two distinct native passes, NOT one fused closure. + // + // `DeleteAllCookies` and `AddOrUpdateCookie` are both + // fire-and-return COM calls that queue work on the WebView2 + // browser process, and Microsoft documents no ordering + // guarantee between a delete and an immediately-following add. + // Issuing them back-to-back in the same pass risks the pending + // delete wiping the cookies we just seeded — which would + // reproduce the very "No such auth key and secret code" failure + // the D5 seed fix cured. So: clear, wait for the delete to + // flush, THEN seed, then wait for the seed to flush, then + // navigate. + let cleared = crate::commands::cookie_native::clear_all_cookies_native(&window); + tracing::info!( + step = "GamepassWebViewClear", + cleared = cleared, + "issued DeleteAllCookies before seeding (issue #296)" + ); + // Let the delete commit on the browser process before we start + // writing the fresh cookies. + tokio::time::sleep(Duration::from_millis(200)).await; - tracing::info!( - step = "GamepassWebViewSeed.Summary", - seeded = seeded - seed_failures, - failed = seed_failures, - "cookie seed summary from BeanfunClient jar into WebView before login navigation" - ); + let seeded = crate::commands::cookie_native::seed_cookies_native(&window, &client); + tracing::info!( + step = "GamepassWebViewSeed.Summary", + seeded = seeded, + "seeded fresh session cookies after clear (native COM)" + ); + // Let the seed flush before the navigation below sends the + // request cookies (same flush stance as `web_browser::open_*`). + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Non-Windows has no native cookie API; fall back to wry's + // `set_cookie` (best-effort per-cookie). The cookie-persistence + // quirk this clears is Windows/WebView2-specific and beanfun ships + // Windows-only, so the absence of a clear here is acceptable. + #[cfg(not(target_os = "windows"))] + { + let mut seed_failures = 0usize; + let seeded = seed_webview_cookies_from_client(&client, |cookie| { + if let Err(err) = window.set_cookie(cookie.clone()) { + seed_failures += 1; + tracing::warn!( + step = "GamepassWebViewSeed.CookieError", + cookie_name = %cookie.name(), + cookie_domain = ?cookie.domain(), + error = ?err, + "failed to seed cookie into GamePass WebView; continuing with remaining cookies" + ); + } + // Explicit `Ok` so the helper's fail-fast short-circuit + // semantics never fire — we want a best-effort full pass + // matching WPF. + Ok::<(), std::convert::Infallible>(()) + }) + .expect("seed closure is infallible"); + + tracing::info!( + step = "GamepassWebViewSeed.Summary", + seeded = seeded - seed_failures, + failed = seed_failures, + "cookie seed summary from BeanfunClient jar into WebView before login navigation" + ); + } // ── Navigate to the real login URL. From here on the page-load // handler drives completion (same as before). diff --git a/src-tauri/src/commands/cookie_native.rs b/src-tauri/src/commands/cookie_native.rs index e210d71..d9c803b 100644 --- a/src-tauri/src/commands/cookie_native.rs +++ b/src-tauri/src/commands/cookie_native.rs @@ -11,6 +11,12 @@ use crate::services::beanfun::BeanfunClient; /// Seed every unexpired cookie from `client`'s reqwest jar into the /// WebView2 cookie manager of `window` using the native COM API. +/// +/// This function only *adds* cookies. Callers that need a clean slate +/// first (the GamePass re-login flow — issue #296) must call +/// [`clear_all_cookies_native`] in a **separate** pass and wait for it +/// to flush before seeding; see that function's docs for why the +/// clear and seed are deliberately not fused into one native call. pub fn seed_cookies_native( window: &WebviewWindow, client: &BeanfunClient, @@ -123,6 +129,86 @@ pub fn seed_cookies_native( total } +/// Delete **every** cookie in the WebView2 profile of `window` via the +/// native COM `ICoreWebView2CookieManager::DeleteAllCookies`. +/// +/// # Why this is separate from [`seed_cookies_native`] (issue #296) +/// +/// WebView2 keeps a single cookie store per user-data-folder, shared +/// by every window for the lifetime of the host *process*. After a +/// GamePass logout the server-side session is dead but its +/// `bfWebToken` / `ASP.NET_SessionId` cookies linger in that store, so +/// the next GamePass login (a new window, same process) inherits the +/// stale token, the portal short-circuits the OAuth round-trip, and +/// the harvest lifts a dead session. Restarting the .exe was the only +/// recovery (it ends the WebView2 browser session, dropping the +/// session cookies). Clearing the store before the next login makes +/// every attempt start fresh — equivalent to a process restart. +/// +/// # Why a dedicated pass instead of clearing inside the seed +/// +/// `DeleteAllCookies` and `AddOrUpdateCookie` are both fire-and-return +/// COM calls that queue work on the browser process; Microsoft does +/// **not** document an ordering guarantee between a delete and an +/// immediately-following add. Fusing them into one `with_webview` +/// closure risks the pending delete wiping the freshly-seeded cookies. +/// The caller therefore runs this clear in its own pass, waits a beat +/// for it to flush, and only then calls [`seed_cookies_native`]. +/// +/// Returns `true` if the `DeleteAllCookies` call was issued +/// successfully, `false` on any COM failure (logged at WARN). A +/// `false` return is best-effort — the caller still proceeds to seed. +pub fn clear_all_cookies_native(window: &WebviewWindow) -> bool { + let issued = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let issued_inner = issued.clone(); + + let result = window.with_webview(move |webview| unsafe { + let core = match webview.controller().CoreWebView2() { + Ok(c) => c, + Err(e) => { + tracing::warn!(step = "NativeClear", error = ?e, "CoreWebView2"); + return; + } + }; + + let core2: ICoreWebView2_2 = match Interface::cast(&core) { + Ok(c) => c, + Err(e) => { + tracing::warn!(step = "NativeClear", error = ?e, "cast v2"); + return; + } + }; + + let manager = match core2.CookieManager() { + Ok(m) => m, + Err(e) => { + tracing::warn!(step = "NativeClear", error = ?e, "CookieManager"); + return; + } + }; + + match manager.DeleteAllCookies() { + Ok(()) => { + issued_inner.store(true, std::sync::atomic::Ordering::SeqCst); + tracing::info!( + step = "NativeClear.Complete", + "DeleteAllCookies issued on WebView2 profile" + ); + } + Err(e) => { + tracing::warn!(step = "NativeClear.Failed", error = ?e, "DeleteAllCookies failed"); + } + } + }); + + if let Err(e) = result { + tracing::warn!(step = "NativeClear", error = ?e, "with_webview failed"); + return false; + } + + issued.load(std::sync::atomic::Ordering::SeqCst) +} + /// Register a `NewWindowRequested` handler on the WebView2 instance /// that redirects popup requests to navigate within the same window. pub fn register_new_window_handler(window: &WebviewWindow) {