From 3c71ebd838b887d2b626f975c9aef78e772902ba Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 21 Apr 2026 21:23:33 +0800 Subject: [PATCH 01/15] fix(ui): make ServiceAccountInfo value rows selectable (#234) 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 --- src/windows/ServiceAccountInfo.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/windows/ServiceAccountInfo.vue b/src/windows/ServiceAccountInfo.vue index 2656495..4e723b9 100644 --- a/src/windows/ServiceAccountInfo.vue +++ b/src/windows/ServiceAccountInfo.vue @@ -372,6 +372,17 @@ function handleCancel(): void { color: var(--bf-on-surface); margin: 0; word-break: break-all; + /* + * Issue #234: use-case is copy-paste of the account / serial-number + * / name / authType strings the dialog surfaces. App.vue disables + * text selection globally (-webkit-user-select: none on body) to + * feel native; this dialog is the one place an explicit override + * matters because every row is a read-only value the user frequently + * copies into game support tickets. + */ + -webkit-user-select: text; + user-select: text; + cursor: text; } .service-account-info__status--ok { From 4f074102e00ccfba834b5966459f6fc6a32f3c0e Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 21 Apr 2026 21:27:33 +0800 Subject: [PATCH 02/15] fix(window): disable shadow on Windows 10 to remove artefact border (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/Cargo.lock | 102 ++++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 9 ++++ src-tauri/src/lib.rs | 49 +++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e3d4977..ec5a410 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", @@ -2564,6 +2565,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 +2682,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 +2727,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 +2817,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 +2889,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8c9bf9..19ef925 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -145,6 +145,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/src/lib.rs b/src-tauri/src/lib.rs index da02dee..225b8aa 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. /// @@ -351,6 +381,25 @@ 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| { From f5e59df4ad736c273a252ef1708677607ef343f1 Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 21 Apr 2026 21:28:35 +0800 Subject: [PATCH 03/15] fix(window): scale fitWindow height cap to the actual display (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- src/router/index.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/router/index.ts b/src/router/index.ts index 0c0e0c6..630060b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -489,12 +489,36 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void let currentWidth = 560 let observer: ResizeObserver | null = null + /** + * Upper bound applied to the auto-fit height. + * + * Previously hard-coded to `900` which was narrower than several + * pages (Settings with the Game section expanded, AccountList with + * a populated service-account list) — the cap forced the inner + * `__scroll` container to paint its own scrollbar and was the root + * cause of issue #236 "returning from Settings still has a scroll + * bar". Scaling to the actual display instead fits the content + * naturally on any desktop without letting the window eat the + * taskbar (50px safety margin keeps the window draggable on + * Windows's default 40px taskbar). + * + * Falls back to 900 when `window.screen` is unavailable (jsdom / + * headless CI) so the spec harness keeps working. + */ + function maxFitHeight(): number { + const avail = typeof window !== 'undefined' ? window.screen?.availHeight : undefined + if (typeof avail === 'number' && avail > 0) { + return Math.max(300, avail - 50) + } + return 900 + } + function fitWindow(): void { const root = document.querySelector('[data-window-root]') as HTMLElement | null if (!root) return // Remove height lock to measure natural content height root.style.height = 'auto' - const h = Math.max(300, Math.min(Math.ceil(root.scrollHeight), 900)) + const h = Math.max(300, Math.min(Math.ceil(root.scrollHeight), maxFitHeight())) void appWindow.setSize(new LogicalSize(currentWidth, h)).then(() => { // Lock height to viewport so flex scroll areas work root.style.height = '100vh' From 85c071bf290fba5373f66841496a1f7d1e42d65b Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 21 Apr 2026 21:33:15 +0800 Subject: [PATCH 04/15] fix(window): tighten fitWindow timing + kill layout-flip race (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/router/index.ts | 107 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 8 deletions(-) diff --git a/src/router/index.ts b/src/router/index.ts index 630060b..b9526fe 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -484,10 +484,45 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void * `[data-window-root]` element so the window tracks content * height changes (e.g. sections appearing/disappearing, async * data loading). Width comes from route meta. + * + * # Why double `requestAnimationFrame` (issue #236) + * + * Previously this code used `setTimeout(setupObserver, 80)` after + * every `afterEach`. Four bugs fell out of that: + * + * 1. **Timing race** — 80ms is not tied to Vue's render flush or + * the browser's paint, so on a slow boot (config.xml IPC taking + * longer than 80ms) the observer attached to a half-rendered + * DOM and measured a shorter-than-final `scrollHeight`, leaving + * the user with a window that never grew to match the real + * content height. + * 2. **Navigation storm** — on first launch the router fires + * `afterEach` twice in rapid succession: `/` → `/login/` + * (region picker empty child) → `/login/id-pass` (via + * `LoginRegionSelection.vue::watch(config.loaded)`). Both + * `setTimeout` s fired, the earlier one measuring the picker + * DOM just as it was being replaced. `scheduleOnNextPaint` + * auto-cancels the previous pending callback so only the final + * destination's DOM gets measured. + * 3. **`setSize` → layout-restore race** — the old code ran + * `height='auto'` → measure → `setSize().then(() => height='100vh')`. + * `setSize` is an async IPC (10-50 ms), so the DOM stayed in + * "height: auto" for a frame-and-a-half while the window was + * still at its old size — user-visible if the new content was + * taller than the old window. The new `fitWindow` flips + * `auto` → `100vh` synchronously within one frame (the browser + * never paints the intermediate state) and fires the `setSize` + * IPC without awaiting the layout restore. + * 4. **Observer loop** — the `height='auto'` flip fired the + * observer, which called `fitWindow`, which flipped it again. + * The same-frame flip-back in (3) coalesces the observer + * notifications to a no-op (`100vh` in → `100vh` out), so no + * explicit guard flag is needed. */ const appWindow = getCurrentWindow() let currentWidth = 560 let observer: ResizeObserver | null = null + let pendingFrame: number | null = null /** * Upper bound applied to the auto-fit height. @@ -513,23 +548,80 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void return 900 } + /** + * Measure the current `[data-window-root]`'s natural content + * height and resize the OS window to match. Both the + * `height: auto` measurement flip and the `height: 100vh` restore + * land inside a single synchronous block so the browser never + * paints the intermediate "unclipped" state — only one frame is + * ever rendered per fit regardless of how long the IPC takes. + * + * Safe to call concurrently: successive invocations just re-run + * the measurement cycle. The `pendingFrame` guard upstream keeps + * the rate low enough that this isn't a hot path. + */ function fitWindow(): void { const root = document.querySelector('[data-window-root]') as HTMLElement | null if (!root) return - // Remove height lock to measure natural content height + // Both flips happen in the same synchronous block so the browser + // never paints the intermediate `height: auto` state (it's a + // forced-layout read followed by a write, both before the next + // paint). See bug (3) in the header docblock above. root.style.height = 'auto' const h = Math.max(300, Math.min(Math.ceil(root.scrollHeight), maxFitHeight())) - void appWindow.setSize(new LogicalSize(currentWidth, h)).then(() => { - // Lock height to viewport so flex scroll areas work - root.style.height = '100vh' + root.style.height = '100vh' + void appWindow.setSize(new LogicalSize(currentWidth, h)) + } + + /** + * Schedule `cb` to run after two animation frames. + * + * Two rAFs — not one — because Vue's async scheduler flushes + * on the microtask immediately after the first rAF, so the DOM is + * usually correct on the *second* rAF. Concrete symptom if we + * only used one: `LoginRegionSelection.vue::watch(config.loaded)` + * auto-redirects to `/login/id-pass` on the first post-mount + * tick, and a single-rAF measurement would race that redirect. + * + * Cancels any previously pending callback so an `afterEach` storm + * collapses to a single fit on the final settled destination. + */ + function scheduleOnNextPaint(cb: () => void): void { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + // jsdom / SSR path — just run immediately so specs don't need + // a rAF polyfill. + cb() + return + } + if (pendingFrame !== null) window.cancelAnimationFrame(pendingFrame) + pendingFrame = window.requestAnimationFrame(() => { + pendingFrame = window.requestAnimationFrame(() => { + pendingFrame = null + cb() + }) }) } - function setupObserver(): void { + /** + * Tear down the previous route's observer, attach a fresh one to + * the new route's `[data-window-root]`, and perform the first + * fit. `skipFirstObserverCallback` swallows the synthetic + * notification `ResizeObserver.observe()` fires immediately after + * attaching — the manual `fitWindow()` right below already covers + * that initial measurement, so we'd otherwise fit twice in a row. + */ + function attachObserver(): void { if (observer) observer.disconnect() const root = document.querySelector('[data-window-root]') as HTMLElement | null if (!root) return - observer = new ResizeObserver(() => fitWindow()) + let skipFirstObserverCallback = true + observer = new ResizeObserver(() => { + if (skipFirstObserverCallback) { + skipFirstObserverCallback = false + return + } + scheduleOnNextPaint(fitWindow) + }) observer.observe(root) fitWindow() } @@ -537,7 +629,6 @@ export function installRouterGuards(router: Router, deps: RouterGuardDeps): void router.afterEach((to) => { const w = to.meta.windowWidth as number | undefined if (w) currentWidth = w - // Give Vue time to mount the new route component - setTimeout(setupObserver, 80) + scheduleOnNextPaint(attachObserver) }) } From 26bf2a34205d132c99371be102f29096f6d5a42e Mon Sep 17 00:00:00 2001 From: YCC3741 Date: Tue, 21 Apr 2026 21:53:04 +0800 Subject: [PATCH 05/15] fix(window): observe inner content so i18n switches re-fit (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- src/pages/About.vue | 2 +- src/pages/AccountList.vue | 2 +- src/pages/LoginPage.vue | 15 ++++++++++- src/pages/ManageAccount.vue | 2 +- src/pages/Settings.vue | 2 +- src/router/index.ts | 53 ++++++++++++++++++++++++++++++------- 6 files changed, 61 insertions(+), 15 deletions(-) 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(() => {
-
+