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/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" + ); + } } 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) {