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.

36 changes: 36 additions & 0 deletions src-tauri/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
);
}
}
197 changes: 156 additions & 41 deletions src-tauri/src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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)
// ═══════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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<R: tauri::Runtime>(step: &'static str, window: &WebviewWindow<R>) {
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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1157,6 +1220,13 @@ async fn handle_gamepass_page_load<R: tauri::Runtime>(
}
};

// 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",
Expand Down Expand Up @@ -1626,43 +1696,88 @@ pub async fn open_gamepass_window<R: tauri::Runtime>(
// 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).
Expand Down
86 changes: 86 additions & 0 deletions src-tauri/src/commands/cookie_native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: tauri::Runtime>(
window: &WebviewWindow<R>,
client: &BeanfunClient,
Expand Down Expand Up @@ -123,6 +129,86 @@ pub fn seed_cookies_native<R: tauri::Runtime>(
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<R: tauri::Runtime>(window: &WebviewWindow<R>) -> 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<R: tauri::Runtime>(window: &WebviewWindow<R>) {
Expand Down
Loading