Skip to content

feat/refactor: Breaking Changes - Replace WPF with Rust/Vue 3#218

Merged
YCC3741 merged 78 commits intocodefrom
feat/beanfun-next-rewrite
Apr 20, 2026
Merged

feat/refactor: Breaking Changes - Replace WPF with Rust/Vue 3#218
YCC3741 merged 78 commits intocodefrom
feat/beanfun-next-rewrite

Conversation

@YCC3741
Copy link
Copy Markdown
Collaborator

@YCC3741 YCC3741 commented Apr 20, 2026

Summary

Full rewrite of the Beanfun launcher from C# / WPF to Tauri v2 + Rust + Vue 3, achieving 1:1 functional and UI parity with the legacy version.

Closes #217

What's included

Backend (Rust)

  • beanfun.com HTTP session management (login, OTP, QR code, TOTP)
  • Account/service account CRUD with DPAPI-encrypted Users.dat (byte-for-byte interop with WPF)
  • Game launching with LocaleRemulator integration + SHA-256 integrity checks
  • PostMessageW auto-paste with highestAvailable UAC manifest (release builds)
  • GitHub release update checker with semver + WPF version format support
  • System tray minimize, Config.xml persistence

Frontend (Vue 3 + TypeScript)

  • All pages: login (id-pass, QR, gamepass), account list, settings, about, tools dialogs
  • Region selection (TW / HK), i18n (zh-TW, zh-CN, en-US)
  • In-app browser via Tauri WebviewWindow (replaces WPF WebView2 iframe)
  • Element Plus UI with design tokens

Repo restructure

  • Flattened beanfun-next/ to repo root, removed WPF code
  • Renamed crate/package from beanfun-next to beanfun
  • productName = Beanfun, identifier = tw.beanfun.app

CI / Infrastructure

  • ci.yml: ESLint, Prettier, TypeCheck, Vitest, cargo fmt, Clippy, cargo test (Windows + macOS)
  • build-and-release.yml: Tauri build → standalone Beanfun.exe → GitHub Release
  • commitlint.yml: Conventional Commits enforcement
  • dependabot.yml: npm, cargo, github-actions weekly updates

Test plan

  • cargo check passes
  • cargo test — 722 tests passed
  • cargo fmt --check passes
  • npm run typecheck passes
  • npm run lint passes
  • npm run test — 586 tests passed (40 test files)
  • npm run format:check passes
  • npm run tauri build -- --no-bundle — produces 18.97 MB standalone exe
  • Manual smoke test: login, account list, game password, tray, settings, about, in-app browser

YCC3741 added 30 commits April 17, 2026 01:38
Design artifacts for the beanfun-next rewrite:
- stitch-prompt.md: Glassmorphism + Fluent + Soft Depth design brief
  handed to Stitch with 8 preset palettes + custom hex support
- beanfun-next/mockups/: 25 HTML mockups covering every Page,
  Dialog, and Game Tool Window, plus a shared _design-system.html
  showcasing theme tokens and utility classes
Bootstrap the beanfun-next workspace for the full rewrite:
- Tauri v2 shell (window + bundle icons sourced from legacy
  Beanfun/Resources/icon.ico via `tauri icon`)
- Vue 3 + Vite + TypeScript frontend baseline
- Runtime deps: element-plus, pinia, pinia-plugin-persistedstate,
  vue-i18n@11, vue-router@4, vuedraggable@4, @tauri-apps/api
- Dev deps: vitest@4, @vue/test-utils, jsdom, @types/node
- Rust deps: reqwest (rustls), reqwest_cookie_store, tokio,
  serde, serde_json, thiserror@2, anyhow, tracing, des, cipher,
  sha2, quick-xml, regex, url, base64, chrono, plus Windows-only
  windows@0.58 / winreg / wmi, and dev deps (wiremock, axum@0.8,
  assert_matches, tempfile, pretty_assertions, tokio-test)
- Todo.md: full rewrite plan, P-1 mockup tracking, and P0
  progress with chunked delivery structure

Verified: `cargo check` passes in 1m 00s; `npm run tauri dev`
launches a blank Tauri window (Rust build 47s, Vite 1.6s).
Set up the code-quality and formatting baseline for beanfun-next:
- ESLint 9 flat config via `@vue/eslint-config-typescript`
  (defineConfigWithVueTs + vueTsConfigs.recommended) with
  `@vue/eslint-config-prettier/skip-formatting`
- Prettier 3 (.prettierrc.json + .prettierignore): single quotes,
  no semi, trailing comma, printWidth 100, LF
- rustfmt.toml: max_width 100, LF, default heuristics
- clippy.toml: msrv 1.80, complexity/argument thresholds
- .editorconfig scoped to beanfun-next/ (UTF-8, LF, 2-space with
  4-space override for .rs/.toml) — legacy Beanfun/ WPF project
  intentionally left untouched
- package.json scripts: typecheck, lint, lint:fix, format,
  format:check, test, test:watch
- DevDeps: eslint@9, eslint-plugin-vue, @vue/eslint-config-
  typescript, @vue/eslint-config-prettier, prettier

Also applies the one-time Prettier / cargo fmt run over the
Tauri scaffold (LF line endings, consistent quotes/spacing).
No runtime behaviour change.
Smoke tests (P0 step 0.5):
- beanfun-next/tests/unit/smoke.spec.ts + vitest.config.ts
  (jsdom env, globals, coverage v8) — 3 tests: vitest harness,
  jsdom availability, and a basic @vue/test-utils mount
- beanfun-next/src-tauri/tests/smoke.rs — 4 tests covering
  arithmetic harness, serde_json roundtrip, reqwest client
  build, and sha2::Sha256 digest shape

GitHub Actions CI (P0 step 0.6):
- .github/workflows/beanfun-next-ci.yml
- Triggers: push / pull_request on branch `code` scoped to
  `beanfun-next/**` + workflow_dispatch; concurrency cancels
  outdated runs per ref
- Matrix: windows-latest + macos-latest (Windows is the real
  target; macOS runs provide a cross-platform sanity net)
- `frontend` job: npm ci → lint → format:check → typecheck →
  vitest
- `rust` job: dtolnay/rust-toolchain@stable + Swatinem/rust-
  cache@v2 → cargo fmt --check → cargo clippy --all-targets
  -- -D warnings → cargo test

Verified locally: ESLint, Prettier, vue-tsc, Vitest, cargo
fmt --check, cargo clippy -- -D warnings, and cargo test all
green; YAML syntax validated.
Enforce Conventional Commits on pull requests targeting `code`.

- commitlint.config.js (repo root): extends
  `@commitlint/config-conventional`. Relaxations tailored for
  this repo: `header-max-length` 120 (default 72 is too tight
  for scoped subjects), `body-max-line-length` / `footer-max-
  line-length` disabled (Chinese bodies use multi-byte chars),
  `scope-enum` disabled (we mix `next` / `updater` / `ui` /
  `deps` / `ci` scopes). Ignores dependabot ("Bump X from A
  to B") and "Merge pull request|branch" commits.
- .github/workflows/commitlint.yml: runs
  `wagoid/commitlint-github-action@v6` on `pull_request` to
  `code`, ubuntu-latest, fetch-depth 0. Read-only permissions.

Verified by running commitlint locally (via npx) against the
last 4 commits on this branch - all pass with 0 problems and
0 warnings.
…tep 0.8)

Replace the Tauri + Vue scaffold README with a project-specific
Traditional Chinese README covering:

