Skip to content

fix: ServiceAccountInfo selectable + Win10 shadow + window auto-fit (#234 #235 #236)#237

Merged
YCC3741 merged 15 commits intocodefrom
fix/issues-234-235-236
Apr 22, 2026
Merged

fix: ServiceAccountInfo selectable + Win10 shadow + window auto-fit (#234 #235 #236)#237
YCC3741 merged 15 commits intocodefrom
fix/issues-234-235-236

Conversation

@YCC3741
Copy link
Copy Markdown
Collaborator

@YCC3741 YCC3741 commented Apr 21, 2026

Closes #234, #235, #236.

Summary

#234 — 遊戲帳號詳情欄位可選

  • src/windows/ServiceAccountInfo.vue.service-account-info__valueuser-select: text override
  • App.vue 的 global user-select: none 導致帳號 / 編號 / 名稱無法複製;override 後讓使用者能複製貼到客服信件裡
  • WPF 原版 ServiceAccountInfo.xaml 對應位本來就是 selectable TextBlock,behaviour 一致

#235 — Win10 視窗框線

  • 原因:upstream tauri-apps/tauri#11654 / #13176 — Win10 上 tao undecorated-shadow inset 計算有誤,DWM 重繪後出現 1px 色差框線
  • Fix:條件式 set_shadow(false)只在 Win10 套用(build number < 22000)。Win11 保留預設 shadow 讓圓角玻璃面有立體感
  • 新增 os_info = "3"(cfg(windows) scope, default-features = false)做版本偵測
  • 偵測 function is_windows_10() 保守:非 Windows / 非 semantic version 一律 false(誤判 Win11 會失去 shadow → visible regression,誤判 Win10 沒套 shadow 才是保守選擇)

#236 — 視窗自適應

src/router/index.ts::installRouterGuards 裡的 fitWindow 原本有 6 個相關 bug:

  • Bug C (cap=900px):Settings / AccountList 內容超過 900px 就被截斷,出現內部 scroll bar(「從設定返回有滑桿問題」)→ commit 3 改抓 window.screen.availHeight - 50
  • Bug A (timing race):setTimeout(80) 不對齊 Vue render flush/paint 時機,slow IPC 下會抓到未渲染完的 DOM → commit 4 改用 rAF
  • Bug B (layout-restore race):height='auto' → async setSize().then(height='100vh') 之間 flex layout 會崩 → commit 4 改成 frame flip
  • Bug D (observer feedback loop):height auto-flip 自己觸發 ResizeObserver → 多餘 frame flip → computed style 不變,coalesce 成 no-op
  • Bug E (duplicate initial fit):observe() 會帶一次 fire callback,加上初始化呼叫的 fit 重複觸發 → skipFirstObserverCallback flag 略過第一次
  • Bug F (follow-up) (i18n 切換仍有 scroll):[data-window-root] 永遠 100vh,ResizeObserver 根本沒有「內容長高」可 fire → commit 5 同時對實際內容 wrapper 的 data-window-content 一併 observe;skip-initial 從「第一次 callback」改為「第一個 rAF 之前一律略過」以支援多目標

額外:pendingFrame handle 配合 afterEach + observer callback 取消 → afterEach storm(//login/ 連續兩次 redirect)自動合併為單次 fit on 真正 destination。

多帳號頁面太長 / OTP 被推下去(#236 延伸)

  • Commit 6 .account-list__scroll 改為 overflow: hidden 的 flex column;__container / __list / __list-body 沿 flex: 1 + min-height: 0 鏈條,讓內層 list 變唯一的 scroller,OTP footer 永遠 anchored 在視窗底部
  • 移除原本 max-height: 300px 的 cap(這是讓 OTP 被擠出視窗外的主因)

選中帳號按 Enter = GetOtp(B6 parity)

  • Commit 7 AccountList.vue 加入 handleGlobalEnter + window-level keydown listener
  • Guards(詳見 SUT docblock):非 Enter / IME / repeat 忽略;focus 在 <input>/<textarea>/<button>/contenteditable 忽略;.el-overlay 內部(ElDialog/ElMessageBox)忽略、進行中 selection 變動忽略
  • 新建獨立 spec AccountList.EnterHotkey.spec.ts 涵蓋每條 guard branch + onBeforeUnmount leak check
  • 沒選 account 時還是靜默 no-op(與 click 觸發 MsgSelectAccount toast 不同),減少 fat-finger Enter 被當作 UX

內文字體微調(#236 延伸)

  • Commit 8 每個 class +1 字級大小(+0.0625rem,+1px@16px root),__title 1.625→1.75, __row-name 0.875→0.9375, __otp-field 1.125→1.25 等,相對比例不變

設定頁 tooltip 換行 / 寬度(用戶回報「關閉硬體加速」說明被截斷)

  • Commit 9 Settings.vue 全部四個 el-tooltip 加上 popper-class="settings__tip-popper"
  • 新增非 scoped style block:.settings__tip-popper.el-popper { max-width: 280px; white-space: pre-line; word-break: break-word; line-height: 1.5; } — 因為 popper 被 teleport 到 document.body,scoped 樣式抓不到
  • pre-line 讓 i18n 字串裡的 \n 正常換行,max-width + word-break 防止單行擠出視窗

Session keep-alive — 連線過一段時間會斷(用戶回報舊版 5.9.1 可放好幾天不斷)

  • Commit 11 移植 WPF MainWindow.pingWorker_DoWork (xaml.cs L2322-2368) → BeanfunClient.Ping() (bfClient.cs L193-212)
  • 新增 BeanfunClient::ping()echo_token.ashx?webtoken=1(與 WPF wire-shape 完全一致)
  • 新增 commands::auth::run_ping_looptokio::spawn 跑 60 秒 cadence 的 keep-alive task
  • AuthContextping_cancel: CancellationToken 欄位;logout 直接 cancel() 立刻收掉 background task
  • install_session_and_start_ping helper 統一登入完成後的 (clone, install, spawn) pattern;若已有 prev AuthContext 也會 cancel 它的舊 token,避免「未 logout 直接 re-login」漏 leak
  • 錯誤一律 tracing::debug! 吞掉,配合 WPF catch { } 的 best-effort 語意(5xx / 連線錯誤不會殺 loop)
  • tests/ping.rs integration 6 case 鎖定 wire-shape (GET / path / webtoken=1 / 2xx ok / 5xx Error / TW + HK 都走 portal_base);commands::auth::tests 4 個 unit 鎖定 cancel-before-first-tick / cancel-during-sleep / 5xx-不殺 loop / install-會-cancel-prev

記憶視窗位置(用戶回報希望關掉視窗下次能從同位置開)

  • Commit 13tauri-plugin-window-state = "2"(官方 plugin)
  • 只開 StateFlags::POSITION — 不存 SIZE / MAXIMIZED:tauri.conf.jsonresizable: false 且 router 的 fitWindow 是 size 唯一 owner,存 size 會跟它打架(用戶看到「先回到舊 size,第一次切 route 又跳到新 size」的 flicker)
  • 設定檔走標準 appConfigDir(Win: %APPDATA%\tw.beanfun.app\),跟其他 Beanfun local data 共用 uninstall path
  • 前端零修改:plugin 自動 hook WindowEvent::CloseRequested / Destroyed 存、init 時自動 restore

從 Settings/About 返回 AccountList 不重 fetch(用戶回報拖曳排序「會被刷掉」)

  • Commit 14 setupGameOnMount 開頭加 fast-path:account.serviceAccounts.length > 0 && auth.session !== null → flip loadState='ready' 後直接 return
  • 根因:AccountList 是 routed component,/settings 進去再回來會 unmount + remount,每次都跑完整 bootstrap (game.loadGames + selectActiveGame + loadList)。spinner + 短暫空 list flash 讓拖曳排序「看起來」被重置(實際上 Config.xml::AccountOrder_<code>_<region> 一直在,只是 re-apply 在 HTTP 回來之後)
  • Skip 安全性:所有「真該 re-fetch」的 path 都已經會先清掉 serviceAccounts
    • auth.logout() / auth.session_requiredclearAccountSession
    • 換遊戲 → setActiveService 清掉 store
    • 登入後 cold mount → store 本來就空,predicate false,會跑完整 bootstrap
  • 新增兩個 spec case:fast-path 命中時不該打 listGames/getAccounts/setActiveService;store 空時還是要走完整 bootstrap(baseline 防 over-aggressive skip)

CI 雜項

  • Commit 12 cargo fmtlib.rs視窗框線問題 #235 win10 shadow helper(前一輪 CI 失敗根因:多行 tracing::warn! 被 rustfmt 折成單行)
  • Commit 15 Deflake run_ping_loop_keeps_running_after_ping_failure:原本用 #[tokio::test(start_paused = true)] + wiremock,paused runtime time 凍住 hyper 內部 time wheel,Windows CI 一路卡到 GitHub default 6h timeout。改為 inject interval seam (run_ping_loop_with_interval),test 用 50ms 真實時間 cadence。同時把 .github/workflows/ci.yml 的 rust job 加上 timeout-minutes: 25,未來再 hang 不會再吃滿 6 小時 budget

Why one PR

11 個 issue/feedback (3 issue + 8 follow-up requests) 都屬於 window chrome / AccountList 佈局 / session 行為 / visual polish 同一 surface area;切 11 個 PR 評述成本太高。不過每個 commit 都嚴守 scope 對應一個 bug / concern 以便 revert / cherry-pick。

Test plan

  • npx vitest run — 597/597 pass(54 AccountList + 6 EnterHotkey + 537 其他)
  • npx vue-tsc --noEmit clean
  • npx eslint + prettier --check clean
  • cargo check + cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • cargo test — auth (40) + ping integration (6) + 全 suite green on local + CI run 24752238006 (8.4 min)
  • Win10 真環境可選 — 請看 issue 視窗框線問題 #235 使用者 (@Muerimes) 協助驗證 merge 後 release build 框線是否消失
  • Win11 smoke test — 我端已確認:
    • 視窗框線問題 #235 Win11 shadow 保留不變
    • 視窗自適應 #236 視窗自適應在首次啟動 / 登入後 / 設定 / About 返回 / 語言切換 五個情境都正常
    • 多帳號時 OTP 永遠可見(不再 scroll)
    • 選中帳號按 Enter 觸發 GetOtp
    • 字體調整後 layout 沒崩
    • 設定頁四個 tooltip 都正常換行不被截斷
    • 拖曳視窗到第二螢幕 → 關閉 → 重啟 → 回到同位置
    • AccountList 拖曳排序後 → 進設定 → 返回 → 排序保留且無 spinner flash
  • Session keep-alive (60s ping) — 需 long-running smoke 確認,建議 reviewer 留意 tracing::debug! log 看是否 60 秒一次

Refs

  • tauri-apps/tauri#11654 — Win10 undecorated shadow border bug
  • tauri-apps/tauri#13176 — maintainer 確認 shadow: false 是 workaround
  • tao#1052 — Win11 shadow inset 修好(解釋為何 Win11 不受影響)
  • WPF 5.9.1 MainWindow.pingWorker_DoWork (xaml.cs L2322-2368) + BeanfunClient.Ping() (bfClient.cs L193-212) — Session keep-alive 對齊原版
  • tauri-plugin-window-state v2.4.1 — 用 StateFlags::POSITION 只存位置

YCC3741 added 9 commits April 21, 2026 21:23
App.vue disables user selection globally so the shell feels native,
but the Account Details dialog is read-only data the user frequently
copies into game-support tickets (sid / ssn / sname / sauthtype).
Override user-select on .service-account-info__value so every row
in the dialog can be highlighted and copied.

Status text ("Normal" / "Banned") is covered by the same class; the
WPF original also rendered it as a selectable TextBlock so the
behaviour matches.

Fixes #234
…235)

Upstream tauri-apps/tauri#11654 / #13176: on Windows 10 the tao
undecorated-shadow inset calculation paints a 1px coloured artefact
border around the window after DWM redraws. Maintainer-confirmed
workaround is `set_shadow(false)`, but we only want it on Win10 —
Windows 11 (build >= 22000) rendered the same shadow correctly since
tao #1052 and the shadow there gives the rounded glass panel its
depth.

Adds `os_info` (Windows-only, default-features off so no transitive
serde) to detect the build number. `is_windows_10()` is conservative:
non-Windows and non-semantic versions both fall back to "leave shadow
alone" so a mis-detection on Win11 can never regress that path.

The shadow flip lives in the Tauri `.setup()` callback because
`WebviewWindow::set_shadow` is only available after the window is
constructed; declaring `"shadow": false` in tauri.conf.json would
also disable it on Win11.

Fixes #235
`fitWindow` hard-capped the auto-fit height at 900px, which was
smaller than several pages when their content expanded (Settings
with the full Game section visible, AccountList with a populated
service-account list + game grid). The cap forced those pages to
paint an inner scrollbar inside `__scroll` — this is the "從設定
返回還有滑條能上下滾動" symptom in issue #236.

Use the actual display height (`window.screen.availHeight`) minus a
50px taskbar safety margin instead. The pages already obey an
`overflow: hidden` root + flex-child `overflow-y: auto`, so capping
at the display height just lets them take the space they naturally
need without letting the window chrome eat the taskbar.

Falls back to the old 900 constant when `window.screen` is
unavailable (jsdom / headless CI) so the existing router spec
harness keeps working without environment setup.

Refs #236 (bug C)
The previous `setTimeout(80)` + async `height='auto' → setSize().then
(height='100vh')` flow had four separate bugs that together produced
the symptoms in issue #236:

1. **Timing race**: 80ms wasn't tied to Vue's render flush or the
   browser's paint, so slow config.xml IPC left the observer
   attached to a half-rendered DOM.
2. **Navigation storm**: first launch fires `afterEach` twice in
   rapid succession (`/` → region picker → auto-redirect via
   `LoginRegionSelection.vue::watch(config.loaded)`) and both
   timeouts ran, the earlier one measuring the picker DOM just as
   it was being replaced.
3. **Layout-restore race**: `setSize` is async IPC and the
   `height='auto'` flip lived for a frame-and-a-half until the
   `.then()` callback reset it back to `100vh`, producing visible
   flicker if the new content was taller than the old window.
4. **Observer feedback loop**: the `height='auto'` flip itself
   tripped the ResizeObserver, which fired `fitWindow` again.

Replacing the timeout with a shared double-`requestAnimationFrame`
handle (`scheduleOnNextPaint`) addresses all four:

- Double rAF straddles Vue's microtask flush so measurement sees
  the settled DOM.
- A single shared `pendingFrame` handle means cancelling the
  previous callback before scheduling the next one — the
  afterEach storm collapses to a single fit on the final
  destination.
- `fitWindow` now flips `height: auto` → read `scrollHeight` →
  flip `height: 100vh` in the same synchronous block (forced-
  layout read followed by a write, both before the next paint) so
  the browser never renders the intermediate unclipped state.
- Same-frame flip-back coalesces the observer notifications (the
  computed-style value ends where it started) so there's no
  feedback loop — the `skipFirstObserverCallback` flag additionally
  swallows the synthetic `observe()` notification so we don't fit
  twice in a row when switching routes.

jsdom / SSR paths fall through to synchronous execution so the
router spec harness keeps working without a rAF polyfill.

Refs #236 (bugs A, B, D, E; bug C was the 900px cap fixed in the
previous commit)
Follow-up to 85c071b. Users reported the window still paints an inner
scrollbar after a language switch, because the ResizeObserver was only
watching `[data-window-root]` — that element is locked to `100vh`, so
content-only height changes (i18n string swaps, async data arriving,
drop-downs) never triggered a re-fit.

- Each page now marks its real content wrapper with
  `data-window-content` (AccountList / Settings / About / ManageAccount
  `__container`, plus a new wrapper around `<RouterView>` in
  LoginPage). These wrappers actually grow/shrink with the content, so
  observing them catches the changes the root misses.
- `attachObserver` in `src/router/index.ts` now observes both the
  root and the content wrapper (when present).
- The "skip first ResizeObserver callback" flag was racy with two
  observed targets (synthetic initials can arrive in one or two
  callbacks), so it's been replaced with an rAF gate that swallows
  every notification until the first post-attach animation frame.
  That reliably excludes the synthetic initials without guessing how
  they'll be batched.

Existing router + LoginPage + AccountList specs stay green.
Reported against the post-#236 auto-fit rework: on screens where the
total page height (game bar + quick actions + account list + OTP
section) exceeded the OS-available height, the window was capped at
`screen.availHeight - 50` and the *outer* `account-list__scroll`
container ate the overflow. That pushed the OTP row below the fold
and forced the user to mouse-wheel before they could hit "Get OTP" —
the legacy Beanfun (B6) client shows everything on a single page
instead.

Flip the layout so the outer container no longer scrolls and the
inner account list is the only scroller:

- `.account-list__scroll` → `overflow: hidden` + flex column so it
  distributes space to its children instead of clipping them with an
  outer scrollbar.
- `.account-list__container` / `.account-list__list` → `flex: 1` +
  `min-height: 0` so the flex chain actually allows the list to
  shrink below its natural content height.
- `.account-list__list-body` → drop the 300 px cap; `flex: 1` now
  determines its height from whatever space the surrounding chrome
  leaves, and `overflow-y: auto` keeps the list itself scrollable.

Net effect: header, game bar, quick actions and the OTP footer are
always visible regardless of account count; only the list scrolls.
Existing AccountList + router specs (88) stay green.
Legacy Beanfun (B6) and the WPF port both treated Enter as a shortcut
for the Get-OTP button once a service account was highlighted. The
SPA port never wired an equivalent, so users who were used to the
keyboard flow had to reach for the mouse every time. Restore the
WPF `lstViewAccount.KeyDown` parity.

- `src/pages/AccountList.vue` now attaches a `window`-level
  `keydown` listener on mount and tears it down on unmount.
  `handleGlobalEnter` routes to the existing `handleGetOtp` flow
  when all guards pass.
- Guards, in order: ignore non-Enter / IME composition / key-repeat,
  ignore when focus is on a form control (`input` / `textarea` /
  `select` / `button` / `contenteditable`), ignore when focus is
  inside `.el-overlay` (so open `ElDialog` / `ElMessageBox`
  instances own the key), ignore when an OTP fetch is already in
  flight, ignore when nothing is selected (silent no-op — the
  button path keeps its `MsgSelectAccount` toast for explicit
  clicks). See the `handleGlobalEnter` docblock for the rationale
  behind each branch.
- `window.dispatchEvent(keydown)` events in jsdom report the
  `Window` itself as `event.target`, which has no `closest()`; the
  handler narrows via `target instanceof HTMLElement` before
  probing, treating "no focused element" (common on first paint)
  as safe to forward.

New spec `tests/unit/pages/AccountList.EnterHotkey.spec.ts` locks
down the behaviour in its own file. The sibling `AccountList.spec.ts`
follows the P12 convention of not unmounting wrappers between cases
(so its 50+ mounts' listeners stack up on the shared `window`);
Vitest's per-file jsdom isolation gives the Enter suite a clean
slate without requiring sweeping changes to the sibling file. Six
cases cover each guard branch plus an onBeforeUnmount leak check,
and the existing 52 AccountList + 36 router specs remain green.
Per user feedback "內文字體可以稍微調大一點點" — every text class in
`AccountList.vue` gets ~+0.0625rem (one pixel at the 16 px root base)
so the page reads more comfortably on modern high-DPI displays
without tipping over into a new visual weight.

Breakdown (all numbers in `rem`):

| Class                      | Before | After   |
|----------------------------|--------|---------|
| `__title`                  | 1.625  | 1.75    |
| `__subline`                | 0.8125 | 0.875   |
| `__game-name`              | 1      | 1.0625  |
| `__game-status`            | 0.75   | 0.8125  |
| `__balance-label`          | 0.625  | 0.6875  |
| `__balance-value`          | 0.9375 | 1       |
| `__quick-link`             | 0.6875 | 0.75    |
| `__list-title`             | 0.875  | 0.9375  |
| `__list-count`             | 0.75   | 0.8125  |
| `__list-state`             | 0.8125 | 0.875   |
| `__row-grip`               | 0.875  | 0.9375  |
| `__row-num`                | 0.8125 | 0.875   |
| `__row-name`               | 0.875  | 0.9375  |
| `__row-sub`                | 0.6875 | 0.75    |
| `__add-btn`                | 0.8125 | 0.875   |
| `__limit-notice`           | 0.75   | 0.8125  |
| `__otp-title`              | 0.875  | 0.9375  |
| `__otp-field`              | 1.125  | 1.25    |
| `__otp-get`                | 0.875  | 0.9375  |

Everything keeps its relative proportion so the existing visual
hierarchy (title > otp-field > balance-value > row-name > ...) is
preserved. All 58 AccountList + EnterHotkey specs remain green.
…scroll

Follow-up to #236 feedback. The previous 80px min-height on
`.account-list__list-body` was only enough for a single row, so with
two accounts the second row was vertically clipped (reported on PR
#237 with a screenshot).

Bumped min-height to 9rem (~144px), budgeted as:
  row      = 0.625rem * 2 padding + ~28px content  ~= 52px
  2 rows   = 104px
  gap      = 0.25rem between rows                   =   4px
  padding  = 0.5rem * 2 on __list-body              =  16px
  total                                             ~= 124px
plus ~20px buffer for line-height jitter / sub-pixel rounding.

Keeps the list-body flexible (still `flex: 1`) so it continues to
absorb window growth past two rows, but stops the flex chain from
squashing it below the two-row threshold when window height is tight.

Trade-off documented in the CSS block: with a single account the
list has a small empty strip below the row. User preferred that
over a clipped second row.
This was referenced Apr 21, 2026
YCC3741 added 6 commits April 21, 2026 22:19
Follow-up to PR #237. On a narrow Tauri window the
`disableHardwareAcceleration` checkbox tooltip rendered as
"Disabling h..." because:

1. Element Plus' default popper CSS uses `white-space: normal`,
   which collapses the `\n` characters our i18n strings already
   embed to break long explanations into two sentences. The two
   sentences were getting glued together into one very wide line.
2. With `placement="right"` that single wide line then ran past
   the right edge of the Tauri window, which clips anything
   outside the webview bounds, producing the truncation.

Added a namespaced popper class `settings__tip-popper` on all four
settings tooltips (trad-login / kill-patcher / skip-play-window /
disable-hw-accel) and a non-scoped `<style>` block targeting it:

    .settings__tip-popper.el-popper {
      max-width: 280px;        /* lets flip strategy fall back to */
                               /* top or bottom when right doesn't fit */
      white-space: pre-line;   /* honour `\n` from i18n strings */
      word-break: break-word;  /* protect no-seam English copy */
      line-height: 1.5;
    }

The block is intentionally non-scoped because el-popper is
teleported to `document.body` and lives outside this component's
scoped CSS boundary; the class name prefix keeps the blast radius
to these four tooltips.

Tests: 595/595 pass, vue-tsc clean.
rustfmt collapses the single-argument `tracing::warn!` call onto
one line; CI's `cargo fmt --all -- --check` step was failing on
the 3-line form that landed in commit 2 of the #235 window
border fix.

No behavioural change.
Users have been reporting that the current build drops its
Beanfun session after a few minutes of idle, whereas the legacy
WPF client v5.9.1 could stay logged in for days. Investigation
against the WPF source revealed a `pingWorker` BackgroundWorker
(`MainWindow.xaml.cs` L2322-2368 + `bfClient.cs` L193-212) that
silently hits `echo_token.ashx?webtoken=1` every 60 seconds to
reset the portal's server-side inactivity timer. The Tauri
rewrite never ported this machinery, so the server naturally
reaps idle sessions and the user's next action (Get OTP, launch
game) fails.

This change reproduces the WPF behaviour:

- `BeanfunClient::ping()` issues the GET against `portal_base`
  with `webtoken=1` appended as a query pair. The body is
  deliberately discarded (not piped through `bounded_text`) —
  the request is only useful for its side effect.
- `AuthContext` now owns a `tokio_util::sync::CancellationToken`
  so each spawned loop has a matching shutdown signal.
- Every login path (`login_regular`, `login_totp`,
  `login_qr_check` on Approved, `complete_gamepass_login`)
  routes through a new `install_session_and_start_ping` helper
  that installs the auth context, spawns the loop, and — if a
  prior context was already installed — cancels its token so we
  never leak an orphaned task holding a stale cookie jar.
- `logout` cancels the token before calling `logout_service`
  so the keep-alive can't race a server-side logout.
- Error handling matches WPF's `catch { }`: failures are logged
  at `tracing::debug!` level and the loop keeps running.

Tests:

- New `tests/ping.rs` (6 integration tests) pins the wire-shape
  contract: GET method, `echo_token.ashx` path, `webtoken=1`
  query, 2xx→Ok, 5xx→LoginError::Http, TW+HK both routing
  through `portal_base`, and a huge-body 200 still resolving
  (ping must not read the body).
- New unit tests in `commands::auth::tests` cover
  `run_ping_loop` cancellation (pre-cancel returns immediately
  without hitting the network, mid-sleep cancel wakes up the
  select without waiting the full 60 s, 5xx doesn't kill the
  loop via `start_paused` + `tokio::time::advance`) plus two
  end-to-end tests that `install_session_and_start_ping`
  populates `AppState::auth` with a live loop and that a second
  install cancels the previous loop's token.

`tokio-util` was already pulled in transitively via `tauri`;
promoting it to a direct dep with `features = ["rt"]` so the
feature set can't silently regress.
Several users have asked for the launcher to come back up where
they last left it — typically a secondary monitor or a specific
spot on a multi-display setup. This change wires
`tauri-plugin-window-state` (official Tauri plugin) into the
main window so the X / Y coordinates survive across restarts.

Why position-only (`StateFlags::POSITION`):

`tauri.conf.json` ships `resizable: false` and the router's
`fitWindow` (`src/router/index.ts`) is the canonical owner of
per-route width/height. Persisting SIZE / MAXIMIZED 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. Restricting the state flags to POSITION
avoids the conflict cleanly — the plugin only touches `set_position`,
fitWindow keeps owning `set_size`, and there's no overlap.

The state file lives under the standard `appConfigDir`
(Windows: `%APPDATA%\tw.beanfun.app\`), so it's reset by the
same uninstall path as the rest of Beanfun's local data and
honours the existing data-deletion expectations.

Plumbing:

- `Cargo.toml`: declare `tauri-plugin-window-state = "2"`
- `lib.rs::run`: register the plugin via `Builder::with_state_flags`
- `capabilities/default.json`: grant `window-state:default` so
  the plugin's commands work for the main window

Frontend code is untouched — the plugin auto-saves on
`WindowEvent::CloseRequested` / `Destroyed` and auto-restores
on init, so no JS/TS API call is needed for this feature.
Users with custom drag-and-drop sort orders reported that every
visit to the Settings (or About) page was wiping their order
back to the server default on return. The order *was* still
persisted to `Config.xml` under `AccountOrder_<code>_<region>`
and re-applied by `loadList` after the HTTP response landed —
but the spinner-then-empty-list flash made it look reset.

Root cause: `AccountList` is a routed component, so `/settings`
unmounts it and navigating back remounts it. `setupGameOnMount`
unconditionally re-ran the bootstrap (`game.loadGames` →
`selectActiveGame` → `loadList`) every mount, with no awareness
that the Pinia stores had already cached everything for the
current session.

Fix: a fast-path predicate at the top of `setupGameOnMount`:

  if (account.serviceAccounts.length > 0 && auth.session !== null) {
    loadState.value = 'ready'
    return
  }

The skip is safe because every code path that *should* trigger
a re-fetch already clears `serviceAccounts` first:

- `auth.logout()` / `auth.session_required` → `clearAccountSession()`
  resets the store.
- Game change via the picker → `setActiveService` clears the store
  before the new fetch.
- Cold mount after login → store empty by construction, predicate
  is false, full bootstrap runs.

`loadState` would otherwise sit at its initial `'loading'`
sentinel forever (no `loadList` call to flip it), so the skip
branch flips it to `'ready'` inline before returning.

Tests:

- New regression case `skips re-fetch on remount when serviceAccounts
  already cached for current session` seeds a populated store + live
  session before mount, asserts `listGames` / `getAccounts` /
  `setActiveService` are never called, and asserts the user's
  `selectedSid` survives the round-trip.
- Companion baseline `still re-fetches on remount when
  serviceAccounts cache is empty (cold mount baseline)` re-asserts
  the existing post-login first-paint behaviour so a future
  too-eager skip predicate fails loudly here too.
- 54/54 AccountList tests pass; `vue-tsc --noEmit` clean.
The previous CI run timed out (`cargo test` ran for 6 h before
GitHub cancelled it). Root cause: the `5xx-doesn't-kill-loop`
unit test combined `#[tokio::test(start_paused = true)]` with
a wiremock `MockServer`. With the runtime clock paused, hyper's
internal time wheel stopped advancing too, and on Windows the
mocked HTTP request never resolved — the test hung indefinitely
inside `client.ping().await` before the first `tokio::time::advance`
call could even fire. Locally on a faster Windows host this
sometimes reaches the `received_requests` check before timing
out, which is why the regression escaped manual smoke testing.

Refactor the production loop to expose its sleep interval as
a parameter:

- `run_ping_loop(client, cancel)` keeps its public signature and
  always passes `PING_INTERVAL` (60 s, WPF parity).
- `run_ping_loop_with_interval(client, cancel, interval)` is the
  new private inner that callers — i.e. the unit test — can drive
  with a 50 ms cadence under real time. No `start_paused`, no
  manual `advance()`, no hyper time-wheel stall.

The 5xx test now polls for two requests with a 10 s deadline
backstop (expected to land in ~100 ms) and finishes in roughly
the same wall-clock time as the other ping-loop tests.

Defence-in-depth: also add `timeout-minutes: 25` to the rust
job in `.github/workflows/ci.yml`. Healthy runs land in 4-8 min
on a warm cache, so 25 leaves comfortable headroom while
preventing future hangs from silently consuming the GitHub
default 6 h budget.

Verified locally:

- `commands::auth::tests::run_ping_loop_*` (3 tests) pass in
  0.08 s
- `tests/ping.rs` integration suite (6 tests) pass in 0.01 s
- `cargo fmt --check` + `cargo clippy --all-targets -- -D warnings`
  clean
@YCC3741 YCC3741 merged commit d0f733e into code Apr 22, 2026
2 checks passed
@YCC3741 YCC3741 deleted the fix/issues-234-235-236 branch April 22, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

遊戲帳號詳情

1 participant