From 13a60080df339f341c5800cb9148c8e806c0d327 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 04:09:57 +0800 Subject: [PATCH 1/9] feat: account rename with local/server option, display overrides, drag order backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix rename API: TW uses tw.beanfun.com, HK has no server API - Edit modal: TW shows 'sync to server' checkbox (default off), HK shows 'local only' note - DisplayOverrides struct with names + order, DPAPI encrypted - get_game_accounts and refresh_accounts apply name overrides and custom sort order from display_overrides.dat - set_display_override, set_account_order, get_display_overrides commands added - Account cards show game account ID instead of #SN - i18n keys for edit_sync, edit_local_only (EN/繁中/简中) --- src-tauri/Cargo.lock | 2 +- src-tauri/src/commands/account.rs | 101 +++++++++++++++++-- src-tauri/src/lib.rs | 15 +++ src-tauri/src/models/app_state.rs | 4 + src-tauri/src/services/account_storage.rs | 88 ++++++++++++++++ src-tauri/src/services/beanfun_service.rs | 9 +- src/features/launcher/AccountContextMenu.tsx | 67 +++++++++--- src/lib/tauri.ts | 4 + src/locales/en-US.json | 2 + src/locales/zh-CN.json | 2 + src/locales/zh-TW.json | 2 + 11 files changed, 272 insertions(+), 24 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bea19d4..af35b61 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "maplelink" -version = "0.3.0" +version = "0.3.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 59af212..a94723d 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -89,7 +89,23 @@ pub async fn get_game_accounts( } let accounts = ss.game_accounts.read().await; - let dtos = accounts.iter().map(GameAccountDto::from).collect(); + let overrides = state.display_overrides.read().await; + let mut dtos: Vec = accounts + .iter() + .map(|a| { + let mut dto = GameAccountDto::from(a); + if let Some(name) = overrides.names.get(&dto.id) { + dto.display_name = name.clone(); + } + dto + }) + .collect(); + // Apply custom sort order + if !overrides.order.is_empty() { + dtos.sort_by_key(|d| { + overrides.order.iter().position(|id| id == &d.id).unwrap_or(usize::MAX) + }); + } Ok(dtos) } @@ -152,7 +168,22 @@ pub async fn refresh_accounts( .await .map_err(login_err_to_dto)?; - let dtos: Vec = accounts.iter().map(GameAccountDto::from).collect(); + let overrides = state.display_overrides.read().await; + let mut dtos: Vec = accounts + .iter() + .map(|a| { + let mut dto = GameAccountDto::from(a); + if let Some(name) = overrides.names.get(&dto.id) { + dto.display_name = name.clone(); + } + dto + }) + .collect(); + if !overrides.order.is_empty() { + dtos.sort_by_key(|d| { + overrides.order.iter().position(|id| id == &d.id).unwrap_or(usize::MAX) + }); + } drop(session_guard); *ss.game_accounts.write().await = accounts; @@ -295,12 +326,18 @@ pub async fn change_account_display_name( let session_guard = ss.session.read().await; let _session = auth::require_valid_session(&session_guard).map_err(to_dto)?; + let region = state.config.read().await.region.clone(); let game_code = format!("{}_{}", DEFAULT_SERVICE_CODE, DEFAULT_SERVICE_REGION); - let success = - beanfun_service::change_display_name(&ss.http_client, &game_code, &account_id, &new_name) - .await - .map_err(login_err_to_dto)?; + let success = beanfun_service::change_display_name( + &ss.http_client, + ®ion, + &game_code, + &account_id, + &new_name, + ) + .await + .map_err(login_err_to_dto)?; if success { tracing::info!(account_id = %account_id, new_name = %new_name, "display name changed"); @@ -311,6 +348,58 @@ pub async fn change_account_display_name( Ok(success) } +/// Save a local display name override (persisted to display_overrides.json). +/// +/// Used when the user renames an account locally (HK region, or TW without sync). +#[tauri::command] +pub async fn set_display_override( + account_id: String, + display_name: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let mut overrides = state.display_overrides.write().await; + if display_name.is_empty() { + overrides.names.remove(&account_id); + } else { + overrides.names.insert(account_id.clone(), display_name.clone()); + } + if let Err(e) = + crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides) + .await + { + tracing::warn!("failed to save display overrides: {e}"); + } + tracing::info!("display override saved: {account_id} = {display_name}"); + Ok(()) +} + +/// Save custom account sort order (persisted to display_overrides). +#[tauri::command] +pub async fn set_account_order( + order: Vec, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let mut overrides = state.display_overrides.write().await; + overrides.order = order; + if let Err(e) = + crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides) + .await + { + tracing::warn!("failed to save display overrides: {e}"); + } + tracing::info!("account order saved ({} entries)", overrides.order.len()); + Ok(()) +} + +/// Get all local display name overrides. +#[tauri::command] +pub async fn get_display_overrides( + state: State<'_, AppState>, +) -> Result, ErrorDto> { + let overrides = state.display_overrides.read().await; + Ok(overrides.names.clone()) +} + /// Retrieve the authenticated user's email address (context menu action). /// /// Delegates to [`beanfun_service::get_email`]. Returns an empty string diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87f2b25..8a8de63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -136,6 +136,9 @@ pub fn run() { commands::account::get_remain_point, commands::account::auto_paste_otp, commands::account::change_account_display_name, + commands::account::set_display_override, + commands::account::set_account_order, + commands::account::get_display_overrides, commands::account::get_auth_email, commands::launcher::launch_game, commands::launcher::launch_game_direct, @@ -229,6 +232,16 @@ pub fn run() { accounts_path.display() ); + let overrides_path = config_dir.join("display_overrides.json"); + let display_overrides = tauri::async_runtime::block_on(async { + account_storage::load_display_overrides(&overrides_path).await + }); + tracing::info!( + "loaded {} display overrides from {}", + display_overrides.names.len(), + overrides_path.display() + ); + // 4. Initialise AppState with loaded config. let auto_update_enabled = config.auto_update; let update_channel = config.update_channel.clone(); @@ -244,6 +257,8 @@ pub fn run() { config_path, saved_accounts: tokio::sync::RwLock::new(saved_accounts), accounts_path, + overrides_path, + display_overrides: tokio::sync::RwLock::new(display_overrides), http_client, }; diff --git a/src-tauri/src/models/app_state.rs b/src-tauri/src/models/app_state.rs index 44b28d7..4a4d8ca 100644 --- a/src-tauri/src/models/app_state.rs +++ b/src-tauri/src/models/app_state.rs @@ -25,6 +25,10 @@ pub struct AppState { pub saved_accounts: RwLock>, /// Path to the `accounts.json` file on disk. pub accounts_path: PathBuf, + /// Path to `display_overrides.dat` for local account customizations. + pub overrides_path: PathBuf, + /// Local account customizations (display names + sort order). + pub display_overrides: RwLock, /// A shared HTTP client for non-session operations (update checks, etc.) pub http_client: reqwest::Client, } diff --git a/src-tauri/src/services/account_storage.rs b/src-tauri/src/services/account_storage.rs index 919b7fc..9ebbbad 100644 --- a/src-tauri/src/services/account_storage.rs +++ b/src-tauri/src/services/account_storage.rs @@ -187,3 +187,91 @@ pub fn remove_account(accounts: &mut Vec, region: &str, account: & accounts.retain(|a| !(a.region == region && a.account == account)); accounts.len() < before } + +// --------------------------------------------------------------------------- +// Display name overrides (local-only renames, DPAPI encrypted) +// --------------------------------------------------------------------------- + +/// Local account customizations: display name overrides + sort order. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct DisplayOverrides { + /// account_id → custom display name + #[serde(default)] + pub names: std::collections::HashMap, + /// Custom account sort order (list of account IDs) + #[serde(default)] + pub order: Vec, +} + +/// Load display overrides from DPAPI-encrypted .dat + .key files. +pub async fn load_display_overrides(path: &Path) -> DisplayOverrides { + let dat_path = path.with_extension("dat"); + let key_path = path.with_extension("key"); + + if dat_path.exists() && key_path.exists() { + let ciphertext = match tokio::fs::read(&dat_path).await { + Ok(d) => d, + Err(_) => return DisplayOverrides::default(), + }; + let entropy = match tokio::fs::read(&key_path).await { + Ok(k) => k, + Err(_) => return DisplayOverrides::default(), + }; + return match dpapi::unprotect(&ciphertext, &entropy) { + Ok(plaintext) => { + let json = String::from_utf8_lossy(&plaintext); + serde_json::from_str(&json).unwrap_or_default() + } + Err(e) => { + tracing::warn!("failed to decrypt display overrides: {e}"); + DisplayOverrides::default() + } + }; + } + + // Legacy plaintext fallback + auto-migrate + if path.exists() { + if let Ok(json) = tokio::fs::read_to_string(path).await { + if let Ok(o) = serde_json::from_str::(&json) { + let _ = save_display_overrides(path, &o).await; + let _ = tokio::fs::remove_file(path).await; + return o; + } + if let Ok(map) = serde_json::from_str::>(&json) { + let o = DisplayOverrides { names: map, order: Vec::new() }; + let _ = save_display_overrides(path, &o).await; + let _ = tokio::fs::remove_file(path).await; + return o; + } + } + } + + DisplayOverrides::default() +} + +/// Save display overrides encrypted with DPAPI. +pub async fn save_display_overrides( + path: &Path, + overrides: &DisplayOverrides, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("failed to create dir: {e}"))?; + } + let json = serde_json::to_string(overrides) + .map_err(|e| format!("failed to serialize overrides: {e}"))?; + + let (ciphertext, entropy) = + dpapi::protect(json.as_bytes()).map_err(|e| format!("DPAPI encrypt failed: {e}"))?; + + let dat_path = path.with_extension("dat"); + let key_path = path.with_extension("key"); + + tokio::fs::write(&dat_path, &ciphertext) + .await + .map_err(|e| format!("failed to write overrides.dat: {e}"))?; + tokio::fs::write(&key_path, &entropy) + .await + .map_err(|e| format!("failed to write overrides.key: {e}")) +} \ No newline at end of file diff --git a/src-tauri/src/services/beanfun_service.rs b/src-tauri/src/services/beanfun_service.rs index 40c4ea7..9231373 100644 --- a/src-tauri/src/services/beanfun_service.rs +++ b/src-tauri/src/services/beanfun_service.rs @@ -304,11 +304,18 @@ pub async fn get_remain_point(client: &Client, region: &Region) -> Result Result { - let url = "https://bfweb.hk.beanfun.com/generic_handlers/gamezone.ashx"; + // Only TW region has a server-side rename API + if *region != Region::TW { + // HK: no API, return false so caller saves locally + return Ok(false); + } + + let url = "https://tw.beanfun.com/generic_handlers/gamezone.ashx"; let form = [ ("strFunction", "ChangeServiceAccountDisplayName"), diff --git a/src/features/launcher/AccountContextMenu.tsx b/src/features/launcher/AccountContextMenu.tsx index 2b200c0..c96afab 100644 --- a/src/features/launcher/AccountContextMenu.tsx +++ b/src/features/launcher/AccountContextMenu.tsx @@ -3,7 +3,9 @@ import { useTranslation } from "../../lib/i18n"; import { open } from "@tauri-apps/plugin-shell"; import { commands } from "../../lib/tauri"; import { useRefreshAccounts } from "../../lib/hooks/use-accounts"; +import { useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "../../lib/stores/auth-store"; +import { useConfigStore } from "../../lib/stores/config-store"; import { Modal } from "../shared/Modal"; import type { GameAccountDto } from "../../lib/types"; @@ -127,15 +129,19 @@ function AccountDetailView({ function EditAccountView({ account, t, + region, onSave, onCancel, }: { account: GameAccountDto; t: (key: string) => string; - onSave: (name: string) => void; + region: string; + onSave: (name: string, syncToServer: boolean) => void; onCancel: () => void; }) { const [name, setName] = useState(account.displayName); + const [syncToServer, setSyncToServer] = useState(false); + const isTW = region === "TW"; return (
@@ -147,10 +153,23 @@ function EditAccountView({ value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && name) onSave(name); + if (e.key === "Enter" && name) onSave(name, syncToServer); }} className="rounded-lg border border-[var(--tb-border)] bg-[var(--bg)] px-3 py-2 text-xs text-[var(--text)] transition-colors outline-none focus:border-accent" /> + {isTW ? ( + + ) : ( + {t("launcher.context.edit_local_only")} + )}
); } @@ -200,30 +291,54 @@ function ListItem({ account, isSelected, isCopied, + isDragging, + isBumped, + idx, onSelect, onContextMenu, onCopy, + onGripDown, copyTitle, }: { account: GameAccountDto; isSelected: boolean; isCopied: boolean; + isDragging: boolean; + isBumped: boolean; + idx: number; onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void; onCopy: (e: React.MouseEvent) => void; + onGripDown: (e: React.MouseEvent) => void; copyTitle: string; }) { const initial = account.displayName.charAt(0).toUpperCase(); return ( From b2650702ff42c81140fab59a9931842db392a324 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 05:03:12 +0800 Subject: [PATCH 3/9] feat: add dragBump keyframe animation for drag reorder feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS @keyframes dragBump: scale 1 → 1.04 → 1 over 200ms. Used by AccountGrid to animate displaced items during drag. --- src/styles/globals.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/styles/globals.css b/src/styles/globals.css index 11ad221..42f2b07 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -107,6 +107,18 @@ z-index: 1; } + @keyframes dragBump { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.04); + } + 100% { + transform: scale(1); + } + } + @keyframes hbeat { 0% { transform: scale(1); From 98d2026178918b86eb4d164a908b36b366657db5 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 05:03:37 +0800 Subject: [PATCH 4/9] feat: DisplayOverrides struct with names + order, apply sort in get/refresh - DisplayOverrides now has 'names' (HashMap) and 'order' (Vec) - get_game_accounts and refresh_accounts apply custom sort order - set_account_order command saves order to encrypted storage - Legacy HashMap format auto-migrated on load --- src-tauri/src/commands/account.rs | 16 +++++++++++++--- src-tauri/src/services/account_storage.rs | 11 ++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index a94723d..19b1bf9 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -103,7 +103,11 @@ pub async fn get_game_accounts( // Apply custom sort order if !overrides.order.is_empty() { dtos.sort_by_key(|d| { - overrides.order.iter().position(|id| id == &d.id).unwrap_or(usize::MAX) + overrides + .order + .iter() + .position(|id| id == &d.id) + .unwrap_or(usize::MAX) }); } Ok(dtos) @@ -181,7 +185,11 @@ pub async fn refresh_accounts( .collect(); if !overrides.order.is_empty() { dtos.sort_by_key(|d| { - overrides.order.iter().position(|id| id == &d.id).unwrap_or(usize::MAX) + overrides + .order + .iter() + .position(|id| id == &d.id) + .unwrap_or(usize::MAX) }); } @@ -361,7 +369,9 @@ pub async fn set_display_override( if display_name.is_empty() { overrides.names.remove(&account_id); } else { - overrides.names.insert(account_id.clone(), display_name.clone()); + overrides + .names + .insert(account_id.clone(), display_name.clone()); } if let Err(e) = crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides) diff --git a/src-tauri/src/services/account_storage.rs b/src-tauri/src/services/account_storage.rs index 9ebbbad..4099aa9 100644 --- a/src-tauri/src/services/account_storage.rs +++ b/src-tauri/src/services/account_storage.rs @@ -237,8 +237,13 @@ pub async fn load_display_overrides(path: &Path) -> DisplayOverrides { let _ = tokio::fs::remove_file(path).await; return o; } - if let Ok(map) = serde_json::from_str::>(&json) { - let o = DisplayOverrides { names: map, order: Vec::new() }; + if let Ok(map) = + serde_json::from_str::>(&json) + { + let o = DisplayOverrides { + names: map, + order: Vec::new(), + }; let _ = save_display_overrides(path, &o).await; let _ = tokio::fs::remove_file(path).await; return o; @@ -274,4 +279,4 @@ pub async fn save_display_overrides( tokio::fs::write(&key_path, &entropy) .await .map_err(|e| format!("failed to write overrides.key: {e}")) -} \ No newline at end of file +} From ad0069c48c86637b9392773971a7e93d6b4cc75d Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 05:03:57 +0800 Subject: [PATCH 5/9] fix: add name/autoComplete attributes to all form inputs - Add name attribute to all inputs/selects missing id or name - Add autoComplete=off and data-form-type=other to text inputs to suppress WebView2 autofill suggestions - Fixes browser warning about form fields without id/name --- src/features/launcher/AccountContextMenu.tsx | 4 ++++ src/features/launcher/SessionTabs.tsx | 3 +++ src/features/login/NormalLoginForm.tsx | 1 + src/features/login/TotpForm.tsx | 4 ++++ src/features/login/VerifyForm.tsx | 2 ++ src/features/shared/UpdateDialog.tsx | 1 + 6 files changed, 15 insertions(+) diff --git a/src/features/launcher/AccountContextMenu.tsx b/src/features/launcher/AccountContextMenu.tsx index c96afab..a5cd3aa 100644 --- a/src/features/launcher/AccountContextMenu.tsx +++ b/src/features/launcher/AccountContextMenu.tsx @@ -150,6 +150,9 @@ function EditAccountView({ setName(e.target.value)} onKeyDown={(e) => { @@ -161,6 +164,7 @@ function EditAccountView({