- Project positioning: rewrite of pungin/Beanfun (C# / WPF)
  in Tauri v2 + Rust + Vue 3, with the legacy C# project
  staying in ../Beanfun until feature parity is reached
- Tech stack table (shell / backend / Windows FFI / frontend /
  UI / state-i18n-router / testing)
- Development environment table (Node.js >= 22, Rust stable
  MSVC, WebView2, VS Build Tools with C++ desktop workload)
- Quick Start (`npm install` + `npm run tauri dev`)
- Common commands (frontend npm scripts + backend cargo
  commands)
- Project structure tree
- Testing locations (Vitest unit + cargo integration tests)
- Contribution guidelines (branch / Conventional Commits /
  CI matrix / formatting) with link to commitlint.config.js
- Roadmap section linking to the root Todo.md, with P-1 and
  P0 marked as complete

Also marks P0 and all three chunks as complete in Todo.md
(section heading gets a completion check, individual steps
get `[x]` marks with a summary of what was actually shipped).

No code changes.
Port the legacy C# `WCDESComp` class (Beanfun/API/WCDESComp.cs)
to Rust. Byte-compatible with the original so the existing OTP
flow (see Beanfun/Tools/BeanfunClient.OTP.cs) can be rebuilt on
top of it in P3.

Scope:
- src-tauri/src/core/mod.rs — framework-agnostic domain module
- src-tauri/src/core/wcdes/mod.rs — DES/ECB/NoPadding impl +
  tests
- src-tauri/src/lib.rs — `pub mod core;`

Public API (matches Todo.md P1 spec):
- `encrypt_hex(plaintext: &str, key: &str) -> Result<String>`
  Uppercase hex, ASCII plaintext, no padding.
- `decrypt_hex(hex_str: &str, key: &str) -> Result<String>`
  Case-insensitive hex, ASCII output (may include \0; caller
  decides whether to trim, same as the C# caller).
- `WcdesError` — typed error enum (thiserror) covering
  InvalidKeyLength / InvalidPlaintextLength /
  InvalidCiphertextHexLength / InvalidHexChar. The C# version
  swallows all exceptions into `null`; we surface a typed
  error without changing byte-level behaviour.

Parity with C# (verified by test):
- `Encoding.ASCII` lossy fallback: code points > 0x7F map to
  `?` (0x3F) on both encode and decode.
- `BitConverter.ToString(..).Replace("-","")` → uppercase hex.
- `Convert.ToByte(s, 16)` → case-insensitive hex input.
- `PaddingMode.None` → plaintext length must be a multiple of
  8 bytes (no zero-padding in core; caller responsible).

Tests (19 passing):
- 6 WPF-compat fixtures from Node `crypto.createCipheriv(
  'des-ecb', autoPadding=false)` (byte-equal to C# DES.Create)
  × 2 directions (encrypt + decrypt) = 12 effective checks
- 4 roundtrip cases (8 / 16 / 24 bytes + trailing \0)
- Hex case-insensitivity + uppercase-hex output assertions
- 9 error-path tests (key length 0/5/7/9, plaintext not
  multiple of 8, hex odd length, hex non-block length, hex
  with invalid char, empty plaintext allowed)
- 2 non-ASCII fallback tests (key and plaintext with `é`,
  `中` mapping to `?`)

Deps: `des 0.8` + `cipher 0.4` + `thiserror 2` (already in
Cargo.toml from P0.3).

Verified locally: `cargo test`, `cargo fmt --check`,
`cargo clippy --all-targets -- -D warnings` all green.

Note: a real runtime fixture from WPF (recorded from an actual
`get_webstart_otp.ashx` response) will be added as an
integration test in P3 once the login flow is wired up.
Self-review of 87578aa flagged the block-by-block loop in
`encrypt_hex` and `decrypt_hex` as duplicated: 10 lines of
identical iterator / `GenericArray` / `copy_from_slice` logic
differing only by `cipher.encrypt_block` vs `cipher.decrypt_block`.

Centralise it in a new private `process_blocks` helper that
takes the cipher, the data slice, and the per-block operation
as a closure. Both public functions now collapse to a single
call, and the "orchestration" responsibility (validate -> bytes
-> process -> format) is no longer entangled with the low-level
block iteration.

- Add `DesBlock` type alias to name `GenericArray<u8, U8>` once,
  reused by the helper signature (the closure's `&mut DesBlock`
  parameter).
- No behaviour change: all 19 `core::wcdes` tests still pass
  byte-identically (fixtures, roundtrip, error paths, ASCII
  fallback); no new tests required.

Verified: cargo fmt --check, cargo clippy --all-targets --
-D warnings, cargo test all green.
1:1 port of the WPF `ApplicationUpdater.IsNewerVersion`
(Beanfun/Update/ApplicationUpdater.cs L220-292).

- `VersionInfo { major, minor, patch, timestamp }` with raw `String` fields,
  matching the C# regex-capture workflow and side-stepping premature integer
  overflow on arbitrary-length timestamps.
- `is_newer(local: &str, remote: &VersionInfo) -> bool` with the exact same
  semantics as C#:
  * Regex branch `(\d+)\.(\d+)\.?(\d+)?\.?\((\d+)\)` for "maj.min[.patch](ts)"
    local strings. Same-timestamp short-circuits to "not newer" (defensive
    against release-tag typos).
  * Missing local patch defaults to 0 (older `5.8(ts)` builds).
  * Packs both sides as `{maj:03}{min:03}{patch:03}{ts}` and compares as `i64`.
  * Regex-miss fallback: strip non-digits, left-pad to 19, parse as `i64`.
  * Any parse / overflow error is swallowed and reported as "not newer",
    mirroring C# `try { ... } catch { return false; }`.

15 unit tests cover: patch boundary 5.8.9 < 5.8.10, major/minor/patch/timestamp
newer cases, identical version, old "5.8(ts)" format without patch,
trailing-dot form, same-timestamp short-circuit, unparseable-local fallback,
intentionally-lossy fallback (locked in), non-numeric remote fields, and i64
overflow on both regex and fallback branches.

P2 Todo.md items covered:
- core/version/mod.rs is_newer signature
- WPF IsNewerVersion 所有 case (5.8.9 < 5.8.10, timestamp 相同, 舊格式無 patch)
Four pure HTML/URL parsers under `core::parser`, each 1:1 aligned with a
regex from the legacy WPF code so services/beanfun (P3+) can consume ASP.NET
WebForms responses without behavioural drift.

- `ParserError` (thiserror, shared) with variants
  `MissingViewState`, `MissingAkey`, `MissingRequestVerificationToken`. Plus
  a `Result<T>` alias, mirroring the `core::wcdes` convention.

- `viewstate.rs::extract_viewstate(html) -> Result<ViewStateForm>`:
  * `ViewStateForm { viewstate: String, viewstate_generator, event_validation: Option<String> }`.
  * Uses the WPF **loose** regex `id="<field>"[^>]+value="([^"]+)"` (also used
    by `MainWindow.xaml.cs` for the verify flow), which subsumes the strict
    variant and survives ASP.NET attribute-order reshuffles.
  * Required: `__VIEWSTATE` (else `MissingViewState`). Optional:
    `__VIEWSTATEGENERATOR`, `__EVENTVALIDATION` — matches the varying
    expectations of the 10+ WPF call sites (initial GET vs POST round-trip,
    verify-code page).

- `account.rs`: `ServiceAccountRow { is_enable, sid, ssn, sname }` with:
  * `extract_service_accounts(html) -> Vec<ServiceAccountRow>` — regex
    `onclick="([^"]*)"><div id="(\w+)" sn="(\d+)" name="([^"]+)"`, verbatim
    from `BeanfunClient.Account.cs` L87-110, including the empty-group
    `continue;` guard. `sname` is HTML-entity decoded via `html-escape`.
  * `extract_account_limit_notice(html) -> Option<String>` — regex
    `<div id="divServiceAccountAmountLimitNotice" class="InnerContent">(.*)</div>`.

- `akey.rs::extract_akey(input) -> Result<String>`: greedy `akey=(.*)`,
  **intentionally** preserved from the WPF pattern (L261/L365/L688 in
  `BeanfunClient.Login.cs`). Regression-guarded so a future "helpful" fix to
  stop at `&` cannot silently change behaviour.

- `token.rs::extract_verification_token(html) -> Result<String>`: regex
  `__RequestVerificationToken[^>]+value="([^"]+)"`. Tolerates both `name="..."`
  (Razor) and `id="..."` (hand-rolled form) shapes.

Dependency: `html-escape = "0.2"` (v0.2.13, pure Rust, tiny). Used only in
`account.rs` to decode display names that come back as e.g. `Fran&#231;ois`.

Tests: 28 parser unit tests, ≥5 per parser (viewstate 7, account 8, akey 7,
token 6). All hand-crafted HTML snippets aligned with the WPF regexes.
Real login-flow response fixtures will be recorded in P3 and added as an
integration-test suite.

SRP/DRY self-review before commit:
- Extracted `capture_first` + `compile_field` helpers in viewstate.rs to
  eliminate a dispatch-with-panic branch; each hidden field now has its
  own function-local `OnceLock` adjacent to its sole use-site.
- Shared `ParserError` enum + `Result<T>` alias across all parsers.
- Each parser is a single submodule with its own regex(es) and tests —
  no cross-module coupling except the shared error type.

P2 Todo.md items: all 6 parser/version sub-tasks ticked, acceptance
criterion (parser tests green, coverage ≥ 95%) met.
- Extract `capture_first(re, input) -> Option<String>` into `core/parser/mod.rs`
  as a crate-private helper; every parser under this module used the identical
  `captures -> group(1) -> owned string` chain.
- Inline the helper at viewstate / account / akey / token call sites; each one
  collapses to a single line, with callers appending `.ok_or(err)` as needed.
- Upgrade `core/version` regex init from `.unwrap()` to `.expect(...)` for
  consistency with the rest of the parser stack.

No behavioural change. All 62 lib tests, `cargo fmt --check`, and
`cargo clippy -D warnings` remain green.
Foundation for the Rust port of BeanfunClient's login surface.

- `services/beanfun/client.rs`: `BeanfunClient` wrapping reqwest with a
  per-session cookie store shared across two internal clients (default
  follow-redirects, and a no-redirect variant for the `return.aspx`
  Set-Cookie capture step later in the flow). `ClientConfig` carries
  region, endpoints, timeout (30s default), body cap (16 MiB default),
  and UA matching the WPF client. `bounded_text` streams the response
  body chunk-by-chunk and aborts past the cap, preventing OOM.
- `services/beanfun/error.rs`: `LoginError` enum with 18 variants
  mirroring every WPF `errmsg` string plus transport / parser cascades.
- `services/beanfun/session.rs`: `Credentials` (zeroize on drop, redact
  Debug) and `Session` (redact Debug for skey / web_token).
- `services/beanfun/login/session_key.rs`: region-aware
  `get_session_key` — TW scrapes `pSKey=…` from the final redirected
  URL, HK scrapes the `ctl00_ContentPlaceHolder1_lblOtp1` span from the
  body. Both paths reuse the same portal entry URL helper.
- `tests/session_key.rs`: 7 wiremock integration tests covering happy
  path, missing key, empty body, body cap, and UA compliance for both
  regions.
- `Cargo.toml`: adds `zeroize` (everything else was already pulled in
  during P0 scaffolding).

Quality gates: 77 lib tests + 7 session_key integration + 4 smoke = 88
tests all green. `cargo fmt --check` clean. `cargo clippy --all-targets
-- -D warnings` clean.

Ref: WPF `Beanfun/Tools/BeanfunClient.Login.cs::GetSessionkey`.
Port the full WPF `TwRegularLogin` flow to async Rust: session key
→ Login/Index → CheckAccountType → AccountLogin → SendLogin
→ return.aspx → bfWebToken → Session.

- services/beanfun/login:
  - index.rs — GET Login/Index, remap ParserError →
    LoginError::MissingVerificationToken
  - check_account_type.rs — POST + JSON body-sniff captcha extraction
  - account_login.rs — POST + pure classify_outcome() for the 4 WPF
    result-code branches (success / advance-check none / advance-check
    http-url / server-message)
  - send_login.rs — GET + hidden-input scrape via core/parser/form
  - return_aspx.rs — POST via http_no_redirect, raw Set-Cookie scan
    for bfWebToken
  - tw_regular.rs — orchestrator composing the 6 steps into a Session
  - mod.rs — shared ensure_success + apply_json_headers helpers
    (DRY across current and future HK/TOTP/QR flows)
- core/parser/form.rs — extract_hidden_inputs() non-submit input
  scraper, reused by send_login (and later the QR flow)
- client.rs — login_url / login_url_with_skey / portal_url URL helpers

Tests (115 total green, +27 from previous chunk):
- 20 new unit tests (form scraper, classify_outcome branches,
  bfWebToken scan, url helpers)
- 7 new wiremock integration tests in tests/tw_login.rs covering
  happy path + 6 error branches (wrong password, both advance-check
  shapes, empty SendLogin, missing bfWebToken, missing Index token)
…e (P3 chunk 2 polish)

Post-review alignment for Chunk 3.2:

- account_login::classify_outcome reordered to match WPF L101-107:
  `("1", "1") => AdvanceCheck`, `("1", _) => Ok` (any other
  `Result` under `ResultCode=1` is success in WPF, including `""`,
  `null`, `"0"`, `"2"`). Our earlier tight match on `("1", "0")`
  narrowed the contract and would have spuriously rejected legal
  server responses. Truth table in the module docs updated.
- account_login tests: rename `success_returns_ok` and add
  `result_code_1_empty_result_returns_ok` +
  `result_code_1_unknown_result_returns_ok` locking in the WPF-
  permissive shape.
- return_aspx::scan_bfwebtoken: documented the `(?i)` flag as an
  intentional divergence (WPF is case-sensitive) with rationale.

Integration test coverage bumped from 7 → 10 in tests/tw_login.rs:
- `check_account_type_non_json_response_falls_through_empty_captcha`
  locks in the HTML-body tolerance WPF exercises at L70-78.
- `account_login_payload_propagates_captcha_from_check_account_type`
  uses `body_partial_json` to prove the captcha from step 2 reaches
  step 3's request body.
- `session_cookies_persist_across_login_steps` verifies the shared
  cookie jar on `BeanfunClient` forwards `Set-Cookie` from
  Login/Index onto CheckAccountType.

Totals: 99 lib + 7 session_key + 4 smoke + 10 tw_login = 120 green;
fmt --check and clippy -D warnings green.
Port WPF LoginCompleted (L838-882) as the fixed five-field POST to
return.aspx that every non-QR login funnels through. Reuses the Chunk
3.2 post_return_aspx for transport and wraps the bfWebToken result into
a Session keyed to the client's configured region.

- build_completed_form(session_key, akey) emits the exact WPF field
  order: SessionKey, AuthKey, ServiceCode="", ServiceRegion="",
  ServiceAccountSN="0". ServiceCode/ServiceRegion are intentionally
  empty on the wire (WPF L856-857) even when the caller has real
  values in scope; the real metadata travels on the returned Session.
- login_completed(...) debug_asserts non-empty session_key+akey then
  delegates to post_return_aspx, propagating MissingWebToken unchanged.
- 6 unit tests lock down field order, values, empty-string wire shape
  and length.
- 5 integration tests cover TW/HK region stamping, URL-encoded POST
  body substrings (SessionKey=..., AuthKey=..., ServiceAccountSN=0,
  ServiceCode=&, ServiceRegion=&), and MissingWebToken propagation.

Intentional divergences from WPF (documented in the module docs):
- Skip the redundant `Location` GET (WPF L865): with auto-redirect
  enabled, WPF's `ResponseHeaders["Location"]` reflects the final 200
  and is usually empty — we read bfWebToken from the 302 directly via
  the Chunk 3.2 no-redirect pattern, which is strictly equivalent.
- No `GetAccounts` / `getRemainPoint` tail (WPF L874-879): those are
  account-service concerns and belong in P4 `services/beanfun/account`.
  Keeping login_completed narrowly scoped keeps SRP clean and avoids
  coupling the login path to independent downstream APIs.

Validation: 105 lib + 5 login_completed + 7 session_key + 4 smoke +
10 tw_login = 131 tests pass, clippy -D warnings clean, rustfmt clean,
and the two rustdoc warnings introduced by the first draft are gone
(the remaining 6 warnings are pre-existing and out of scope here).
Implements the HK Regular credential login end-to-end, with full
TOTP-challenge continuation surfaced through the Err channel of
`login_hk_regular`, aligning 1:1 with WPF `HkRegularLogin`
(Beanfun/Tools/BeanfunClient.Login.cs L202-293).

New modules
- `services/beanfun/login/totp_challenge.rs` -- typed continuation
  handle storing the parsed ViewStateForm + session key + account id
  + HK URL + service metadata, with a Debug impl that redacts
  session_key and viewstate contents. Preferred over WPF's
  stash-raw-HTML-on-client approach for smaller footprint, clearer
  invariants, and safer tracing.
- `services/beanfun/login/hk_error.rs` -- pure HTML parser that
  classifies HK error bodies into MsgBox / pollRequest / Unrecognized,
  mirroring WPF's two-regex precedence (L266 .. L274). The
  pollRequest variant preserves `token` for chunk 3.3.4
  (CheckIsRegisteDevice) even though 3.3.2 only surfaces the
  concatenated message. Also hosts `is_advance_check` and
  `classify_missing_akey_body` which both HK Regular and the
  upcoming TOTP flow consume (DRY, single source of truth).
- `services/beanfun/login/hk_regular.rs` -- orchestrator that chains
  get_session_key -> GET -> POST -> 4-way branch in WPF's exact
  precedence order. Uses the loose `extract_viewstate` regex but
  enforces HK's stricter "all three required" contract by mapping
  the Option fields to typed LoginError variants.

Behavioural contract
- LoginError::TotpRequired refactored from unit variant to
  `TotpRequired(Box<TotpChallenge>)` so the caller can dispatch into
  `login_totp` in chunk 3.3.3 without a second HTTP round-trip.
- MissingViewState mapped from ParserError to the flattened
  LoginError variant so callers see a single shape across all
  three viewstate-required errors.
- `service_code` / `service_region` threaded through the signature
  (WPF defaults `610074` / `T9`) -- mirrors WPF's
  `HkRegularLogin(AccountID, Password, service_code, service_region)`
  L191-195 and `MainWindow.xaml.cs::this.service_code` which flows
  from saved config at startup (L72-73, L357-358). Values populate
  the Session contract and ride on the TotpChallenge for the TOTP
  continuation.

Testing
- 19 new unit tests across totp_challenge (4), hk_error (9), and
  hk_regular (6) covering redaction, regex precedence, form order,
  URL build, and the four-way branch classifier.
- 11 new integration tests in `tests/hk_login.rs` covering: HK
  happy path / custom service metadata flows to Session / TOTP
  triggered / advance check / MsgBox / pollRequest concat /
  Missing{ViewState,Generator,EventValidation} / both-optional-fields
  precedence / Unrecognized no-akey / POST body field presence.
- All quality gates green (fmt / clippy -D warnings / cargo doc 0
  warnings).

Housekeeping
- Also cleaned 5 pre-existing rustdoc warnings from P3.2 files
  (redundant explicit link targets in account_login/send_login;
  ambiguous fn/mod links in check_account_type/index).
Consume `LoginError::TotpRequired(TotpChallenge)` handed back by the
HK Regular flow (chunk 3.3.2) and post the 6-digit OTP to complete
login. Mirrors WPF `BeanfunClient.Login.cs::TotpLogin` (L303-399)
including:

- 6 positional `&str` OTPs (otp1..otp6), matching WPF's signature so
  the otpCode1..6 wire mapping is obvious at call sites.
- Region-conditional `__VIEWSTATEENCRYPTED` -- HK emits the empty
  field, TW drops it entirely (WPF L347-348).
- WPF payload order (EVENTTARGET / EVENTARGUMENT / VIEWSTATE /
  VIEWSTATEGENERATOR / [VIEWSTATEENCRYPTED] / EVENTVALIDATION /
  otpCode1..6 / totpLoginBtn).
- Branch precedence mirroring WPF L359-388: RELOAD_CAPTCHA_CODE +
  alert (AdvanceCheck), akey (success, login_completed), else
  MsgBox / pollRequest / Unrecognized via the shared classifier.
- Defensive viewstate guards in WPF's check order (VIEWSTATE first,
  then EVENTVALIDATION, then VIEWSTATEGENERATOR) even though the
  challenge producer already validated them.

Service metadata (`service_code` / `service_region`) rides on the
TotpChallenge rather than being re-accepted on the `login_totp`
signature. WPF reads `this.service_code` at both HkRegularLogin and
TotpLogin call sites from the same MainWindow instance field; since
the OTP UI is modal, the value cannot change between those two
reads. Capture-at-HK-Regular is therefore observably equivalent to
capture-at-TOTP and spares the UI layer from re-threading app-config
state across the async OTP prompt await.

`TotpChallenge` drops its `#[allow(dead_code)]` now that `viewstate`
/ `session_key` / `service_code` / `service_region` are all
consumed by `login_totp`.

Coverage: 4 unit tests (HK vs TW field shape, positional OTP
mapping, fill values) plus 8 integration tests (happy path, custom
service metadata flows to Session, advance check, MsgBox,
pollRequest, unrecognized body, HK wire shape, TW wire shape
verifying __VIEWSTATEENCRYPTED absence). Quality gates: fmt clean,
clippy -D warnings clean, 178 tests pass, cargo doc 0 warnings.
…chunk 3.3.4)

Implements the single-shot `login_registered_device` orchestrator so HK
Regular / TOTP flows that surface a `pollRequest(...)` continuation can
drive `bfAPPAutoLogin.ashx` polling to completion, matching WPF's
`CheckIsRegisteDevice` (BeanfunClient.Login.cs L667-700) +
`bfAPPAutoLogin_Tick` (MainWindow.xaml.cs L2400-2441) behaviour.

- `LoginError`: add `DeviceRegistrationRequired { login_token, poll_url,
  param }`, `DeviceLoginTimeout`, `DeviceLoginRejected` to model the
  poll-response switch branches.
- `hk_error::classify_missing_akey_body`: `pollRequest` signal now
  produces `DeviceRegistrationRequired` (was display-only
  `ServerMessage`); tests in `hk_error`, `tests/hk_login.rs`,
  `tests/totp_login.rs` updated to assert the new variant + preserve
  login_token / url / param.
- `Endpoints::hk().newlogin_base`: point at
  `https://tw.newlogin.beanfun.com/`, matching WPF's hard-coded host for
  `CheckIsRegisteDevice` on HK region (L675-676); test + doc updated.
- `login/registered_device.rs`: new single-shot module. `Ok(Some)` on
  `IntResult==2` (after `login_completed` tail), `Ok(None)` on `0/1` or
  when `StrReslut` has no parseable `akey` (WPF's silent-retry path);
  `-1 -> ServerMessage`, `-2 -> DeviceLoginTimeout`, `-3 ->
  DeviceLoginRejected`, other/missing -> `Unknown`. `LT=<login_token>`
  POSTed through `newlogin_base/login/bfAPPAutoLogin.ashx` with no
  Referer (matching WPF).
- `tests/registered_device.rs`: 11 integ tests covering every IntResult
  branch, akey-less StrReslut silent retry, LT payload shape, and
  newlogin_base host routing.

Quality gates: `cargo fmt --check`, `cargo clippy --all-targets --
-D warnings`, `cargo test` (191 passed), `cargo doc -D warnings` all
green.
Implements `init_qr_login` — the first leg of the QR-code flow that
fetches the antiforgery token, the base64 PNG and the Beanfun mobile
deeplink. Mirrors WPF `BeanfunClient.GetQRCodeValue` (L409-453) +
`getQRCodeStrEncryptData` (L455-476) and reuses `get_login_index` so
the antiforgery extraction path lives in one place.

Behaviour highlights:
- Region guard: HK short-circuits with `LoginError::QrUnsupportedRegion`
  before any HTTP traffic, matching WPF UI's `loginMethodInit`
  L1099-1114 disable-button behaviour.
- `bitmap_base64` keeps WPF's storage shape (`data:image/png;base64,...`)
  so the future Tauri UI can drop it straight into `<img src=...>`.
- `deeplink: Option<String>` mirrors WPF's null-tolerant storage and
  passes the value through `normalize_beanfun_app_deeplink` to unwrap
  `play.games.gamania.com/.../deeplink/?url=...` redirects (WPF L478-504,
  case-insensitive host/path match for `OrdinalIgnoreCase` parity).
- Layered `Result == 0` / `ResultData` / `QRImage` checks all funnel to
  `LoginError::QrInitResultError`, matching WPF's `errmsg =
  "LoginIntResultError"` branch (L429-441 + L469-473).
- JSON parse failure surfaces as `LoginError::Json(...)` instead of
  WPF's unhandled `JObject.Parse` exception (same safety rationale as
  P3 chunk 3.3.4).

Coverage: 10 unit tests next to the source (deeplink helper edge
cases + serde shape) and 15 integration tests in `tests/qr_init.rs`
spanning happy / region guard / 4-header wire shape / each layered
error branch. Total suite now 216 (up from 191).
Implements `poll_qr_login_status` — the single-shot polling round-
trip that the UI fires once per second to check whether the user has
scanned / confirmed / cancelled the QR code. Mirrors WPF
`BeanfunClient.QRCodeCheckLoginStatus` (L609-665) and bundles the
classification logic into a typed `QrPollOutcome` enum so callers
can pattern-match without string compares.

Behaviour highlights:
- Single-shot API: function performs exactly one round-trip and
  returns one outcome. Caller (UI / orchestrator) owns the polling
  loop, cadence, and cancellation. Same contract as P3 chunk 3.3.4
  device-registration polling.
- `QrPollOutcome` keeps all four WPF `ResultMessage` strings as
  distinct variants (`Failed` / `WaitLogin` / `TokenExpired` /
  `Approved`) instead of conflating the two "keep polling" values
  into one — per the chunk 3.4 design decision (option B / no
  conflation). UI can show different copy if it wants to.
- `Approved` is a unit variant: WPF's `Success` branch never reads
  `ResultData` (L647-648); the downstream `qr_finalize` step pulls
  `skey` / `verification_token` from the cached `QrLoginInit`.
- Region guard short-circuits with `QrUnsupportedRegion` before any
  HTTP traffic, mirroring `qr_init`.

Wire shape (matches WPF L611-627 byte-for-byte):
- POST `/QRLogin/CheckLoginStatus` (note: NOT under `/Login/`).
- Headers: Accept + Referer + Origin + RequestVerificationToken +
  Content-Type. Deliberately omits `X-Requested-With` because WPF
  `SetBaseHeaders` clears all headers and the QR-poll path doesn't
  re-add it (unlike `qr_init`, which does add it).
- Empty form body. reqwest doesn't auto-set
  `Content-Type: application/x-www-form-urlencoded` for `.body("")`,
  so we set it explicitly to match `WebClient.UploadString`'s
  default for an empty NameValueCollection.

Error mapping:
- Unknown / missing `ResultMessage` → `LoginError::ServerMessage(raw)`
  (WPF L649-652 `errmsg = response`).
- JSON parse failure → `LoginError::QrJsonParseFailed`
  (WPF L634-638 `errmsg = "LoginJsonParseFailed"`).
- HTTP transport / non-2xx surface as the existing `Http` / `Unknown`
  variants via `?` and `ensure_success`.

Side change: `QrLoginInit` gains a `skey: String` field so the
poll/finalize steps can rebuild the `Referer` URL without the caller
threading the value separately. Mirrors WPF `QRCodeClass.skey`.
Tests updated accordingly.

Coverage: 3 unit tests on the `PollResponse` serde shape (extras
ignored, key spelling locked, missing field tolerated) and 9
integration tests in `tests/qr_poll.rs` (4 happy `ResultMessage` →
each outcome, unknown / missing → `ServerMessage(raw)`, non-JSON →
`QrJsonParseFailed`, HK region short-circuit, full wire-shape
verification including the negative `X-Requested-With` assertion).
Total suite now 228 (up from 216).
Implements `finalize_qr_login(client, &init) -> Session`, the final
HTTP sequence after `poll_qr_login_status` returns `Approved`. Mirrors
WPF `BeanfunClient.Login.cs::QRCodeLogin` (L530-607) split into three
discrete round-trips for testability:

- step 1: GET `QRLogin/QRLogin` (handshake; body discarded, mirroring
  WPF L535-541 which only `Debug.WriteLine`'s the response).
- step 2: GET `Login/SendLogin` via the shared `send_login` helper,
  passing the QR-specific Accept string (WPF L545 — adds image/avif,
  image/webp, image/apng over the TW Regular L124 string).
- step 3: POST `return.aspx` via the shared `post_return_aspx` helper,
  scraping bfWebToken from the raw Set-Cookie header (WPF L588-598).

Skips WPF `LoginCompleted`'s second `return.aspx` POST with the garbage
`AuthKey="OK"` payload (L838-882) — its only useful side effect, the
bfWebToken capture, is already done in step 3. `GetAccounts` is left
to P3.5.

DRY: parameterises `send_login`'s Accept header (callers now pass the
exact byte string; TW Regular and QR send different values per WPF).
SRP: Accept is a "self-description" detail and now lives at the
callsite, where the WPF reference is grep-able.

`Session.account_id` is set to "" because QR login has no user-typed
account id; P3.5's GetAccounts will populate it.

Documented divergence: reqwest 0.12 (via hyper) auto-injects
`Accept: */*` on every request with no public API to suppress it, so
step 3 sends `Accept: */*` while WPF sends none. RFC 9110 §12.5.1
specifies these as semantically equivalent. Locked by the wire-shape
test so any other unintended Accept value still trips an assertion.

Quality gates: cargo fmt --check, cargo clippy --all-targets -D
warnings, cargo test --workspace (239 pass — +11: 9 integration
+ 2 unit), RUSTDOCFLAGS=-D warnings cargo doc --no-deps all green.
…view)

Chunk 3.4 self-review uncovered an unverified assumption in the QR
finalize flow: the initial implementation skipped WPF's second
return.aspx POST inside LoginCompleted (BeanfunClient.Login.cs L838-882)
on the rationale that "the only useful side effect (capturing
bfWebToken) was already done in step 3". A line-by-line re-read showed
WPF deliberately re-reads bfWebToken from the cookie jar AFTER that
second POST (L868), meaning the WPF developers expected the POST to
either rotate the token or carry session-affirmation state we don't
observe from the outside.

Per the 1:1 functional parity rule, and to eliminate the stale-token
risk in the absence of a real-server test bed, finalize_qr_login now
runs all four WPF steps:

  1. GET QRLogin/QRLogin (handshake, body discarded)
  2. GET Login/SendLogin
  3. POST return.aspx with the SendLogin form (token captured but
     deliberately discarded -- transient)
  4. login_completed("OK", ...) -- shared 5-field LoginCompleted POST,
     canonical bfWebToken comes from here

The QR flow now reuses login_completed verbatim (DRY: same helper that
HK Regular and TOTP funnel through), with the QR-specific bits limited
to the akey="OK" sentinel and an empty account_id.

Drive-by:
- Fix completed.rs doc bug: it referenced "QRCodeCompleted" (a method
  that doesn't exist in WPF) when describing the shared tail. The
  actual WPF method is LoginCompleted; QR / HK Regular / TOTP all
  funnel through it. TW Regular is the lone exception (its return.aspx
  form is scraped from SendLogin, not the fixed 5-field shape).
- Document why the QR flow passes "OK" as akey on the parameter docs.

Tests:
- happy_path now locks Session.web_token == step 4 token AND
  != step 3 token, so a future regression that flips back to
  "skip step 4" trips the assertion.
- Split return_aspx_missing_set_cookie into per-step variants:
  step3_missing_set_cookie_yields_missing_web_token (with
  short-circuit assertion) and
  step4_login_completed_missing_token_yields_missing_web_token
  (the canonical user-facing failure surface).
- New step4_login_completed_posts_five_field_form_with_authkey_ok
  asserts the 5-field LoginCompleted body shape and proves step 3's
  fields don't leak into step 4.
- New steps_3_and_4_post_return_aspx_in_that_order asserts the two
  return.aspx POSTs happen in the right sequence.
- Wiremock body_string_contains discriminators (AuthKey=AUTH_INNER
  for step 3, AuthKey=OK for step 4) keep the two POSTs cleanly
  separated despite sharing the same path.

Quality gates: cargo fmt / clippy -D warnings / cargo test
(qr_finalize 12 integ + 2 unit, all 11 binaries green) /
cargo doc --no-deps all pass.
Port WPF `BeanfunClient.Logout()` (Login.cs L884-909) as a 3-step
region-aware sequence:

1. GET portal_base/generic_handlers/remove_bflogin_session.ashx
2. GET {logout_host}/logout.aspx?service=999999_T0
   (TW → newlogin_base, HK → login_base, mirroring WPF's overloaded
    `loginHost` local at L887-897)
3. POST newlogin_base/generic_handlers/erase_token.ashx with
   `web_token=1` (TW only; WPF L900 region guard)

Failure policy: best-effort — every step runs regardless of earlier
failures, and we return the FIRST error encountered. WPF's callers
swallow errors entirely (try/catch in App.xaml.cs L72-76 +
MainWindow.xaml.cs L237-241), but surfacing the first error gives
us diagnostic value without breaking that fire-and-forget contract
(callers can still `let _ = logout(&client).await;`).

Cookie jar deliberately not cleared, mirroring WPF (which never
clears its WebClient cookies in Logout). Long-lived isolation is
done by dropping and rebuilding the BeanfunClient.

Also adds `BeanfunClient::newlogin_url()` helper to mirror the
existing `portal_url` / `login_url` API; first user is logout's
step-2-TW and step-3 URL builds.

Test coverage:

- tests/logout.rs (10 tests): TW happy / HK happy + step-3 skip /
  service=999999_T0 query / web_token=1 form body + Content-Type /
  per-step 5xx failure × 3 + still-attempts-remaining-steps proof /
  multi-step-fail returns FIRST error / TW step-2 routes through
  newlogin_base / HK step-2 routes through login_base.
- tests/login_then_logout.rs (2 tests): TW Regular login → logout
  hits all 3 logout endpoints; HK Regular login → logout hits
  only 2; both assert cookie jar is non-empty after logout
  (WPF-aligned never-clear policy lock).

Quality gates: fmt / clippy -D warnings / cargo test
(255 tests) / cargo doc all green.

Refs: WPF BeanfunClient.Login.cs::Logout L884-909
Add `login::orchestrator` — a typed `LoginMethod` enum + `login_with`
single-call dispatcher that routes single-shot password flows to
the right underlying `login_*` function. This is the entry point
the Tauri command layer (P6) will call from the IPC boundary.

Variants:

- `LoginMethod::TwRegular` → `login_tw_regular`
- `LoginMethod::HkRegular { service_code, service_region }`
  → `login_hk_regular`

TOTP and QR are deliberately NOT in the enum — module docs explain
why in detail. Short version:

- HK TOTP needs a mid-flow interactive 6-digit code from the user;
  fits a two-call (`TotpChallenge` → submit) shape, not a single
  `dispatcher → Session` shape.
- QR Code is a 3-step UI-driven flow (`init` → poll-loop →
  `finalize`); the UI must observe `QrPollOutcome` transitions to
  render the QR image and react to user actions.
- HK device-registered re-login is a recovery path off TOTP, not a
  user-selectable top-level method.

All four flows remain reachable as direct public exports from the
parent module — the dispatcher is purely a typing / routing
convenience for the single-shot subset.

Test coverage:

- One unit test in `orchestrator.rs` pinning `LoginMethod: Send`
  (the dispatcher runs from tokio worker threads via Tauri).
- `tests/orchestrator.rs` (3 tests): TW dispatch produces a
  TW session; HK dispatch with region defaults produces an HK
  session; HK dispatch with custom service_code/service_region
  forwards them all the way to the returned `Session` (creds +
  service-args plumb-through lock).

Per-flow branch coverage already lives in `tw_login.rs` /
`hk_login.rs`; this file's job is solely the dispatch contract.

Also updates `Todo.md` to tick chunk 3.5 and document the
dispatcher / cookie-jar / first-error design decisions.

Quality gates: fmt / clippy -D warnings / cargo test
(259 tests) / cargo doc all green.
… chunk 3.5 review)

Doc-only sweep after reviewing chunk 3.5 against the WPF reference and
the current Rust surface. Four staleness/accuracy fixes, no behaviour
changes.

- orchestrator.rs::LoginMethod::TwRegular: previous wording claimed TW
  scrapes service_code/service_region from the SendLogin form's hidden
  inputs. Reality: login_tw_regular's signature does not take them and
  the returned Session is always populated with LoginRegion defaults.
  The real reason the dispatcher omits them is that GetAccounts (the
  only consumer of those args in WPF's TwRegularLogin) is deferred to
  P4. Doc rewritten to describe the signature reason and the P4
  forward-compatibility path.

- logout.rs failure policy: previous wording mentioned only one
  divergence from WPF (we return the first error vs WPF callers
  silently swallowing). It missed that WebClient throws on the first
  non-2xx response, so WPF's Logout() actually short-circuits internally
  too -- our best-effort all-steps behaviour is the second, intentional
  divergence. Both are now spelled out, with a separate subsection
  explaining the choice of returning the first error rather than a
  Vec<LoginError>.

- client.rs::cookie_store(): previous wording said "(logout)", but the
  chunk 3.5 never_clear policy means logout no longer touches this API
  -- the only caller in the entire codebase is tests/login_then_logout.rs
  asserting the never_clear invariant. Doc rewritten to be honest about
  current usage and to point at logout.rs for the rationale. Visibility
  kept pub: integration tests only see pub items, and future P4/P6
  callers (multi-session diagnostics) may legitimately need direct jar
  access.

- logout.rs cookie-jar section: replaced the brittle "see client.rs
  module docs L20-22" hard-coded line range with an intra-doc link to
  the "Cookie jar" section name.

Quality gates: cargo fmt, cargo clippy --all-targets -D warnings,
cargo test --workspace (259 tests), cargo doc --no-deps --workspace
(0 warnings).
Implement `services/beanfun/account.rs` covering the four account
JSON-shaped endpoints from `BeanfunClient.Account.cs`:

- `get_accounts(...)` — orchestrates auth.aspx → list page →
  per-row `get_create_time` (silenced to None per row, matching
  WPF `try { ... } catch { return null; }`) → ssn-asc sort.
- `get_service_contract(...)` — POST `gamezone.ashx`
  `GetServiceContract`, returns the JSON `strResult` (empty when
  `intResult != 1`, matching WPF).
- `add_service_account(...)` — POST `gamezone.ashx`
  `AddServiceAccount`. Empty `name` short-circuits to `Ok(false)`
  before firing, matching WPF's guard.
- `change_service_account_display_name(...)` — POST `gamezone.ashx`
  `ChangeServiceAccountDisplayName`. Empty / unchanged name
  short-circuits to `Ok(false)`, matching WPF's two early returns.

Public types: `ServiceAccount`, `AccountListResult`,
`AmountLimitNotice` (None / AuthReLoginRequired / Other(String)).
Service layer keeps the notice as raw Traditional Chinese; i18n
and simplified-Chinese conversion are deferred to the UI layer
(WPF's `I18n.ToSimplified()` + `TryFindResource("AuthReLogin")`
were view-layer concerns).

User-defined account ordering (`AccountList.ApplyAccountOrder`) is
intentionally deferred to P5 (storage) / P6 (commands); P4.1 only
performs the deterministic ssn-asc first-pass sort that WPF does
inline.

Supporting changes:

- `core/time.rs`: new `dt_compact` (`Y(M-1)DDhhmmssfff`) and
  `dt_iso` (`yyyyMMddHHmmss.fff`) cache-buster formatters
  reproducing `BeanfunClient.cs::GetCurrentTime(2)` / `(1)` from
  spec (no WPF source referenced). Both functions take a
  `chrono::DateTime<Local>` so unit tests can pin the timestamp.
- `core/parser/account.rs`: new
  `extract_service_account_create_time` + supporting regex for
  the `<input id="dteCreate" value="..." />` field that
  `get_create_time` scrapes.
- Re-exports added in `core/parser/mod.rs` and
  `services/beanfun/mod.rs`.

Tests:

- 13 integration cases in `tests/account.rs` covering each
  endpoint's happy path plus the WPF-aligned edge cases (sorting,
  quota notices, partial create-time failures, empty list, empty
  name, same-name guard, `intResult != 1`).
- Unit tests for `parse_int_result_eq_one` (empty body, null,
  missing field, non-1 ints, invalid JSON) and
  `classify_amount_limit_notice` (absent, advance-auth substring,
  arbitrary text preserved verbatim).
- Unit tests in `core/time` use pinned `DateTime` values to
  assert exact formatter output, including January (month0=0),
  October (month0=9), December (month0=11), and the
  zero-padding edge cases.

Quality gates: cargo fmt --check, cargo clippy --all-targets
-D warnings, cargo test --all-targets, cargo doc
--no-deps --document-private-items all green.
Port `BeanfunClient.OTP.cs::GetOTP` (151 lines C#) to async Rust as
`services/beanfun/otp.rs`. Issues a 5-step HTTP sequence against the
Beanfun portal, then DES-ECB-decrypts the resulting envelope into the
8-character OTP the launcher hands to the game client.

What lands
----------
* `services/beanfun/otp.rs` — `get_otp(client, session, account, sc, sr)`
  drives the full flow:
    1. GET  game_zone/game_start_step2.aspx     -> longPollingKey [+ TW unkData] [+ screatetime fallback]
    2. GET  generic_handlers/get_cookies.ashx   -> m_strSecretCode
    3. POST generic_handlers/record_service_start.ashx  (response discarded; primes server-side state)
    4. GET  get_result.ashx?meth=GetResultByLongPolling (response discarded; long-poll trigger)
    5. GET  generic_handlers/get_webstart_otp.ashx -> "1;{key8}{ciphertext_hex}"
    6. WCDES decrypt -> trim trailing NULs -> OTP
  Each step is a private SRP-clean async helper; pure parsing /
  envelope-decoding logic is split into `parse_*` + `step_6_decrypt`
  functions for unit testing.
* `services/beanfun/error.rs` — 7 new typed `LoginError` variants
  (1:1 with WPF `errmsg` strings):
    OtpMissingLongPollingKey { snippet }
    OtpMissingUnkData              (TW only)
    OtpMissingCreateTime
    OtpMissingSecretCode
    OtpEmptyResponse
    OtpServerRejected { message }
    OtpDecryptionFailed { cause }
* `services/beanfun/mod.rs` — register `pub mod otp` + re-export
  `get_otp`; Layers table picks up the new row.
* `tests/otp.rs` — 12 wiremock-backed integration tests covering
  TW happy / HK happy / 4 step1 errors / 1 step2 error / 3 step5
  errors / 1 step6 decrypt failure / 2 wire-shape locks.

Design decisions worth flagging
-------------------------------
* OTP step 2 host is region-asymmetric (TW = tw.newlogin.beanfun.com,
  HK = login.hk.beanfun.com). Existing `Endpoints` schema doesn't
  cover this exact split; rather than adding a fourth base URL for
  one call, `step_2_get_secret_code` branches on
  `client.config().region` between `newlogin_url` (TW) and
  `login_url` (HK). Documented in module doc.
* `account.screatetime == None` fallback: WPF mutates the input
  account; we keep `&ServiceAccount` immutable and store the
  fallback in a local `Step1Data.screatetime`. Re-uses
  `core::parser::extract_service_account_create_time` from P4.1
  (DRY win).
* WPF dev artifacts NOT ported (per cross-chunk policy):
  `ServicePointManager.Expect100Continue = false` (reqwest's
  default behaviour is byte-equivalent), and the commented-out
  `Thread.Sleep` / `Console.WriteLine` (dead code).
* `step_5_get_otp` builds its URL via `format!` rather than
  reqwest's `.query()` builder to preserve byte-for-byte WPF wire
  format: `CreateTime` uses `%20` (not form-encoded `+`), and the
  64-char `ppppp=` hex literal must appear verbatim.
* `OtpServerRejected.message` carries server text raw — UI prepends
  the localised "Get OTP failed" prefix, matching the
  `AmountLimitNotice` separation-of-concerns established in P4.1.
* `tick_count_ms()` mirrors .NET's `Environment.TickCount` (i32 ms,
  wraps every ~24.8 days); pure cache-buster, server doesn't
  validate.
* New crate dep: `percent-encoding = "2"` (already a transitive dep
  via `url`); `Uri.UnescapeDataString`-equivalent for parsing the
  TW unk_data URL-encoded fragment.

Quality gates
-------------
* cargo fmt --all -- --check          green
* cargo clippy --all-targets -D warnings  green
* cargo test --all-targets             193 lib + 12 otp + all prior
                                       integration tests pass
* cargo doc --no-deps --document-private-items  zero warnings
Port BeanfunClient.Verify.cs (getVerifyPageInfo / getVerifyCaptcha /
verify) plus MainWindow.xaml.cs::reLoadVerifyPage parsing and
verifyWorker_DoWork response classification into
services/beanfun/verify.rs. Surfaces the 3-call captcha re-auth
path triggered by LoginError::AdvanceCheckRequired.

Public API (TW only by design):
- get_verify_page_info(client, advance_check_url: Option<&str>)
  -> Result<VerifyPageInfo, LoginError>
- get_verify_captcha(client, samplecaptcha) -> Result<Vec<u8>, LoginError>
- submit_verify(client, page_info, verify_code, captcha_code)
  -> Result<VerifyOutcome, LoginError>

Six new typed LoginError variants (1:1 mapped to WPF errmsg
strings): VerifyUnsupportedRegion, VerifyMissingViewState,
VerifyMissingEventValidation, VerifyMissingSampleCaptcha,
VerifyMissingLblAuthType, VerifyCaptchaImageTooSmall { actual }.

VerifyOutcome enum captures the four ways verifyWorker_DoWork reads
the POST response: Success, ServerMessage(String), WrongCaptcha,
WrongAuthInfo. All four variants are valid HTTP-200 business
results returned through Ok(); only transport / parse failures take
the Err channel.

HK clients are rejected up front with VerifyUnsupportedRegion. WPF
hardcodes tw.newlogin.beanfun.com on all three endpoints
(BeanfunClient.Verify.cs L23-25 / L43-45 / L90-92 +
MainWindow.xaml.cs L797-803) and only the TW account_login branch
sets advanceCheckUrl (BeanfunClient.Login.cs L186), so HK regular /
TOTP triggers of LoginAdvanceCheck would hit a TW host with HK
cookies in WPF -- a silent dead path. We surface the typed error
instead, leaving the UI to fall back to "please re-login" rather
than render a verify form for HK sessions.

Pure helpers (ensure_tw, build_default_advance_check_url,
build_captcha_url, build_verify_form, parse_verify_page,
classify_verify_response) keep all parsing / form construction /
outcome classification side-effect-free for unit testing. The
private bounded_bytes helper mirrors BeanfunClient::bounded_text
without UTF-8 validation; lives in verify.rs because captcha is
the only byte-returning call across the entire service surface.

Tests: 18 unit (region guard + URL builders + form shape + parse
field-missing branches + outcome classification) + 15 integration
(wiremock-backed full 3-call flow + HK rejection x 3 + URL routing
+ alert short-circuit + captcha size threshold + POST body wire
order + 4 outcome variants). Full suite: 214 lib + 123 integration,
fmt / clippy --all-targets -- -D warnings / cargo doc all clean.
Drop the strict TW-only region guard from services/beanfun/verify.rs and
let HK clients run the same 3-call flow as TW clients, matching WPF
byte-for-byte.

WPF rationale (re-audited):
  - BeanfunClient.Verify.cs L23-25 / L43-45 / L90-92 hardcode
    `tw.newlogin.beanfun.com` for AdvanceCheck.aspx GET, BotDetectCaptcha
    GET, and AdvanceCheck.aspx POST regardless of LoginRegion.
  - MainWindow.xaml.cs::reLoadVerifyPage L797-803 strips and re-prepends
    the same TW host onto the form action.
  - HK regular (BeanfunClient.Login.cs L249) and HK TOTP (L361) both
    raise `LoginAdvanceCheck` when the server returns
    `RELOAD_CAPTCHA_CODE` + `alert`. That signal is server-driven, so HK
    reaching this flow is a supported recovery path, not a dead branch.

Earlier port rejected HK with a typed `VerifyUnsupportedRegion`,
assuming "HK cookies on TW host" was a guaranteed-fail dead path.
Re-audit shows that interpretation removes a flow WPF has shipped to HK
users for years; align with WPF instead and let the server decide cookie
acceptance. Failure modes degrade gracefully into existing typed
`VerifyMissing*` errors (same observable surface as WPF's
`VerifyNoViewstate` / `VerifyNoEventvalidation`).

Changes:
  - error.rs: remove `LoginError::VerifyUnsupportedRegion` (was unused
    after the guard came out).
  - verify.rs: delete `ensure_tw` helper + its 3 call sites + 2 unit
    tests; rewrite module-level "Region routing" docs and per-fn
    `# Errors` sections to document the region-agnostic contract; add
    `url_helpers_target_tw_newlogin_host_even_for_hk_client` unit test
    to lock the `Endpoints::hk().newlogin_base = TW` invariant we now
    rely on.
  - tests/verify.rs: drop 3 hk-rejection integration tests; add
    `hk_happy_path_runs_full_flow_via_tw_newlogin_routing` exercising
    the full 3-step flow against an HK-configured client.
  - Todo.md: rewrite chunk 4.3 design decisions to record the new HK
    alignment policy and updated test counts (17 unit + 13 integration).

Quality gates:
  - cargo fmt --all -- --check ........ pass
  - cargo clippy --all-targets -- -D warnings .. pass
  - cargo test --all-targets .......... 213 lib + 121 integration pass
  - cargo doc --no-deps --document-private-items .. 0 warnings
Port `BeanfunClient.UnconnectedGame_*` (init / check / check_nickname /
add / change_password) from `BeanfunClient.Account.cs` to
`services/beanfun/account.rs`, preserving 1:1 functional parity with
the WPF implementation while exposing a typed, region-aware Rust API.

- Public types: `AddAccountSession` (viewstate triplet + region for
  round-tripping WebForms hidden state, with HK-only `__VIEWSTATEENCRYPTED`
  materialised internally), `AddAccountInit`, `CheckOutcome`,
  `AddAccountOutcome` (`Success` | `ErrorMessage`), `ChangePasswordOutcome`
  (`VerifyCodeSent` | `ErrorMessage`).
- Public functions: `unconnected_game_init_add_account_payload`,
  `unconnected_game_add_account_check`,
  `unconnected_game_add_account_check_nickname` (shares
  `add_account_check_inner`), `unconnected_game_add_account`,
  `unconnected_game_change_password` (5-step orchestration).
- 5 typed errors mirroring `VerifyMissing*` pattern:
  `AccountMgmtMissingViewState{,Generator}`,
  `AccountMgmtMissingEventValidation`, `AccountMgmtMissingGameName`,
  `AccountMgmtMissingAccountLen`.
- Region handling: `mgmt_url` injects `TW/` vs `HK/` portal prefix;
  `change_password_url` deliberately downgrades scheme to `http://` for
  the three HK-only `change_password` steps to match the apparent WPF
  typo (documented as a `# WPF deviation candidate` for P10 security
  review).
- 20 unit tests + 15 integration tests in
  `tests/account_management.rs` covering TW/HK happy paths, missing
  hidden-field typed errors, DN field-name divergence (`t1` vs
  `txtServiceAccountDN`), `__VIEWSTATEENCRYPTED` HK toggle,
  change_password 5-step success / `lblErrorMessage` rejection /
  Unknown outcome.
- Quality gates: `cargo fmt --check` clean, `cargo clippy --all-targets
  -- -D warnings` 0 warning, `cargo test --all-targets` 237 lib units +
  13 integration binaries 0 failed, `cargo doc --no-deps
  --document-private-items` 0 warning.
YCC3741 added 19 commits April 18, 2026 15:23
Beanfun prod returns ResultCode / Result / ResultMessage / Captcha as
integers for some branches; WPF reads them via JToken.ToString() and
tolerates any JSON scalar, while Rust serde Option<String> rejects
`invalid type: integer 1, expected a string` and fails the whole login.

Add a shared `deserialize_jtoken_to_string` visitor (str / i64 / u64 /
f64 / bool / null, bool maps to .NET-capitalised "True" / "False",
object + array still rejected as a regression probe) and a
`parse_step_json` wrapper that logs a bounded body_preview via
tracing::warn! when parsing fails, applied to the four affected fields
in account_login.rs + check_account_type.rs. 20 new tests pin the
parity matrix, legacy all-string shape, mixed int/str shape, and the
UTF-8-safe log truncation.
…sites

Happy-path login had no operator-visible log, and the three diagnostic
failure sites (TW / HK session-key regex miss, SendLogin empty scrape)
surfaced only as typed `LoginError` variants with no context on the
wire shape that actually came back from Beanfun. That left us without
enough data to tell a transient server-side hiccup apart from a real
protocol change when a user reports a failure.

Add a symmetrical pair of `tracing::info!` lines at the two terminal
"Session assembled" sites (`tw_regular` for TW, `completed` for HK
Regular / HK TOTP / QR Code — the three flows that funnel through
`login_completed`), logging `step` / `region` / `account_id`. Session
bearers (skey, web_token, akey) are deliberately not logged; the
`Session::Debug` impl already redacts them, and `account_id` is the
same field `SessionInfo` exposes to the frontend.

On the failure side, wrap the three `.ok_or(...)` / `if empty` sites
with `tracing::warn!` that includes either the final redirected URL
(TW session-key) or a bounded `body_preview` (HK session-key +
SendLogin) capped at the shared `BODY_LOG_PREVIEW_CHARS` via the
existing `truncate_chars` helper (UTF-8 safe, CJK won't split
mid-codepoint). Helper + const stay private to `login/mod.rs` —
submodules access them as descendants, no visibility escalation
needed.

No behavioural change: the helper / const already existed for the
P3 JToken-parity fix and every `LoginError` variant and error-code
mapping is unchanged. This is purely observability.
- Initialise a tracing_subscriber in lib.rs so backend
  tracing::info!/warn! events reach the Tauri dev console. This is
  the prerequisite for the D5 GamePass cookie-jar dumps.
- Hoist read_bfwebtoken_from_jar into login/mod.rs, scoped to the
  portal origin to match WPF BeanfunClient.GetCookie. Used by
  login_completed now; reused by GamePass later.
- login_completed: follow the Location header on 302 like WPF
  L803-836, read the token from the jar instead of the Set-Cookie
  header, and use account_id="<deferred>" as the sentinel the
  orchestrator resolves via GetAccountInfo afterwards.
- qr_poll: accept the "411 Length Required" body shape the live
  server occasionally returns (WPF L1114-1141 parity).
- qr_finalize: swallow MissingWebToken on step 3 so the next poll
  cycle picks the token up (empty-webtoken retry, WPF parity).
- return_aspx: docstring-only clarification, no behaviour change.
- Tests: rename the QR-finalize fixture hook to mount_after_landing
  across hk/tw/totp/qr/orchestrator/logout/registered-device
  harnesses, and add a login_completed case covering the <deferred>
  sentinel.
- P11 hotfix: restore the LightBlue accent override path in
  useThemeColor + matching spec coverage.
Rebuild the WPF login flow as Vue pages under `/login`, with the
parent LoginPage shell, region picker, id-pass, QR, and GamePass
forms all wired into the hash-mode router. The 4 forms mirror the
WPF XAML-driven flow 1:1 while adopting Element Plus for layout and
keeping i18n / Pinia / theme plumbing centralised.

Frontend (D1-D5):
- `pages/LoginPage.vue`: parent shell rendering `<RouterView />`
  and page-scoped chrome. Swaps the P11 placeholder page out.
- `pages/LoginRegionSelection.vue` (D2): Taiwan / Hong Kong picker
  persisted to config store; sets the default region honoured by
  subsequent form-to-form navigations.
- `pages/IdPassForm.vue` (D3): account + password form covering
  regular + TOTP + registered-device + verify branches via the
  `loginRegular` / `loginTotp` / `loginRegistered` store actions;
  switch links to QR and GamePass.
- `pages/QrForm.vue` (D4): QR poll with expired / approved /
  pending / retry handling; HK redirect toast; inline
  connection-lost fallback with a Reload affordance.
- `pages/GamepassForm.vue` (D5): CP2 frontend — 4-step progress
  tracker (prepare / openWindow / authenticate / complete),
  HK-unsupported redirect toast, connection-lost / window-error
  banners, Reload affordance.

Router / shared infra:
- `router/index.ts`: replace the P11 placeholder with the nested
  `/login` shell + named children (`login-region` / `login-id-pass`
  / `login-qr` / `login-gamepass`).
- `i18n/messages.ts`: retire `placeholder.*` namespace; add
  `loginShell.*` / `loginRegion.*` / `loginQr.*` / `loginGamepass.*`
  and `errors.auth.gamepass_window_already_open` across zh-TW /
  zh-CN / en-US.
- `stores/auth.ts`: switch `loginQrCheck` to `SafeResult<QrStatus>`
  so poll loops can branch on success / error without duplicate
  toasts; add `loginGamepassStart` + `applyGamepassSession`
  actions and the `LoginGamepassStart` guard slot.
- `types/bindings.ts`: add `loginGamepassStart` /
  `openGamepassWindow` bindings generated by tauri-specta.
- Specs: `tests/unit/pages/*` for each new page, updated router /
  i18n / auth-store specs.

Backend (D5):
- `services/beanfun/login/gamepass.rs`: new module owning the WPF
  GamePass flow parity — `seed_webview_cookies_from_client`,
  `try_complete_gamepass_login`, `inject_webview_cookies`, and the
  host-only cookie domain-rehydration fix discovered during live
  retest (host-only cookies are re-emitted with an explicit
  `Domain` attribute so the Tauri WebView accepts them).
- `commands/auth.rs`: `login_gamepass_start` +
  `open_gamepass_window` commands plus the Tauri event emitters
  (`gamepass-login-success` / `gamepass-login-failed`) and the
  `trace_cookie_jar` diagnostic helper used for debugging the
  cookie-seed race.
- `commands/state.rs`: `pending_gamepass` slot holding the
  `PendingGamepass { client, skey }` struct across the user's
  WebView round-trip.
- `commands/mod.rs`: register the new commands via
  `collect_commands!` turbofish.
- `commands/error.rs`: map `LoginError::GamepassUnsupportedRegion`
  → `auth.gamepass_unsupported_region`, plus inline race-guard /
  navigate codes (`auth.gamepass_window_already_open`,
  `ui.gamepass_navigate_failed`).
- `services/beanfun/error.rs`: add
  `LoginError::GamepassUnsupportedRegion` variant.
- `services/beanfun/login/mod.rs`: re-export the new gamepass
  module.
Mark the P12.1 D1-D5 login-flow milestones + the two D5 live-test
hotfixes (cookie-seed race guard, host-only cookie domain
rehydration) as complete in Todo.md. Keeps the Todo.md history as
the single source of truth for D-step sequencing per user rule #5.
… audit + auth guards

D6 - TOTP entry: extract reusable useOtpInputs composable (single-source
six-digit OTP state + paste/keyboard handling, SRP-conformant), wire
LoginTotp.vue page + spec, route /login/totp guarded by pendingTotp.

D7 - LoginWait splash: add LoginWait.vue + spec for the post-login
service-token redirect window, route /login/wait keyed off the auth
store session-pending state.

D8 - Verify (advanced auth): add VerifyPage.vue + spec covering the
captcha refresh / submit / wrong-captcha / wrong-auth-info / alert
branches; rename auth.verify_required to auth.advance_check_required
across services/invoke doc + IdPassForm spec to match the WPF
AdvanceCheck.aspx flow exactly. pendingVerify slot added to auth store
to gate /login/verify entry.

D9 - i18n key-usage audit: add tests/unit/i18n/key-usage.spec.ts that
statically scans src/{pages,composables,components,stores}/**/*.{vue,ts}
for literal t('...') call sites and asserts (a) every literal key
exists in zh-TW messages (missing-key guard, fail-loud) and (b) every
frontend-only zh-TW leaf path is reachable from a literal call site or
an explicitly-declared dynamic consumer (errors.{code} /
themePreset.{name} / loginRegion.defaultBadge / loginRegion.totpHint).
messages.ts docblock updated with the DYNAMIC_KEY_CONSUMERS extension
protocol.

D10 - router catch-all + auth guard infra: add LOGGED_IN_LANDING_PATH
constant + RouteMeta.requiresAuth + installRouterGuards(router, deps)
that wires beforeEach (redirect unauth -> /login?redirect=<path>, with
defensive isLoginFunnel check to avoid encoding redirects to the login
funnel itself) plus a session-expired bridge that registers a handler
through services/invoke::registerSessionExpiredHandler to clear the
local session and route to /login?sessionExpired=1. New
auth.clearSession() action does the local-only state wipe and is
reused by logout() (DRY); main.ts wires the guards after app.use(router)
with the auth store's isLoggedIn + clearSession injected. P12.1 itself
declares zero requiresAuth routes -- the infra is staged for P12.2
when /accounts (the LOGGED_IN_LANDING_PATH target) lands.

Quality gates green: vitest 251/251, vue-tsc 0, eslint 0, prettier
clean, cargo fmt + clippy -D warnings + cargo test all pass.
Mark D6-D10 complete in Todo.md (TOTP entry, LoginWait splash, Verify
advanced-auth page, i18n key-usage audit, router auth guard infra) and
record D9.5 RememberVerify-persistence as cancelled -- WPF
accountManager.addAccount writes passwdList + verifyList atomically per
account, so splitting persistence ahead of P12.2 D1 (AccountList read
side) would create dead-write intermediate state and violate SRP.
Persistence (RememberPassword + RememberVerify + AutoLogin +
LoginMethod) is deferred wholesale to P12.2 D1 where the read-side UI
will land alongside.
Port the WPF account-management surface to the Tauri/Vue stack with
strict 1:1 functional parity to `Beanfun/Pages/AccountList.xaml(.cs)`,
`Pages/ManageAccount.xaml(.cs)`, and seven WPF dialog windows.

Pages
- pages/AccountList.vue: 4 list states + per-row context menu
  (Change Alias / Account Info / Check Email), Get OTP + clipboard +
  auto-paste preference, vuedraggable reorder persisted via
  Config.xml AccountOrder_<gameCode>, Gash balance auto-fetch on
  mount + force refresh button (matches WPF updateRemainPoint
  formatting incl. HK in-game suffix carve-out for remainPoint == 0).
- pages/ManageAccount.vue: Users.dat CRUD page with single table +
  region chip + search + import/export (plaintext JSON via backend
  commands.import_records/export_records) + AES backup/restore entry
  via Data Backup toolbar button.

Dialogs (windows/*.vue)
- AddServiceAccount.vue: WPF parity for service-account add flow
  using Contract.vue for terms preview.
- ChangeServiceAccountDisplayName.vue: per-row alias rename.
- ServiceAccountInfo.vue: read-only account-detail dialog.
- AddAccount.vue / ChangeAccount.vue: Users.dat record CRUD
  (P12.2 D8) with method/region/auto-login fields.
- AccRecovery.vue: AES-128-CBC backup/restore (WPF wire format:
  MD5(password) -> AES key, MD5("pungin") -> IV, PKCS7 padding,
  base64 ciphertext) with Export / Recovery flows; decrypt errors
  map to MsgDecryptFailed, other backend errors to RecoveryFailed.
- Contract.vue: pure terms-of-service viewer (mockup acceptance gate
  intentionally not ported - WPF has no agreement state).
- CopyBox.vue: generic (title, value) + Copy clipboard dialog,
  reused by AccountList row context menu Get Email action.

Backend
- services/storage/aes_backup.rs: new module with WPF-compatible
  AES-128-CBC encrypt/decrypt + 12 unit tests covering round-trip,
  empty plaintext, wrong password, malformed base64, PKCS7 padding
  boundary, and 4 fixed WPF reference vectors for byte-for-byte
  cross-impl compatibility.
- commands/storage.rs: new backup_export / backup_restore IPC.
- commands/error.rs: BackupError -> CommandError mapping
  (storage.aes_backup_invalid_ciphertext / decrypt_failed /
  invalid_utf8).
- Cargo.toml: add md-5 / aes / cbc crates + cipher block-padding
  + alloc features.

Plumbing
- router/index.ts: register /accounts (AccountList) and
  /manage-account (ManageAccount); session-expired guard now also
  clears the account store via clearAccountSession callback so the
  next login does not flash stale service-account rows.
- stores/account.ts: add Users.dat CRUD wrappers + serviceAccounts
  cache + selectedSid + email/remainPoint/contract lazy lookups +
  drag-order persistence + clearSessionData.
- stores/auth.ts: integrate clearAccountSession side-channel.
- types/bindings.ts: regenerated with new commands + Account /
  AccountListResult / SessionInfo / BackupError DTOs.

i18n
- i18n/messages.ts: new frontend-only namespaces for accountList /
  manageAccount / addAccountDialog / changeAccountDialog /
  accRecovery scoped strings (zh-TW / zh-CN / en-US).
- locales/zh-CN.json: backfill missing WPF-derived keys.

Tests (vitest 408 -> 413 passing; cargo lib 647 incl. 12 aes_backup)
- tests/unit/pages/AccountList.spec.ts: 30 cases covering all
  states + every D-step branch (D5 OTP decision tree, D7 drag
  persistence, D10.5 GetEmail/Tools split, D11 Gash balance).
- tests/unit/pages/ManageAccount.spec.ts: 15 cases for CRUD +
  import/export + AccRecovery integration.
- tests/unit/windows/*.spec.ts: 8 new dialog specs.
- existing login + router + auth + account specs updated for new
  routes and clearAccountSession integration.

Mockups
- mockups/AccountList.html / IdPassForm.html / QrForm.html /
  Settings.html: design references used during the WPF-vs-mockup
  parity reviews documented in Todo.md.
Mark P12.2 D1-D11 sub-step trees complete in Todo.md and document the
WPF-vs-mockup parity decisions taken during each D-step (notably the
strict WPF parity overrides for D9 manage-account scope and D10 dialog
designs, plus the D10.5 Tools button / GetEmail context-menu split that
realigned with WPF call-site semantics).
Implements the third milestone of the Tauri/Vue rewrite (P12.3 - game
launch + 3 windows), at strict WPF parity:

Backend (Rust)

- services/beanfun/games.rs: INI parser + ServiceList parser +
  list_games orchestrator with unit tests
- commands/game.rs: list_games Tauri command
- commands/account.rs: get_service_contract + 5 unconnected_game_*
  commands (init_add_account_payload / add_account_check /
  add_account_check_nickname / add_account / change_password) with
  serde-tagged AddAccountOutcome and ChangePasswordOutcome enums
- services/beanfun/account.rs: set_active_service command mutates
  AppState.auth.session.{service_code, service_region} so subsequent
  session-gated calls follow the user's selected game (mirrors WPF
  MainWindow.service_code/region field mutation, which has no IPC)

Frontend (Vue)

- stores/game.ts: Pinia game catalogue store (services + ini map +
  selectedGameCode), gameCodeOf + imageUrl helpers,
  UNCONNECTED_GAME_CODES set, isUnconnectedGame computed
- windows/GameList.vue: game picker dialog with region-aware banners,
  emits select(serviceCode, serviceRegion)
- windows/UnconnectedGame_AddAccount.vue: WPF parity flow (id +
  password + confirm password + nickname + verify code), inline
  lblErrorMessage, emits created
- windows/UnconnectedGame_ChangePassword.vue: WPF parity email-only
  reset flow with MsgChangePassword token unescape via
  unescapeWpfCRLF helper, emits verify-code-sent
- pages/AccountList.vue D8 integration:
  - mount-time setupGameOnMount: loadGames -> restore loginGame from
    Config.xml -> selectActiveGame OR auto-open GameList picker
  - selectActiveGame pipeline: persist loginGame -> setActiveService
    IPC (gated, idempotent) -> clear stale account selection -> reload
  - game info bar with real name + region-aware image + Change Game
  - Tools button conditional visibility (TOOLS_GAME_CODES set)
  - Start Game pipeline: resolveGamePath -> wide-char warn ->
    process check -> mode resolve -> launchGame, with OTP+launch
    chain branch when login_action_type=1 and tradLogin=false
  - Add Service Account branches on isUnconnectedGame
  - per-row Change Password dropdown item (unconnected games only)
  - autoPaste className from selectedIni.win_class_name

i18n + bindings

- src/i18n/messages.ts: gamePlaceholder + gamePathPickerPending +
  toolsButton + changeGame + dragHandle + moreActions for all three
  locales; audit confirms all WPF keys present in zh-TW / zh-CN /
  en-US locale jsons
- src/types/bindings.ts: regenerated via cargo run --example
  export_bindings (adds setActiveService + listGames + 5
  unconnectedGame_* + getServiceContract bindings)

Tests

- AccountList.spec.ts: 15 new cases covering D8c-D8h (mount-time game
  setup, info bar render, Tools visibility, Start Game pipeline
  branches, Add Account branching, per-row Change Password); 2
  existing tests updated to seed an active game
- stores/game.spec.ts + windows/GameList.spec.ts +
  windows/UnconnectedGame_AddAccount.spec.ts +
  windows/UnconnectedGame_ChangePassword.spec.ts
- backend unit tests for set_active_service +
  AddAccountOutcome / ChangePasswordOutcome serde shape +
  parse_service_ini / parse_service_list

Quality gates: cargo fmt clean, cargo clippy --all-targets
--all-features -- -D warnings 0 warning, cargo test all green,
npm run typecheck / lint / format:check / test (476 passed) all
green.
Mark P12.3 D0-D12 (incl. D8a-D8i sub-step tree) complete in Todo.md
and record the feat commit hash (a04774c) on D12. Documents the
strict-WPF-parity decisions taken during each D-step (notably D8a
adding the set_active_service backend command to mirror WPF's
mutable MainWindow.service_code/region field, D8c's mount-time
single-fetch pipeline replacing the redundant loadList call, D8f's
OTP+launch chain branching for tradLogin=false / login_action_type=1,
and D7's email-only password reset that overrides the mockup's
3-field design to preserve WPF behavior). Also records the D9 i18n
audit result (zero missing keys across zh-TW / zh-CN / en-US) and
the D10 quality-gate fixes (3 needless_borrows_for_generic_args
clippy warnings in commands/account.rs test asserts + 2 no-unused-vars
eslint errors in AccountList.spec.ts imports).

Follows the P10.3 D9 / P11 D14 / P12.2 D12 convention of committing
the feature first (without the hash it would produce) and doing the
Todo backfill as a separate chore commit to avoid the amend-and-rehash
cycle from P10.2 D15.
- pages/Settings.vue: WPF Settings.xaml 1:1 — App section
  (ManageAccount / UpdateChannel / Language / ThemeColor free-form
  hex + el-color-picker / LoginMode TW-only / 4 boolean checkboxes
  with DisableHardwareAcceleration restart alert) + Game section
  (GamePath via @tauri-apps/plugin-dialog open() / TraditionalLogin
  / KillPatcher / SkipPlayWindow / Tools stub) + Back via
  router.back() with history-length fallback
- pages/About.vue: WPF About.xaml 1:1 — app icon (assets/icon.png
  copied from src-tauri/icons), version via commands.version,
  CheckUpdate via commands.checkUpdate + ElMessageBox.confirm,
  Email/Github via commands.openUrl, AboutText mini-markup
  flattened to plain text (P13 may add full parser)
- windows/WebBrowser.vue: minimal skeleton — iframe for non-cookie
  hosts, immediate commands.openUrl pop-out + toast for
  tw.beanfun.com / hk.beanfun.com (full WebviewWindow + cookie
  injection deferred to P13 once P12.5 KartTools/MapleTools have
  a real consumer)
- AccountList.vue: top-bar Settings + About icon buttons mirroring
  WPF MainWindow titlebar
- router: add /settings + /about routes (public, no requiresAuth
  — WPF allowed entry from login page too)
- stores/ui.ts: add 5 boolean Config keys
  (autoStartGame / askUpdate / tradLogin / autoKillPatcher /
  skipPlayWnd) + loginMethod (0/1) + correct UpdateChannel literal
  from 'Development' to 'Beta' to match backend Channel enum and
  WPF Config.xml schema
- i18n/messages.ts: add settings.* (8 leaf) + webBrowser.* (4 leaf)
  frontend-only keys across zh-TW / zh-CN / en-US
- Mark P12.4 D0–D11 complete in Todo.md with WPF parity / design
  decision / quality-gate fix notes (D12 is this commit pair)
- Apply pre-existing cargo fmt drift in commands/account.rs left
  over from P12.3 so `cargo fmt --all -- --check` is clean again
Adds the four MapleStory/KartRider tool dialogs invoked from
the Tools toolbar button (mirrors WPF `AccountList.xaml.cs`
`btn_Tools_Click`):

- `windows/MapleTools.vue` — Recycling / PlayerReport /
  VideoReport / EquipCalculator / CoreCalculator
- `windows/KartTools.vue` — 6 guild hyperlinks
- `windows/CoreCalculator.vue` — V-Matrix perfect-core search
- `windows/EquipCalculator.vue` — equipment star-force / scroll
  stat calculator
- `windows/ToolsDialogStack.vue` — wrapper hosting the five
  sibling dialogs (incl. WebBrowser) and exposing
  `openForGame(gameCode)` for AccountList / Settings

Backend `clean_maple_game_cache` Tauri command + pure FS
service powers the Recycling button (`services/maple_cache/`
+ `commands/maple_cache.rs`); 7 typed `MapleCache*`
`CommandError` variants surface partial-failure detail to the
frontend toast (observability-over-WPF-silent-swallow).

Pure algorithms split out for unit testability:
- `services/coreCalculator.ts` — combinatorial search ported
  verbatim from WPF `CoreCalculator.xaml.cs::btn_Calculator_Click`
- `services/equipCalculator.ts` — `SCROLLS` table +
  `getStarForceStats` + `calcStat`, ported verbatim from WPF
  `EquipCalculator.xaml.cs` (data quirks preserved with spec
  pins to prevent good-meaning cleanup)

Shared `constants/tools.ts` (`MAPLE_TOOLS_CODES` /
`KART_TOOLS_CODE` / `TOOLS_GAME_CODES`) drives both routing
and the `Settings.vue` `v-if` Tools-button visibility gate
(WPF `MainWindow.xaml.cs` L621 / L630-633 parity that the
SPA Settings page was previously missing).

i18n: per-locale fallback chain `zh-CN -> zh-TW -> en-US`
(`i18n/index.ts::createAppI18n.fallbackLocale`) mirrors WPF
`Helper/I18n.cs::LoadLanguage`'s `MergedDictionaries` two-layer
culture-parent stack (`zh-Hans -> zh`). `Beanfun/Lang/zh-Hans.xaml`
is missing ~30 keys (`Cancel`, `Yes`, `No`, `Tools`,
`Weapon`, `ScrollBlack`, `PerfectCoreNeedSkills`, ...) that
the WPF runtime resolves from `zh.xaml` at runtime — without
this fallback chain, Simplified Chinese SPA users would have
seen English fallback for every WPF-missing key, including
the `Tools` button label and the new EquipCalculator /
CoreCalculator UI strings. New `index.spec.ts` case pins the
parity contract by exercising `Cancel` (zh-Hans-missing,
zh-TW = "取消", en-US = "Cancel").

Locale source sync: `Beanfun/Lang/zh-Hans.xaml` `ToolBox`
backfilled (was missing vs zh.xaml / en.xaml);
`src/locales/zh-CN.json` regenerated from the updated XAML
via `scripts/convert-lang.mjs` (the regen drops `Cancel` /
`IAgree` / `Normal` to match the WPF source — the new
fallback chain ensures runtime parity by resolving those
from zh-TW).

3 new frontend-only i18n keys: `mapleTools.subtitle` /
`mapleTools.recyclingErrors` / `kartTools.subtitle` (synced
across all three locales).

Quality gates: vue-tsc 0, eslint 0, prettier 0, vitest 553
passed (38 files), cargo fmt 0, cargo clippy 0, cargo test
698 unit + integration suites all green.
…auncher composable

P12 acceptance Phase A surfaced 4 missing WPF buttons on the login forms:
RegisterAccount / ForgotPassword / GameStart on IdPassForm, and GameStart
on QrForm. WPF gates the LoginPage GameStart on MainWindow instance state
(service_code / game_exe / dir_value_name / dir_reg) which survives logout
because it lives on the singleton window. Pinia clearGameData wipes the
SPA equivalent on logout, so we mirror the WPF lifetime by parking a JSON
snapshot of the active GameIniEntry under a new lastSelectedIni config
key (paired with the existing loginGame key for the gameCode).

- constants/login.ts: LOGIN_EXTERNAL_URLS table (region x kind) ports the
  4 signup / forgot_pwd URLs from id-pass_form.xaml.cs L42-50 + L57-64.
- composables/useGameLauncher.ts: extracts the 5-step WPF runGame chain
  (resolveStartMode / pathHasWideChar / resolveGamePath /
  checkAndKillRunningGameProcesses / runGame) out of AccountList so both
  LoginForm components can re-use it (SRP/DRY). Calls restoreLastSelected
  internally so consumers do not have to know about the persistence
  shape. resolveGamePath hard selectedGame guard is downgraded to a
  graceful pending-Settings fallback so LoginPage launches survive an
  empty services list.
- stores/game.ts: restoreLastSelected(configStore) action patches
  selectedGameCode + ini map from the persisted snapshot without touching
  services (LoginPage has no consumer for the catalogue and the fetch
  requires an authenticated session). clearGameData docblock updated to
  document that loginGame / lastSelectedIni intentionally outlive logout.
- pages/AccountList.vue: refactored to delegate to useGameLauncher
  (5 helper functions deleted, behaviour 0 change) and now persists the
  active GameIniEntry to lastSelectedIni alongside the existing loginGame
  write inside selectActiveGame.
- pages/IdPassForm.vue: 3 new buttons (RegisterAccount / ForgotPassword
  inline next to the checkboxes; GameStart paired with Login in the
  primary action grid) + a self-mounted WebBrowser dialog for the two
  external URLs (the dialog auto-falls-back to the system browser for
  beanfun.com URLs so existing Beanfun cookies apply).
- pages/QrForm.vue: GameStart button below the Back / Refresh row,
  delegating to the same launcher composable.

Tests: useGameLauncher.spec.ts new file with 12 cases covering the full
launch chain + restoration + every mode / process / wide-char branch;
IdPassForm.spec.ts +5 cases for the 3 new buttons (TW/HK URL routing +
launcher delegation); QrForm.spec.ts +1 case for GameStart delegation;
AccountList.spec.ts +1 D5 case asserting selectActiveGame writes both
loginGame and lastSelectedIni JSON in the same selection.

Quality gates: vue-tsc / eslint / prettier / vitest (572 pass) all green;
cargo fmt / clippy green (no Rust changes in this followup).
…2.4-followup-B)

Restore WPF parity for `new WebBrowser(uri).Show()`: a fresh
`tauri::WebviewWindow` per click with the logged-in BeanfunClient
session cookies pre-seeded, so RegisterAccount / ForgotPassword on
LoginPage and the Maple/Kart `open-web-browser` events render the
authenticated `tw.beanfun.com` / `bfweb.hk.beanfun.com` pages
embedded instead of degrading to the system browser.

- backend: new `commands/web_browser::open_in_app_browser` with a
  host allowlist (tw/hk/bfweb), `about:blank -> seed -> navigate`
  pattern reusing `seed_webview_cookies_from_client`, async build
  for the Windows WebView2 message-pump deadlock guard
- frontend: new `useInAppBrowser` composable as the single dispatch
  point; `system.invalid_url` from the backend triggers a one-toast
  fallback to `commands.openUrl` (system browser); IdPassForm and
  ToolsDialogStack delegate to the composable
- cleanup: delete `windows/WebBrowser.vue` (placeholder dialog) +
  drop `webBrowser.*` i18n keys; replace with `inAppBrowser.*`
- tests: 4 cases for the composable, 13 Rust unit cases for the
  URL allowlist + label generator, refit IdPassForm /
  ToolsDialogStack specs to assert the spy instead of the dialog
… (P12.4-followup-B-fix)

F1  backend allowlist *.beanfun.com suffix match
F2  remove VideoReport button (target URL 404)
F3  mirror WPF game image URL passthrough + unified fallback host
F6  ManageAccount back button
F7  WebviewWindow black-flash fix (hidden build + on_page_load show)
F8  customer service button wire-up
F9  member center button wire-up (web_token stays in Rust)

A   About page: dual author credit, GitHub link, dual email contacts
B   System tray: minimize-to-tray with TrayIconBuilder + config toggle
C   Login page: outline icon, heading text, region TW/HK labels, v6.0.0
D1  Windows manifest highestAvailable for release builds (PostMessageW UIPI)
D3  Updater: null_as_empty deserializer for GitHub release JSON
D4  Updater: semver Path C comparison for pure X.Y.Z local versions
D5  Default window size 720x720
- Remove legacy WPF project (Beanfun/, Beanfun.sln, .config/)
- Flatten beanfun-next/ to repo root
- Rename crate/package from beanfun-next to beanfun (lib: beanfun_lib)
- Update productName/title/identifier in tauri.conf.json
- Fix include_bytes! paths for LocaleRemulator after relocation
- Replace all beanfun-next references in code, comments, and tests
- Add dependabot.yml (npm, cargo, github-actions)
- Add Tauri build-and-release.yml (standalone exe, no installer)
- Rename CI workflow to "Lint, Format & Test" with descriptive job names
- Remove format-check.yml (dotnet) and old build-and-release.yml (dotnet)
- Delete mockups/ (HTML design mockups no longer needed)
- Update README to match original Beanfun format
- Update .gitignore for new structure
- Set bundle targets to [] (bare exe only via --no-bundle)
@YCC3741 YCC3741 changed the title feat: Tauri v2 rewrite - replace WPF with Rust/Vue 3 feat/refactor: Breaking Changes - Replace WPF with Rust/Vue 3 Apr 20, 2026
…rite

Resolve modify/delete conflicts: WPF files deleted in HEAD (Tauri
rewrite replaces them entirely), accept deletions for all 10 conflicted
files. Auto-merged Lang/zh-Hans.xaml (new QR Code i18n strings from code).
@YCC3741 YCC3741 requested a review from pungin April 20, 2026 08:09
@YCC3741 YCC3741 self-assigned this Apr 20, 2026
YCC3741 added 6 commits April 20, 2026 16:16
- Rename commitlint.config.js to .mjs (wagoid/commitlint-github-action@v6 requires ESM)
- Fix unused_mut clippy warning in build.rs by using variable shadowing
- Auto-format workflow YAML files with Prettier
- Remove macOS matrix from CI (Windows-only application)
- Clean up residual beanfun-next/ directory files
- Add .gitattributes with `* text=auto eol=lf` to prevent CRLF
  issues on Windows CI runners (rustfmt, prettier)
- Set commitDepth: 1 in commitlint workflow to only check the
  latest commit (avoids flagging historical commits in the PR)
- Renormalize existing files with CRLF line endings
- Delete commitlint.yml workflow and commitlint.config.mjs
- Add project architecture tree to README
- Remove commitlint references from contributing section
- Use regex replace + WriteAllText instead of ConvertTo-Json +
  Set-Content to preserve file formatting and UTF-8 encoding
- Fix git log range arg passing in PowerShell (split into array)
- Redact URL query params (pSKey) from all GamePass tracing logs (C1/S1)
- Remove login_token from DeviceRegistrationRequired IPC details (S2)
- Add 5 MiB response body limit to fetch_releases_at (S6)
- Stop logging full backend error details to browser console (S12)
- Narrow tokio features from "full" to specific set (S24)
- Untrack Todo.md and add to .gitignore
@YCC3741 YCC3741 merged commit e3661d4 into code Apr 20, 2026
2 checks passed
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.

feat/refactor: Breaking Changes - Replace WPF with Rust/Vue 3

1 participant