diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb51e93..c054176 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: rust: name: 'Rust: fmt / Clippy / Test' runs-on: windows-latest + # Cap the job at 25 min so a hung test (e.g. wiremock + tokio + # paused-time interaction on Windows) fails loudly within the + # next CI cycle instead of silently consuming the GitHub default + # 6-hour budget. Healthy runs land in 4-8 min on a warm cache. + timeout-minutes: 25 defaults: run: working-directory: src-tauri diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e3d4977..0b8fb98 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -330,7 +330,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beanfun" -version = "5.9.2" +version = "5.9.3" dependencies = [ "aes", "anyhow", @@ -346,6 +346,7 @@ dependencies = [ "md-5", "nrbf", "open", + "os_info", "percent-encoding", "pretty_assertions", "quick-xml 0.37.5", @@ -362,11 +363,13 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-window-state", "tauri-specta", "tempfile", "thiserror 2.0.18", "tokio", "tokio-test", + "tokio-util", "tracing", "tracing-subscriber", "url", @@ -2564,6 +2567,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2669,6 +2684,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2693,6 +2729,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2751,8 +2819,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2804,6 +2891,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -4605,6 +4707,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -4939,6 +5056,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8c9bf9..c03a951 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,13 @@ tauri-plugin-opener = "2" # on Linux); no extra config needed beyond declaring the plugin in `lib.rs` # and granting `dialog:default` in `capabilities/default.json`. tauri-plugin-dialog = "2" +# Persists the main window's *position* across launches so users that +# drag the launcher to e.g. a secondary monitor get it back in the +# same spot next time. We deliberately enable `StateFlags::POSITION` +# only — `tauri.conf.json` ships `resizable: false` and the router's +# `fitWindow` (`router/index.ts`) drives per-route height/width, so +# persisting SIZE / MAXIMIZED would fight those handlers. +tauri-plugin-window-state = "2" # Cross-platform URL / file opener (ShellExecuteW on Windows, # LSOpenCFURLRef on macOS, xdg-open on Linux). Already pulled in as @@ -82,6 +89,11 @@ reqwest_cookie_store = "0.8" # Async runtime tokio = { version = "1", features = ["rt", "sync", "time", "fs", "macros"] } +# CancellationToken for the session keep-alive ping loop +# (see `commands::auth::run_ping_loop` + `commands::state::AuthContext::ping_cancel`). +# Already pulled in transitively by `tauri`; making the dependency explicit so +# the feature set doesn't silently regress if an intermediate crate drops it. +tokio-util = { version = "0.7", features = ["rt"] } # Crypto des = "0.8" @@ -145,6 +157,15 @@ webview2-com = "0.38" # avoids a standalone `version = "..."` line that the release workflow's # version-bump regex previously misidentified as the package version. wv2-windows-core = { package = "windows-core", version = "0.61" } +# Windows build number detection — used by `lib.rs::run` to disable the +# window shadow on Windows 10 only. Works around upstream tauri-apps/tauri +# #11654 / #13176 where the undecorated shadow inset calculation is wrong +# on Win10 and paints a 1px artefact border after DWM redraws. Win11 +# (build >= 22000) keeps the default shadow because the upstream fix +# (tao #1052) renders correctly there and the shadow gives the rounded +# glass panel its depth. `default-features = false` opts out of the optional +# `serde` feature; we only need `os_info::get()` for the version check. +os_info = { version = "3", default-features = false } [dev-dependencies] wiremock = "0.6" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4e5b4d3..584fb64 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-start-dragging", "core:window:allow-set-size", "opener:default", - "dialog:default" + "dialog:default", + "window-state:default" ] } diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 7271008..6364197 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -83,12 +83,15 @@ //! //! [`AppState::pending_totp`]: super::state::AppState::pending_totp +use std::time::Duration; + use serde::Serialize; use specta::Type; use tauri::{ webview::PageLoadEvent, AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; +use tokio_util::sync::CancellationToken; use url::Url; use crate::commands::{ @@ -110,9 +113,143 @@ use crate::services::beanfun::{ get_verify_page_info as get_verify_page_info_service, submit_verify as submit_verify_service, VerifyOutcome, }, - LoginError, + LoginError, Session, }; +// ═══════════════════════════════════════════════════════════════════════ +// Session keep-alive (WPF pingWorker parity) +// ═══════════════════════════════════════════════════════════════════════ + +/// Interval between consecutive [`BeanfunClient::ping`] calls inside +/// [`run_ping_loop`]. +/// +/// 60 s matches WPF `MainWindow.pingWorker_DoWork` (`WaitSecs = 60`, +/// `MainWindow.xaml.cs` L2327). The Beanfun portal drops idle sessions +/// after a few minutes server-side; pinging every minute has proved +/// sufficient (WPF users reported sessions surviving for days). +const PING_INTERVAL: Duration = Duration::from_secs(60); + +/// Install a freshly-minted `(client, session)` pair onto +/// [`AppState::auth`] and spawn the session keep-alive ping loop. +/// +/// Centralises the four-site login-finalisation pattern: +/// +/// 1. Derive the [`SessionInfo`] DTO **before** moving `session` so we +/// can still return it to the caller. +/// 2. Wrap `client` + `session` in an [`AuthContext`] — each call +/// mints a fresh [`CancellationToken`] via +/// [`AuthContext::new`]. +/// 3. Install the context on [`AppState::auth`]. Holds the write lock +/// for the shortest possible window (no `.await` points between +/// the acquire and the release besides the `replace` itself). +/// 4. Spawn [`run_ping_loop`] with a clone of `client` + a clone of +/// the context's `ping_cancel` token. The clones share the same +/// cookie jar and cancellation signal as the installed context, +/// so `logout` -> `cancel()` stops the loop promptly. +/// +/// # Why clone `client` for the spawned task? +/// +/// [`BeanfunClient`] is cheap to clone (all inner fields are +/// `Arc<_>`), and cloning keeps ownership semantics simple: the +/// spawned task outlives any single `AppState::auth` read guard. +async fn install_session_and_start_ping( + state: &AppState, + client: BeanfunClient, + session: Session, +) -> SessionInfo { + let info = SessionInfo::from(&session); + let ctx = AuthContext::new(client, session); + let ping_client = ctx.client.clone(); + let ping_cancel = ctx.ping_cancel.clone(); + + // Atomic replace: if a prior `AuthContext` was still installed + // (e.g. the user re-logged in without first calling `logout`, + // or a `session_required` flow recovered mid-session), cancel + // its keep-alive loop so we don't leak an orphaned background + // task holding a stale cookie jar. + let prev = state.auth.write().await.replace(ctx); + if let Some(prev_ctx) = prev { + prev_ctx.ping_cancel.cancel(); + } + + spawn_ping_loop(ping_client, ping_cancel); + info +} + +/// Spawn [`run_ping_loop`] as a detached Tokio task. +/// +/// Split out of [`install_session_and_start_ping`] so unit tests can +/// drive [`run_ping_loop`] directly without a live Tokio reactor +/// observing a spawned future. +fn spawn_ping_loop(client: BeanfunClient, cancel: CancellationToken) { + tokio::spawn(run_ping_loop(client, cancel)); +} + +/// Periodically hit [`BeanfunClient::ping`] until `cancel` fires. +/// +/// Ports WPF `MainWindow.pingWorker_DoWork` +/// (`MainWindow.xaml.cs` L2322-2368). The WPF loop is: +/// +/// 1. Ping. +/// 2. Sleep `WaitSecs` (60 s), checking cancellation each second. +/// 3. Goto 1. +/// +/// Our Tokio rewrite uses [`tokio::select!`] on the cancel token so +/// shutdown fires immediately instead of waiting up to 1 s for the +/// next inner tick. Cancellation is also checked *during* the ping +/// itself so a mid-flight request doesn't delay shutdown by up to +/// the client timeout (tens of seconds). +/// +/// # Error handling +/// +/// Ping failures are swallowed at `tracing::debug!` level to match +/// WPF `BeanfunClient.Ping()`'s `catch { }` (bfClient.cs L193-212). +/// A transient network hiccup or a 5xx from the Beanfun portal must +/// not kill the keep-alive loop — the next tick 60 s later is the +/// retry. If the session is genuinely dead the user will find out +/// on their next meaningful action (Get OTP, launch game), just +/// like WPF. +async fn run_ping_loop(client: BeanfunClient, cancel: CancellationToken) { + run_ping_loop_with_interval(client, cancel, PING_INTERVAL).await; +} + +/// Inner implementation of [`run_ping_loop`] with the sleep interval +/// pulled out as a parameter. +/// +/// The production loop always passes [`PING_INTERVAL`] (60 s, matching +/// WPF). Splitting the interval out keeps the unit tests fast and +/// hermetic — they can drive the loop with a 50 ms cadence and assert +/// real wall-clock behaviour without depending on `start_paused = true`, +/// which interacts poorly with `wiremock`'s hyper server (the paused +/// runtime time freezes hyper's internal time wheel and the HTTP +/// request never resolves on Windows CI). +async fn run_ping_loop_with_interval( + client: BeanfunClient, + cancel: CancellationToken, + interval: Duration, +) { + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => return, + res = client.ping() => { + if let Err(err) = res { + tracing::debug!( + error = ?err, + "session keep-alive ping failed; will retry next tick" + ); + } + } + } + + tokio::select! { + biased; + _ = cancel.cancelled() => return, + _ = tokio::time::sleep(interval) => {} + } + } +} + /// Error code surfaced to the frontend when [`login_totp`] runs and /// there is no pending TOTP challenge on [`AppState::pending_totp`]. /// @@ -252,8 +389,7 @@ pub async fn login_regular( match outcome { Ok(session) => { - let info = SessionInfo::from(&session); - *state.auth.write().await = Some(AuthContext::new(client, session)); + let info = install_session_and_start_ping(&state, client, session).await; Ok(info) } Err(LoginError::TotpRequired(challenge)) => { @@ -322,8 +458,7 @@ pub async fn login_totp( .await?; *state.pending_totp.write().await = None; - let info = SessionInfo::from(&session); - *state.auth.write().await = Some(AuthContext::new(client, session)); + let info = install_session_and_start_ping(&state, client, session).await; Ok(info) } @@ -526,8 +661,7 @@ pub async fn login_qr_check(state: State<'_, AppState>) -> Result { let session = finalize_qr_login(&client, &init).await?; *state.pending_qr.write().await = None; - let info = SessionInfo::from(&session); - *state.auth.write().await = Some(AuthContext::new(client, session)); + let info = install_session_and_start_ping(&state, client, session).await; Ok(QrStatus::Approved { session: info }) } } @@ -963,8 +1097,7 @@ async fn handle_gamepass_page_load( return; } - let info = SessionInfo::from(&session); - *state.auth.write().await = Some(AuthContext::new(client, session)); + let info = install_session_and_start_ping(&state, client, session).await; tracing::info!( step = "GamepassCompletion.Success", @@ -1705,6 +1838,15 @@ pub async fn logout(state: State<'_, AppState>) -> Result<(), CommandError> { let prev_auth = state.auth.write().await.take(); if let Some(ctx) = prev_auth { + // Cancel the session keep-alive ping loop *first* so the + // background task doesn't race with `logout_service` and + // POST a keep-alive ping against a server the server-side + // logout just invalidated. `cancel()` is idempotent and + // non-blocking; the spawned task observes the signal on + // the next `tokio::select!` wake-up inside + // [`run_ping_loop`]. + ctx.ping_cancel.cancel(); + if let Err(err) = logout_service(&ctx.client).await { tracing::warn!( error = ?err, @@ -1726,6 +1868,261 @@ mod tests { AppState::new(PathBuf::from(r"C:\tmp")) } + // ── Session keep-alive (run_ping_loop) ──────────────────────── + + use crate::services::beanfun::Endpoints; + use std::time::Duration as StdDuration; + use url::Url; + use wiremock::matchers::{method as wm_method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn fake_session() -> Session { + Session::new( + LoginRegion::TW, + "skey-test", + "web-token-test", + "acc-test", + LoginRegion::TW.default_service_code(), + LoginRegion::TW.default_service_region(), + ) + } + + /// Build a [`BeanfunClient`] whose `portal_base` points at + /// `server`. Shared between the cancellation and error-handling + /// tests for [`run_ping_loop`]. + fn ping_client_against(server: &MockServer) -> BeanfunClient { + let base = Url::parse(&format!("{}/", server.uri())).expect("mock URL parses"); + let endpoints = Endpoints { + login_base: base.clone(), + portal_base: base.clone(), + newlogin_base: base, + }; + let mut cfg = ClientConfig::for_region(LoginRegion::TW); + cfg.endpoints = endpoints; + BeanfunClient::new(cfg).expect("client builds") + } + + async fn mount_echo_token_200(server: &MockServer) { + Mock::given(wm_method("GET")) + .and(wm_path("/beanfun_block/generic_handlers/echo_token.ashx")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(server) + .await; + } + + /// Cancelling the token *before* the first ping fires must + /// terminate [`run_ping_loop`] without issuing any HTTP + /// request. This pins the shutdown semantics that `logout` + /// relies on: `ctx.ping_cancel.cancel()` takes effect + /// immediately, not "after the next tick". + #[tokio::test] + async fn run_ping_loop_exits_promptly_when_cancelled_before_first_tick() { + let server = MockServer::start().await; + mount_echo_token_200(&server).await; + let client = ping_client_against(&server); + + let cancel = CancellationToken::new(); + cancel.cancel(); + + tokio::time::timeout(StdDuration::from_secs(5), run_ping_loop(client, cancel)) + .await + .expect("loop must return promptly on pre-cancelled token"); + + let requests = server.received_requests().await.expect("log enabled"); + assert!( + requests.is_empty(), + "no ping should fire if token is cancelled before loop entry; got {} request(s)", + requests.len(), + ); + } + + /// After one successful ping, cancelling the token mid-sleep + /// must cause the loop to exit on the next `select!` wake + /// without waiting the full [`PING_INTERVAL`]. This is the + /// hot path — a user that logs out 1 s after login should + /// not leave a 59 s zombie task running. + #[tokio::test] + async fn run_ping_loop_exits_during_sleep_after_first_ping() { + let server = MockServer::start().await; + mount_echo_token_200(&server).await; + let client = ping_client_against(&server); + + let cancel = CancellationToken::new(); + let cancel_for_loop = cancel.clone(); + let handle = tokio::spawn(async move { run_ping_loop(client, cancel_for_loop).await }); + + // Wait until the first ping has been observed by the mock, + // then cancel. We poll instead of `sleep` so the test stays + // deterministic across slow CI machines. + let deadline = tokio::time::Instant::now() + StdDuration::from_secs(5); + loop { + let count = server + .received_requests() + .await + .map(|r| r.len()) + .unwrap_or(0); + if count >= 1 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!("first ping never arrived within 5s"); + } + tokio::time::sleep(StdDuration::from_millis(10)).await; + } + + cancel.cancel(); + + tokio::time::timeout(StdDuration::from_secs(5), handle) + .await + .expect("loop must exit promptly after cancel") + .expect("spawned task must not panic"); + } + + /// A 5xx response from `echo_token.ashx` must NOT kill the + /// loop — WPF's `catch { }` swallows errors and the next tick + /// retries. We pin this by waiting for *two* requests to land + /// against a server that always returns 500, using a 50 ms + /// interval so the test stays sub-second. + /// + /// We deliberately avoid `start_paused = true` here: the paused + /// runtime time freezes hyper's internal time wheel, and on + /// Windows CI the wiremock-served request never resolves + /// (observed: the rust test job hung past the 6 h GitHub + /// timeout). The interval-injection seam in + /// [`run_ping_loop_with_interval`] lets us keep real time + a + /// short cadence instead. + #[tokio::test] + async fn run_ping_loop_keeps_running_after_ping_failure() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/beanfun_block/generic_handlers/echo_token.ashx")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let client = ping_client_against(&server); + + let cancel = CancellationToken::new(); + let cancel_for_loop = cancel.clone(); + let handle = tokio::spawn(async move { + run_ping_loop_with_interval(client, cancel_for_loop, StdDuration::from_millis(50)).await + }); + + // Poll for two requests to land — at 50 ms cadence the second + // ping should arrive within ~100 ms even on slow CI; the 10 s + // ceiling is a generous backstop, not the expected duration. + let deadline = tokio::time::Instant::now() + StdDuration::from_secs(10); + loop { + let count = server + .received_requests() + .await + .map(|r| r.len()) + .unwrap_or(0); + if count >= 2 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!( + "second ping never arrived within 10s (got {count}); 5xx must not stop the loop" + ); + } + tokio::time::sleep(StdDuration::from_millis(10)).await; + } + + cancel.cancel(); + tokio::time::timeout(StdDuration::from_secs(5), handle) + .await + .expect("loop exits after cancel even while failing") + .expect("spawned task must not panic"); + } + + /// End-to-end wiring for the login-path helper: + /// `install_session_and_start_ping` must populate `AppState::auth` + /// *and* spawn a ping loop that actually fires. If wiring is + /// broken we'd observe the auth context installed but no + /// request ever hitting the mock server. + #[tokio::test] + async fn install_session_and_start_ping_populates_auth_and_fires_ping() { + let server = MockServer::start().await; + mount_echo_token_200(&server).await; + let client = ping_client_against(&server); + + let state = empty_state(); + // Minimal placeholder session — fields aren't inspected by + // the keep-alive loop, only `client` is. + let session = fake_session(); + + let _info = install_session_and_start_ping(&state, client, session).await; + + assert!( + state.auth.read().await.is_some(), + "auth context must be installed", + ); + + // Wait for first ping to fire so we know the spawn actually + // landed a live task. + let deadline = tokio::time::Instant::now() + StdDuration::from_secs(5); + loop { + let count = server + .received_requests() + .await + .map(|r| r.len()) + .unwrap_or(0); + if count >= 1 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!("ping loop never fired the first request within 5s"); + } + tokio::time::sleep(StdDuration::from_millis(10)).await; + } + + // Clean up so the spawned task doesn't keep looping in the + // background after this test returns. + let taken = state.auth.write().await.take(); + if let Some(ctx) = taken { + ctx.ping_cancel.cancel(); + } + } + + /// Installing a second session must cancel the first session's + /// ping loop. Without this the old task would hold the old + /// cookie jar alive and keep calling the mock forever. + #[tokio::test] + async fn install_session_and_start_ping_cancels_previous_loop() { + let server = MockServer::start().await; + mount_echo_token_200(&server).await; + + let state = empty_state(); + + let first_client = ping_client_against(&server); + install_session_and_start_ping(&state, first_client, fake_session()).await; + let first_token = state + .auth + .read() + .await + .as_ref() + .expect("first install populates auth") + .ping_cancel + .clone(); + assert!( + !first_token.is_cancelled(), + "fresh install must leave token uncancelled", + ); + + let second_client = ping_client_against(&server); + install_session_and_start_ping(&state, second_client, fake_session()).await; + assert!( + first_token.is_cancelled(), + "replacing an auth context must cancel the prior ping loop", + ); + + // Clean up the second loop. + let taken = state.auth.write().await.take(); + if let Some(ctx) = taken { + ctx.ping_cancel.cancel(); + } + } + // ── split_otp_digits ────────────────────────────────────────── #[test] diff --git a/src-tauri/src/commands/state.rs b/src-tauri/src/commands/state.rs index 4b76cb2..8ca812d 100644 --- a/src-tauri/src/commands/state.rs +++ b/src-tauri/src/commands/state.rs @@ -68,6 +68,7 @@ use std::path::PathBuf; use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; use crate::services::beanfun::{ client::BeanfunClient, @@ -103,6 +104,29 @@ pub struct AuthContext { /// fields are redacted by the `Debug` impl — see /// [`Session`]'s module docs for the sensitivity policy. pub session: Session, + + /// Cancellation handle for the session keep-alive ping loop + /// ([`run_ping_loop`][crate::commands::auth::run_ping_loop]), + /// ported from the WPF `pingWorker` background worker + /// (`MainWindow.xaml.cs` L2322-2368). Logout / session-clear + /// paths call [`.cancel()`][CancellationToken::cancel] on this + /// token so the spawned task exits promptly instead of living on + /// past the `AuthContext` it belongs to. + /// + /// Each [`AuthContext::new`] call mints a **fresh** token + /// ([`CancellationToken::new`]), so logging in twice spawns two + /// independent loops whose lifecycles are individually bounded — + /// a previous login's ping task is always cancelled as part of + /// that login's `clear_all_auth_state` before the new loop is + /// spawned (see `commands::auth`). + /// + /// Cloning an `AuthContext` clones the token, but + /// [`CancellationToken`] is internally `Arc`-backed: every clone + /// observes the same cancel signal. That is *exactly* the + /// semantics we want for the `require_auth` escape-hatch + /// (callers take an owned snapshot, drop the `RwLock` guard, + /// and the snapshot still points at the live token). + pub ping_cancel: CancellationToken, } impl AuthContext { @@ -110,9 +134,16 @@ impl AuthContext { /// single swap-able unit. /// /// Callers (login commands) should immediately `write()` the - /// result into [`AppState::auth`] so downstream commands see it. + /// result into [`AppState::auth`] so downstream commands see it, + /// then spawn the session keep-alive ping loop using + /// [`ping_cancel`][Self::ping_cancel] as the shutdown signal + /// (see `commands::auth::spawn_ping_loop`). pub fn new(client: BeanfunClient, session: Session) -> Self { - Self { client, session } + Self { + client, + session, + ping_cancel: CancellationToken::new(), + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da02dee..d087155 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,36 @@ fn resolve_storage_root() -> Result { Ok(std::env::temp_dir().join("Beanfun")) } +/// Detects whether the host is running Windows 10 (build number < 22000). +/// +/// Used by [`run`] as a gate for the `set_shadow(false)` workaround for +/// upstream tauri-apps/tauri#11654 / #13176 — the bug only manifests on +/// Windows 10 because the undecorated-shadow inset calculation in `tao` +/// paints a 1px artefact border after DWM redraws. Windows 11 (build +/// number >= 22000) renders the same shadow correctly since tao #1052 +/// landed, and the shadow there is desirable (it's what gives the rounded +/// glass panel its depth), so we keep the default behaviour for it. +/// +/// Returns `false` on any non-Windows host (defensive — the caller is +/// already behind `#[cfg(target_os = "windows")]`, this mirrors that +/// contract) and on any Windows host whose `os_info` reports a non- +/// semantic version (`Unknown` / `Rolling` / `Custom`). "Report uncertain +/// version → leave shadow alone" is the safer default: keeping the +/// default shadow on an unknown host is a visual downside at worst, +/// whereas wrongly disabling it on a Win11 host would cause a visible +/// regression there. +#[cfg(target_os = "windows")] +fn is_windows_10() -> bool { + let info = os_info::get(); + if info.os_type() != os_info::Type::Windows { + return false; + } + match info.version() { + os_info::Version::Semantic(_, _, build) => *build < 22000, + _ => false, + } +} + /// Regenerate `src/types/bindings.ts` from the live /// `tauri-specta` builder. /// @@ -340,6 +370,22 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + // Remember-window-position: restores the user's last screen + // position on launch (e.g. after dragging Beanfun onto a + // secondary monitor). `StateFlags::POSITION` intentionally + // *excludes* SIZE / MAXIMIZED / DECORATIONS — `tauri.conf.json` + // ships `resizable: false` and the router's per-route + // `fitWindow` (`src/router/index.ts`) is the canonical owner + // of the window's width/height. Persisting size here would + // race those handlers and the user would see the window snap + // to the saved size before snapping again to the route-driven + // size on the first navigation. The state file lives under + // the standard `appConfigDir` (Windows: `%APPDATA%\tw.beanfun.app\`). + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags(tauri_plugin_window_state::StateFlags::POSITION) + .build(), + ) .manage(app_state) // Tauri-managed handle to the tray ID so commands (e.g. // `system::minimize_main_window`) can drive the tray without @@ -351,6 +397,23 @@ pub fn run() { if let Some(tray_id) = tray::build_tray(app) { *tray_state_for_setup.lock().unwrap() = Some(tray_id); } + + // Issue #235 workaround — on Windows 10 the undecorated-window + // shadow inset calculation paints a 1px coloured border on DWM + // redraws (upstream tauri-apps/tauri#11654 / #13176). Disabling + // the shadow removes the artefact. Windows 11 (build >= 22000) + // keeps the default shadow because the tao #1052 fix renders + // correctly there and the shadow gives the rounded glass panel + // its depth. No-op on non-Windows targets. + #[cfg(target_os = "windows")] + if is_windows_10() { + if let Some(window) = app.get_webview_window("main") { + if let Err(err) = window.set_shadow(false) { + tracing::warn!("set_shadow(false) on Windows 10 main window failed: {err}"); + } + } + } + Ok(()) }) .on_window_event(move |window, event| { diff --git a/src-tauri/src/services/beanfun/client.rs b/src-tauri/src/services/beanfun/client.rs index 9aeb379..a24e497 100644 --- a/src-tauri/src/services/beanfun/client.rs +++ b/src-tauri/src/services/beanfun/client.rs @@ -343,6 +343,70 @@ impl BeanfunClient { .map_err(|e| LoginError::InvalidUrl(format!("newlogin URL `{path}`: {e}"))) } + /// Hit the Beanfun portal's session keep-alive endpoint so the + /// server's inactivity timer is reset. + /// + /// Ports the WPF `BeanfunClient.Ping()` method (`bfClient.cs` + /// L193-212). The original implementation is: + /// + /// ```text + /// public void Ping() + /// { + /// try + /// { + /// string url = "https://" + (TW ? "tw" : "bfweb.hk") + + /// ".beanfun.com/beanfun_block/generic_handlers/echo_token.ashx?webtoken=1"; + /// this.DownloadData(url); + /// } + /// catch { } + /// } + /// ``` + /// + /// WPF drove this from `MainWindow.pingWorker_DoWork` + /// (`MainWindow.xaml.cs` L2322-2368) on a 60-second loop so that + /// the Beanfun backend's session-inactivity timeout never fired + /// — without it, an idle session is reaped server-side after a + /// few minutes and the next user action (Get OTP, launch game) + /// fails with a stale-cookie error. See PR #237 for the bug + /// report. + /// + /// # Semantics + /// + /// - Follows redirects and uses the normal cookie jar so the + /// server sees the same `bfWebToken` that the business-logic + /// calls do. + /// - **Ignores the response body** (same as WPF): the request is + /// only useful for its side effect of resetting the server's + /// inactivity timer. We don't even call `bounded_text` because + /// buffering the body wastes bytes on the hot path. + /// - Non-2xx responses surface as + /// [`LoginError::Http`][crate::services::beanfun::error::LoginError::Http] + /// via `error_for_status()` so the caller can log; the + /// [`run_ping_loop`][crate::commands::auth::run_ping_loop] + /// wrapper swallows them at `tracing::debug!` level to mirror + /// WPF's `catch { }`. + /// + /// # Region routing + /// + /// Derived from the client's configured [`LoginRegion`]: + /// + /// | Region | Endpoint | + /// |--------|-----------------------------------------------------------------------| + /// | TW | `https://tw.beanfun.com/beanfun_block/generic_handlers/echo_token.ashx?webtoken=1` | + /// | HK | `https://bfweb.hk.beanfun.com/beanfun_block/generic_handlers/echo_token.ashx?webtoken=1` | + /// + /// Both live on `portal_base`, so we reuse [`portal_url`] rather + /// than hardcoding the host — keeps wiremock tests straightforward + /// via [`Endpoints::custom`]. + /// + /// [`portal_url`]: Self::portal_url + pub async fn ping(&self) -> Result<(), LoginError> { + let mut url = self.portal_url("beanfun_block/generic_handlers/echo_token.ashx")?; + url.query_pairs_mut().append_pair("webtoken", "1"); + self.http.get(url).send().await?.error_for_status()?; + Ok(()) + } + /// Read `resp`'s body as UTF-8, capping the accumulated bytes at /// [`ClientConfig::max_body_size`]. /// diff --git a/src-tauri/tests/ping.rs b/src-tauri/tests/ping.rs new file mode 100644 index 0000000..d2e469e --- /dev/null +++ b/src-tauri/tests/ping.rs @@ -0,0 +1,242 @@ +//! Integration tests for the session keep-alive ping endpoint +//! (`BeanfunClient::ping`), ported from the WPF `pingWorker` +//! keep-alive loop (`MainWindow.xaml.cs` L2322-2368 calling +//! `BeanfunClient.Ping()` at `bfClient.cs` L193-212). +//! +//! The Beanfun portal expires idle sessions server-side; WPF +//! sidesteps this by pinging `echo_token.ashx?webtoken=1` every 60 s +//! so the backend keeps the session warm. These tests pin the +//! wire-shape contract (method, path, query, region-host routing) +//! that [`crate::commands::auth::run_ping_loop`] depends on. +//! +//! | WPF parity detail | Covered by | +//! |----------------------------------------------------------|--------------------------------------------------| +//! | GET verb + `echo_token.ashx` path | `ping_issues_get_against_echo_token_path` | +//! | `webtoken=1` query string (matches WPF URL) | `ping_attaches_webtoken_one_query_param` | +//! | 2xx response → `Ok(())` | `ping_returns_ok_on_success_body_is_ignored` | +//! | 5xx response → `LoginError::Http` | `ping_surfaces_http_error_on_5xx` | +//! | TW routes through `portal_base` | `ping_tw_routes_through_portal_base` | +//! | HK routes through `portal_base` (its own HK host) | `ping_hk_routes_through_portal_base` | + +use beanfun_lib::services::beanfun::{ + BeanfunClient, ClientConfig, Endpoints, LoginError, LoginRegion, +}; +use url::Url; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +// ----------------------------------------------------------------------------- +// Test fixtures +// ----------------------------------------------------------------------------- + +/// Build a [`BeanfunClient`] whose `portal_base` points at `server` +/// so `/beanfun_block/generic_handlers/echo_token.ashx` requests +/// land on the mock. `login_base` / `newlogin_base` are aliased to +/// the same mock so an accidental mis-routing surfaces as an +/// assertion failure on `server.received_requests` (whatever path +/// was hit would still be visible in the log) rather than a +/// connection refused from the real host. +fn single_server_client(server: &MockServer, region: LoginRegion) -> BeanfunClient { + let base = Url::parse(&format!("{}/", server.uri())).expect("mock URL parses"); + let endpoints = Endpoints { + login_base: base.clone(), + portal_base: base.clone(), + newlogin_base: base, + }; + let mut cfg = ClientConfig::for_region(region); + cfg.endpoints = endpoints; + BeanfunClient::new(cfg).expect("client builds") +} + +/// Three distinct mock servers so we can prove *which* base +/// `ping` routed through. If ping ever drifts onto `login_base` or +/// `newlogin_base` it would fail the tight `received_requests` +/// count assertion below. +fn split_server_client( + portal_server: &MockServer, + login_server: &MockServer, + newlogin_server: &MockServer, + region: LoginRegion, +) -> BeanfunClient { + let url = |s: &MockServer| Url::parse(&format!("{}/", s.uri())).expect("mock URL parses"); + let endpoints = Endpoints { + login_base: url(login_server), + portal_base: url(portal_server), + newlogin_base: url(newlogin_server), + }; + let mut cfg = ClientConfig::for_region(region); + cfg.endpoints = endpoints; + BeanfunClient::new(cfg).expect("client builds") +} + +const ECHO_TOKEN_PATH: &str = "/beanfun_block/generic_handlers/echo_token.ashx"; + +async fn mount_echo_token(server: &MockServer, status: u16) { + Mock::given(method("GET")) + .and(path(ECHO_TOKEN_PATH)) + .respond_with(ResponseTemplate::new(status).set_body_string("echo-ok")) + .mount(server) + .await; +} + +// ----------------------------------------------------------------------------- +// Wire-shape contract +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn ping_issues_get_against_echo_token_path() { + let server = MockServer::start().await; + mount_echo_token(&server, 200).await; + + let client = single_server_client(&server, LoginRegion::TW); + client.ping().await.expect("ping succeeds against 200 mock"); + + let requests = server.received_requests().await.expect("log is enabled"); + assert_eq!( + requests.len(), + 1, + "ping should issue exactly one request per call", + ); + let req: &Request = &requests[0]; + assert_eq!(req.method.as_str(), "GET"); + assert_eq!( + req.url.path(), + ECHO_TOKEN_PATH, + "path must match WPF `bfClient.Ping()` URL verbatim", + ); +} + +#[tokio::test] +async fn ping_attaches_webtoken_one_query_param() { + let server = MockServer::start().await; + // `query_param` matcher will only succeed if the inbound URL + // carries `webtoken=1` — pin the same query string WPF uses. + Mock::given(method("GET")) + .and(path(ECHO_TOKEN_PATH)) + .and(query_param("webtoken", "1")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let client = single_server_client(&server, LoginRegion::TW); + client.ping().await.expect("ping succeeds"); + // MockServer's Drop assertion (via `.expect(1)`) verifies the + // `webtoken=1` matcher was hit exactly once. +} + +// ----------------------------------------------------------------------------- +// Result mapping +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn ping_returns_ok_on_success_body_is_ignored() { + // WPF `BeanfunClient.Ping()` reads the body purely for the + // `Console.WriteLine` debug trace; the *result* is always + // best-effort. Our Rust port must not parse / bounds-check the + // body either — a large response body must NOT surface as a + // `LoginError::BodyTooLarge` because we're not calling + // `bounded_text` on the ping path. + let huge_body = "x".repeat(16 * 1024 * 1024); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(ECHO_TOKEN_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_string(huge_body)) + .mount(&server) + .await; + + let client = single_server_client(&server, LoginRegion::TW); + client + .ping() + .await + .expect("ping returns Ok even when body is huge"); +} + +#[tokio::test] +async fn ping_surfaces_http_error_on_5xx() { + let server = MockServer::start().await; + mount_echo_token(&server, 500).await; + + let client = single_server_client(&server, LoginRegion::TW); + let err = client + .ping() + .await + .expect_err("500 response must surface as LoginError::Http"); + + assert!( + matches!(err, LoginError::Http(_)), + "5xx should map to LoginError::Http, got {err:?}", + ); +} + +// ----------------------------------------------------------------------------- +// Region routing +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn ping_tw_routes_through_portal_base() { + let portal = MockServer::start().await; + let login = MockServer::start().await; + let newlogin = MockServer::start().await; + mount_echo_token(&portal, 200).await; + mount_echo_token(&login, 200).await; + mount_echo_token(&newlogin, 200).await; + + let client = split_server_client(&portal, &login, &newlogin, LoginRegion::TW); + client.ping().await.expect("ping succeeds"); + + assert_eq!( + portal.received_requests().await.expect("log enabled").len(), + 1, + "TW ping must hit portal_base", + ); + assert!( + login + .received_requests() + .await + .expect("log enabled") + .is_empty(), + "TW ping must not hit login_base", + ); + assert!( + newlogin + .received_requests() + .await + .expect("log enabled") + .is_empty(), + "TW ping must not hit newlogin_base", + ); +} + +#[tokio::test] +async fn ping_hk_routes_through_portal_base() { + // WPF `bfClient.Ping()` uses `bfweb.hk.beanfun.com` for the HK + // region. `portal_base` is exactly that host in + // `Endpoints::hk`, so the HK ping path is simply "portal_base + // with an HK URL" — same code path, different configured host. + let portal = MockServer::start().await; + let login = MockServer::start().await; + let newlogin = MockServer::start().await; + mount_echo_token(&portal, 200).await; + mount_echo_token(&login, 200).await; + mount_echo_token(&newlogin, 200).await; + + let client = split_server_client(&portal, &login, &newlogin, LoginRegion::HK); + client.ping().await.expect("ping succeeds"); + + assert_eq!( + portal.received_requests().await.expect("log enabled").len(), + 1, + "HK ping must hit portal_base (mapped to HK host at runtime)", + ); + assert!(login + .received_requests() + .await + .expect("log enabled") + .is_empty(),); + assert!(newlogin + .received_requests() + .await + .expect("log enabled") + .is_empty(),); +} diff --git a/src/pages/About.vue b/src/pages/About.vue index 879ba39..c0b0b4b 100644 --- a/src/pages/About.vue +++ b/src/pages/About.vue @@ -258,7 +258,7 @@ onMounted(() => {
-
+
{ + /* + * Fast path — "navigate back from Settings / About" UX fix. + * + * `AccountList` is a regular routed component, so visiting + * `/settings` or `/about` unmounts it and visiting back + * remounts it. Without this skip, every return would re-run + * the full bootstrap (`game.loadGames` + `selectActiveGame` + + * `loadList`) and the user would see the spinner + an empty + * list flash before the data refilled. Users with custom + * drag-and-drop sort orders read that flash as "my order was + * reset" because the persistent CSV (`AccountOrder__`) + * is only re-applied AFTER the HTTP response lands. + * + * Skipping is safe because every code path that *should* + * cause a re-fetch already clears `serviceAccounts` first: + * + * - logout / session expired → `clearAccountSession` resets + * the store (router guard + `registerSessionExpiredHandler`). + * - change game → `setActiveService` clears the store before + * the new fetch. + * - first cold mount after login → store is empty by + * construction, so the predicate is false and the full + * bootstrap runs. + * + * `loadState` would otherwise sit at its initial `'loading'` + * sentinel forever (no `loadList` to flip it to `'ready'`), + * so we flip it inline before returning. + */ + if (account.serviceAccounts.length > 0 && auth.session !== null) { + loadState.value = 'ready' + return + } + await game.loadGames() if (game.loadState === 'error') { @@ -1857,6 +1890,93 @@ watch(rowsRef, (el) => { }) onBeforeUnmount(destroySortable) + +/* --------------- Enter hotkey (WPF / Beanfun B6 parity) --------------- */ + +/** + * Mirrors the legacy Beanfun (B6) client: once a service account row is + * highlighted, pressing `Enter` kicks off the same Get-OTP flow as + * clicking the button. + * + * # Why a `window`-level listener (vs a row-level `@keydown`) + * + * Clicking a row in our layout sets `account.selectedSid` but does not + * move DOM focus onto the `
  • ` (rows are intentionally not tab stops + * — adding `tabindex` would change tab order and leak focus rings onto + * the whole list). Without focus on the row, a row-scoped `@keydown` + * never fires. A window-level listener sees the Enter keystroke + * regardless of which non-form element currently holds focus, which is + * the same behaviour WPF `lstViewAccount.KeyDown` had on Key.Enter. + * + * # Guards + * + * 1. `event.key === 'Enter'` — only Enter, and not on synthetic repeats + * (holding Enter shouldn't spam-fire the OTP IPC), and not mid-IME + * composition (CJK users would otherwise trigger an OTP every time + * they commit a candidate). + * 2. Focus inside a form control (`input` / `textarea` / `[contenteditable]`) + * → let the control handle its own Enter (e.g. submitting a dialog + * form). Our `readonly` OTP `` passes this filter so Enter + * while it happens to hold focus after a "Copy OTP" click still + * routes through. + * 3. Focus on a `