From 8ce7e67832526d91d6336a4235e7b7498c2bb46b Mon Sep 17 00:00:00 2001 From: lshw54 Date: Tue, 21 Apr 2026 23:20:47 +0800 Subject: [PATCH 01/22] fix: remove window border and attempt DWM border color removal - Remove CSS body border (was 1px solid var(--border)) - Add Win32_Graphics_Dwm feature to windows-sys - Apply DWMWA_BORDER_COLOR = DWMWCP_NONE on every window focus event to suppress the Windows 11 accent border - Fix clippy unnecessary_cast for HWND pointer --- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 18 ++++++++++++++++++ src/styles/globals.css | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ecdcfe..ff33d69 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ winreg = "0.56" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Globalization", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security_Cryptography", "Win32_System_Diagnostics_ToolHelp", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 020cb4a..383e281 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -363,6 +363,24 @@ pub fn run() { }) // -- Window lifecycle ----------------------------------------------- .on_window_event(|window, event| { + // Remove Windows 11 DWM border on every focus gain. + // Must be re-applied because Windows can restore it. + #[cfg(target_os = "windows")] + if let tauri::WindowEvent::Focused(true) = event { + if let Ok(hwnd) = window.hwnd() { + unsafe { + const DWMWA_BORDER_COLOR: u32 = 34; + let color: u32 = 0xFFFFFFFE; // DWMWCP_NONE + let _ = windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute( + hwnd.0, + DWMWA_BORDER_COLOR, + &color as *const _ as *const _, + std::mem::size_of::() as u32, + ); + } + } + } + if let tauri::WindowEvent::Destroyed = event { let label = window.label().to_string(); let app_handle = window.app_handle().clone(); diff --git a/src/styles/globals.css b/src/styles/globals.css index fa7ecaa..11ad221 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -70,7 +70,7 @@ overflow: hidden; user-select: none; -webkit-user-select: none; - border: 1px solid var(--border); + border: none; border-radius: var(--radius); position: relative; transition: From 5c11a6b1d752bf857a4d9c1fa36ce3e7bd273f8e Mon Sep 17 00:00:00 2001 From: lshw54 Date: Tue, 21 Apr 2026 23:21:03 +0800 Subject: [PATCH 02/22] fix: QR enlarged view shows all controls and resizes correctly - Increase login-enlarged window size to 540x780 - Always show header (app icon + title) when enlarged - Always show status text, refresh button, and back button - Back button auto-resizes window to login size before navigating --- src-tauri/src/commands/system.rs | 2 +- src/features/login/QrLoginForm.tsx | 52 ++++++++++++++++-------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index f6d6c12..96c52cc 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -43,7 +43,7 @@ pub fn log_frontend_error(level: String, module: String, message: String) -> Res pub async fn resize_window(page: String, window: tauri::Window) -> Result<(), ErrorDto> { let (width, height): (f64, f64) = match page.as_str() { "login" => (350.0, 580.0), - "login-enlarged" => (500.0, 560.0), + "login-enlarged" => (540.0, 780.0), "main" => (760.0, 530.0), "toolbox" => (750.0, 490.0), _ => { diff --git a/src/features/login/QrLoginForm.tsx b/src/features/login/QrLoginForm.tsx index b8fc67b..76accfd 100644 --- a/src/features/login/QrLoginForm.tsx +++ b/src/features/login/QrLoginForm.tsx @@ -125,22 +125,22 @@ export function QrLoginForm({ onBack }: QrLoginFormProps) { return (
- {/* Header — hide when enlarged */} - {!enlarged && ( -
- MapleLink -
- {t("login.qr.title")} -
+ {/* Header */} +
+ MapleLink +
+ {t("login.qr.title")} +
+ {!enlarged && (
{t("login.qr.instruction")}
-
- )} + )} +
- {!enlarged && status === "expired" && ( + {status === "expired" && ( - )} +
); } From c5629c7c2c61ed7d8cca98670df6c2ea9dabbdc1 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Tue, 21 Apr 2026 23:21:22 +0800 Subject: [PATCH 03/22] feat: persistent update banner and cached update info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache available update info in update store (survives dialog dismiss) - Show update banner on all pages after dismissing update dialog - Banner auto-resizes window height with 50ms delay for smooth layout - Banner is dismissible with × button - Click banner text to re-open update dialog - AboutTab reads cached update info — no manual re-check needed - 'Update available' text in AboutTab is now clickable - Added app.update_banner i18n key (EN/繁中/简中) --- src/App.tsx | 60 ++++++++++++++++++++++++++++++- src/features/toolbox/AboutTab.tsx | 30 +++++++++++----- src/lib/stores/update-store.ts | 6 ++++ src/locales/en-US.json | 2 ++ src/locales/zh-CN.json | 2 ++ src/locales/zh-TW.json | 2 ++ 6 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ea03f4a..b948e3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { commands } from "./lib/tauri"; +import { useTranslation } from "./lib/i18n"; import { useUiStore } from "./lib/stores/ui-store"; import { useUpdateStore } from "./lib/stores/update-store"; import { useConfig } from "./lib/hooks/use-config"; @@ -77,8 +78,34 @@ function SplashScreen() { export function App() { useThemeEffect(); const configLoading = useInitialConfigSync(); + const { t } = useTranslation(); const ready = !configLoading; const [pendingUpdate, setPendingUpdate] = useState(null); + const [bannerDismissed, setBannerDismissed] = useState(false); + const availableUpdate = useUpdateStore((s) => s.availableUpdate); + // Show banner on all pages when update available and dialog dismissed + const showBanner = !pendingUpdate && availableUpdate && !bannerDismissed; + + // Adjust window height when update banner appears or disappears + const bannerHeight = 28; + useEffect(() => { + // Small delay to let the DOM render before measuring + const timer = setTimeout(async () => { + try { + const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + const size = await win.innerSize(); + const scaleFactor = await win.scaleFactor(); + const logicalW = size.width / scaleFactor; + const logicalH = size.height / scaleFactor; + const newH = showBanner ? logicalH + bannerHeight : logicalH - bannerHeight; + await win.setSize(new LogicalSize(logicalW, newH)); + } catch { + /* non-critical */ + } + }, 50); + return () => clearTimeout(timer); + }, [showBanner]); // Global listener for download progress events (works even when UpdateDialog is closed) useEffect(() => { @@ -102,6 +129,7 @@ export function App() { const unlisten = listen("update-available", (event) => { setPendingUpdate(event.payload); + useUpdateStore.getState().setAvailableUpdate(event.payload); }); // Small delay to ensure UI is rendered before showing update dialog @@ -109,7 +137,10 @@ export function App() { commands .checkUpdate() .then((info) => { - if (info) setPendingUpdate(info); + if (info) { + setPendingUpdate(info); + useUpdateStore.getState().setAvailableUpdate(info); + } }) .catch((e) => { commands.logFrontendError("warn", "App", `update check failed: ${e}`); @@ -127,6 +158,33 @@ export function App() { return (
+ {showBanner && ( +
+ + +
+ )}
diff --git a/src/features/toolbox/AboutTab.tsx b/src/features/toolbox/AboutTab.tsx index ed307ad..5816cce 100644 --- a/src/features/toolbox/AboutTab.tsx +++ b/src/features/toolbox/AboutTab.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "../../lib/i18n"; import { commands } from "../../lib/tauri"; +import { useUpdateStore } from "../../lib/stores/update-store"; import { UpdateDialog } from "../shared/UpdateDialog"; import type { UpdateInfoDto } from "../../lib/types"; @@ -10,6 +11,10 @@ export function AboutTab() { const [checking, setChecking] = useState(false); const [updateResult, setUpdateResult] = useState(undefined); const [showUpdateDialog, setShowUpdateDialog] = useState(false); + const cachedUpdate = useUpdateStore((s) => s.availableUpdate); + + // Show cached update from startup check without needing manual re-check + const effectiveResult = updateResult !== undefined ? updateResult : cachedUpdate; useEffect(() => { commands @@ -24,7 +29,10 @@ export function AboutTab() { try { const info = await commands.checkUpdate(); setUpdateResult(info); - if (info) setShowUpdateDialog(true); + if (info) { + useUpdateStore.getState().setAvailableUpdate(info); + setShowUpdateDialog(true); + } } catch { setUpdateResult(null); } finally { @@ -54,12 +62,16 @@ export function AboutTab() { > {checking ? t("toolbox.about.checking_update") : t("toolbox.about.check_update")} - {updateResult !== undefined && !showUpdateDialog && ( - - {updateResult - ? t("toolbox.about.update_available").replace("{{version}}", updateResult.version) - : t("toolbox.about.no_update")} - + {effectiveResult !== undefined && effectiveResult !== null && !showUpdateDialog && ( + + )} + {updateResult === null && ( + {t("toolbox.about.no_update")} )}
@@ -74,8 +86,8 @@ export function AboutTab() { {t("toolbox.about.license")}
- {showUpdateDialog && updateResult && ( - setShowUpdateDialog(false)} /> + {showUpdateDialog && effectiveResult && ( + setShowUpdateDialog(false)} /> )}
); diff --git a/src/lib/stores/update-store.ts b/src/lib/stores/update-store.ts index 92cf6b4..949d22d 100644 --- a/src/lib/stores/update-store.ts +++ b/src/lib/stores/update-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { UpdateInfoDto } from "../types"; export type DownloadStatus = "idle" | "downloading" | "done" | "error"; @@ -12,7 +13,10 @@ export interface UpdateDownloadState { version: string; downloadUrl: string; isPrerelease: boolean; + /** Cached update info from startup check — survives dialog dismiss */ + availableUpdate: UpdateInfoDto | null; + setAvailableUpdate: (info: UpdateInfoDto | null) => void; startDownload: ( version: string, downloadUrl: string, @@ -35,7 +39,9 @@ export const useUpdateStore = create((set) => ({ version: "", downloadUrl: "", isPrerelease: false, + availableUpdate: null, + setAvailableUpdate: (info) => set({ availableUpdate: info }), startDownload: (version, downloadUrl, isPrerelease, method) => set({ status: "downloading", diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 012f8f9..d64a042 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1,6 +1,7 @@ { "app.name": "MapleLink", "app.loading": "Loading...", + "app.update_banner": "v{{version}} available — click to update", "login.title": "Sign In", "login.tabs.normal": "Login", @@ -34,6 +35,7 @@ "login.totp.title": "Two-Step Verification", "login.totp.code": "Verification Code", "login.totp.submit": "Verify", + "login.totp.auto_submit": "Auto-verify when complete", "login.totp.verifying": "Verifying...", "login.verify.title": "Security Verification", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 974247f..fd2c3fc 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1,6 +1,7 @@ { "app.name": "MapleLink", "app.loading": "加载中...", + "app.update_banner": "v{{version}} 可用 — 点击更新", "login.title": "登录", "login.tabs.normal": "账号登录", @@ -34,6 +35,7 @@ "login.totp.title": "两步骤验证", "login.totp.code": "验证码", "login.totp.submit": "验证", + "login.totp.auto_submit": "输入完成自动验证", "login.totp.verifying": "验证中...", "login.verify.title": "安全验证", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index e29de75..342e95e 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -1,6 +1,7 @@ { "app.name": "MapleLink", "app.loading": "載入中...", + "app.update_banner": "v{{version}} 可用 — 點擊更新", "login.title": "登入", "login.tabs.normal": "帳號登入", @@ -34,6 +35,7 @@ "login.totp.title": "兩步驟驗證", "login.totp.code": "驗證碼", "login.totp.submit": "驗證", + "login.totp.auto_submit": "輸入完成自動驗證", "login.totp.verifying": "驗證中...", "login.verify.title": "安全驗證", From b253a315ff7b427a7517a1adaa728a74644e6326 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Tue, 21 Apr 2026 23:21:44 +0800 Subject: [PATCH 04/22] feat: TOTP auto-submit on 6-digit completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'auto-verify when complete' checkbox (default: on) - When enabled, automatically submits after typing or pasting all 6 digits — no need to click Verify button - On error, resets digits and refocuses first input - Added login.totp.auto_submit i18n key (EN/繁中/简中) --- src/features/login/TotpForm.tsx | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/features/login/TotpForm.tsx b/src/features/login/TotpForm.tsx index dcd0c94..bbd453e 100644 --- a/src/features/login/TotpForm.tsx +++ b/src/features/login/TotpForm.tsx @@ -11,6 +11,7 @@ export function TotpForm({ onBack }: TotpFormProps) { const { t } = useTranslation(); const totp = useTotpVerify(); const [digits, setDigits] = useState(["", "", "", "", "", ""]); + const [autoSubmit, setAutoSubmit] = useState(true); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const setRef = useCallback((el: HTMLInputElement | null, idx: number) => { @@ -23,7 +24,23 @@ export function TotpForm({ onBack }: TotpFormProps) { const next = [...digits]; next[idx] = cleaned[0] ?? ""; setDigits(next); - if (idx < 5) inputRefs.current[idx + 1]?.focus(); + if (idx < 5) { + inputRefs.current[idx + 1]?.focus(); + } else if (autoSubmit && next.every((d) => d !== "")) { + // All 6 digits filled + auto-submit enabled → verify immediately + const code = next.join(""); + const pending = useAuthStore.getState().pendingCredentials; + const sessionId = pending?.sessionId ?? ""; + totp.mutate( + { sessionId, code }, + { + onError: () => { + setDigits(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + }, + }, + ); + } } function handleKeyDown(idx: number, e: KeyboardEvent) { @@ -55,6 +72,21 @@ export function TotpForm({ onBack }: TotpFormProps) { setDigits(next); const focusIdx = Math.min(text.length, 5); inputRefs.current[focusIdx]?.focus(); + + if (autoSubmit && next.every((d) => d !== "")) { + const code = next.join(""); + const pending = useAuthStore.getState().pendingCredentials; + const sessionId = pending?.sessionId ?? ""; + totp.mutate( + { sessionId, code }, + { + onError: () => { + setDigits(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + }, + }, + ); + } } function handleSubmit() { @@ -113,6 +145,17 @@ export function TotpForm({ onBack }: TotpFormProps) { {totp.error &&

{totp.error.message}

} + {/* Auto-submit toggle */} + + {/* Verify button */}
@@ -156,7 +168,13 @@ export function ToolsTab() { iconBg: "bg-[rgba(234,179,8,0.1)]", name: t("toolbox.tools.starforce"), desc: t("toolbox.tools.starforce_desc"), - disabled: true, + onClick: () => + commands + .openWebPopup( + "https://brendonmay.github.io/starforceCalculator/", + t("toolbox.tools.starforce"), + ) + .catch(() => {}), }} /> + commands + .openWebPopup( + "https://brendonmay.github.io/hexaCalculator/", + t("toolbox.tools.core_calc"), + ) + .catch(() => {}), }} />
From 713d57aa58914f36572925ed3a5f4640dcb0ca38 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Tue, 21 Apr 2026 23:58:19 +0800 Subject: [PATCH 09/22] feat: add card/list view toggle for account grid (#13) Adds a toggle button next to 'Accounts' header to switch between card view (2-column grid, current default) and list view (compact single-column rows). Auto-defaults to list view when >4 accounts. List view shows avatar initial, display name, and SN in a compact row with copy button on hover. Both views support right-click context menu and copy functionality. --- src/features/launcher/AccountGrid.tsx | 302 +++++++++++++++++++------- src/locales/en-US.json | 2 + src/locales/zh-CN.json | 4 +- src/locales/zh-TW.json | 4 +- 4 files changed, 234 insertions(+), 78 deletions(-) diff --git a/src/features/launcher/AccountGrid.tsx b/src/features/launcher/AccountGrid.tsx index 2764b00..0f75dd0 100644 --- a/src/features/launcher/AccountGrid.tsx +++ b/src/features/launcher/AccountGrid.tsx @@ -14,12 +14,17 @@ interface ContextState { accountId: string; } +type ViewMode = "card" | "list"; + export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridProps) { const { t } = useTranslation(); const { data: accounts, isLoading } = useGameAccounts(); const refreshAccounts = useRefreshAccounts(); const [contextMenu, setContextMenu] = useState(null); const [copiedId, setCopiedId] = useState(null); + const [viewMode, setViewMode] = useState(() => + (accounts?.length ?? 0) > 4 ? "list" : "card", + ); const handleContextMenu = useCallback((e: React.MouseEvent, accountId: string) => { e.preventDefault(); @@ -38,7 +43,46 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP return (
- {t("launcher.accounts")} +
+ {t("launcher.accounts")} + {/* View toggle */} + +
@@ -49,82 +93,35 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP

{t("app.loading")}

) : !accounts?.length ? (

{t("launcher.no_accounts")}

- ) : ( + ) : viewMode === "card" ? (
- {accounts.map((account) => { - const isSelected = selectedAccountId === account.id; - const initial = account.displayName.charAt(0).toUpperCase(); - const isCopied = copiedId === account.id; - return ( - - ); - })} + {accounts.map((account) => ( + onSelectAccount(account)} + onContextMenu={(e) => handleContextMenu(e, account.id)} + onCopy={(e) => handleCopyAccount(e, account.id)} + copyTitle={t("launcher.context.copy_account")} + /> + ))} +
+ ) : ( +
+ {accounts.map((account) => ( + onSelectAccount(account)} + onContextMenu={(e) => handleContextMenu(e, account.id)} + onCopy={(e) => handleCopyAccount(e, account.id)} + copyTitle={t("launcher.context.copy_account")} + /> + ))}
)}
@@ -137,3 +134,156 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP
); } + +/* ---- Card view item ---- */ +function CardItem({ + account, + isSelected, + isCopied, + onSelect, + onContextMenu, + onCopy, + copyTitle, +}: { + account: GameAccountDto; + isSelected: boolean; + isCopied: boolean; + onSelect: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onCopy: (e: React.MouseEvent) => void; + copyTitle: string; +}) { + const initial = account.displayName.charAt(0).toUpperCase(); + return ( + + ); +} + +/* ---- List view item ---- */ +function ListItem({ + account, + isSelected, + isCopied, + onSelect, + onContextMenu, + onCopy, + copyTitle, +}: { + account: GameAccountDto; + isSelected: boolean; + isCopied: boolean; + onSelect: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onCopy: (e: React.MouseEvent) => void; + copyTitle: string; +}) { + const initial = account.displayName.charAt(0).toUpperCase(); + return ( + + ); +} + +/* ---- Shared copy icon ---- */ +function CopyIcon({ + isCopied, + onClick, + title, + position, +}: { + isCopied: boolean; + onClick: (e: React.MouseEvent) => void; + title: string; + position: string; +}) { + return ( + + {isCopied ? ( + + + + ) : ( + + + + + )} + + ); +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index d64a042..77a8c4d 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -169,6 +169,8 @@ "launcher.play": "PLAY", "launcher.launching": "Launching...", "launcher.accounts": "Accounts", + "launcher.view_list": "List view", + "launcher.view_card": "Card view", "launcher.no_accounts": "No accounts found", "launcher.refresh": "Refresh", "launcher.refreshing": "Refreshing...", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index fd2c3fc..70d660a 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -169,6 +169,8 @@ "launcher.play": "开始游戏", "launcher.launching": "启动中...", "launcher.accounts": "账号列表", + "launcher.view_list": "列表视图", + "launcher.view_card": "卡片视图", "launcher.no_accounts": "未找到账号", "launcher.refresh": "刷新", "launcher.refreshing": "刷新中...", @@ -197,7 +199,7 @@ "launcher.beans_topup": "储值与购点", "launcher.beans_exchange": "兑换 beanfun!App 乐豆点", "launcher.member_center": "会员中心", - "launcher.support": "客服", + "launcher.support": "客服中心", "launcher.context.copy_account": "复制游戏账号", "launcher.context.copy_credentials": "复制一次性账密", "launcher.context.edit_account": "编辑游戏账号", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 342e95e..f60194f 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -175,6 +175,8 @@ "launcher.play": "開始遊戲", "launcher.launching": "啟動中...", "launcher.accounts": "帳號列表", + "launcher.view_list": "列表檢視", + "launcher.view_card": "卡片檢視", "launcher.no_accounts": "找不到帳號", "launcher.refresh": "重新整理", "launcher.refreshing": "重新整理中...", @@ -203,7 +205,7 @@ "launcher.beans_topup": "儲值與購點", "launcher.beans_exchange": "兌換 beanfun!App 樂豆點", "launcher.member_center": "會員中心", - "launcher.support": "客服", + "launcher.support": "客服中心", "launcher.context.copy_account": "複製遊戲帳號", "launcher.context.copy_credentials": "複製一次性帳密", "launcher.context.edit_account": "編輯遊戲帳號", From 3e60638cd9e2e1d873f4b4745027c3d2df5dd505 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 00:10:09 +0800 Subject: [PATCH 10/22] fix: report hack with cookie seeding, fix URLs (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add open_auth_popup command — authenticated WebView popup with native COM cookie seeding (reuses member popup pattern) - Report hack now uses open_auth_popup so user doesn't need to re-login in the popup - Royal patrol URL updated to beanfun-event.beanfun.com/EventAD_Mobile - Core calculator URL updated to phantasmicsky.github.io/NodestoneBuilder --- src-tauri/src/commands/system.rs | 95 +++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/features/toolbox/ToolsTab.tsx | 9 ++- src/lib/tauri.ts | 2 + 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 8fb2c6c..a8d6ed3 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -740,6 +740,101 @@ pub async fn open_customer_service( open_web_popup(url, "客服中心".to_string(), app, state).await } +/// Open an authenticated WebView popup with cookie seeding. +/// +/// Used for pages that require beanfun login cookies (e.g. report pages). +/// Reuses the same native COM cookie seeding pattern as member/gash popups. +#[tauri::command] +pub async fn open_auth_popup( + session_id: String, + url: String, + title: String, + app: tauri::AppHandle, + state: tauri::State<'_, crate::models::app_state::AppState>, +) -> Result<(), ErrorDto> { + use crate::services::cookie_native; + use tauri::WebviewWindowBuilder; + + let ss = state.require_session(&session_id).await?; + let label = "auth-popup"; + + if let Some(existing) = app.get_webview_window(label) { + let _ = existing.destroy(); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + } + + let config = state.config.read().await; + let host = match config.region { + crate::models::session::Region::HK => "bfweb.hk.beanfun.com", + crate::models::session::Region::TW => "tw.beanfun.com", + }; + drop(config); + + let seed_cookies = cookie_native::cookies_from_jar( + &ss.cookie_jar, + &[ + &format!("https://{host}/"), + "https://beanfun.com/", + "https://event.beanfun.com/", + ], + ); + + let data_dir = app.path().app_data_dir().map_err(|e| ErrorDto { + code: "SYS_PATH_ERROR".to_string(), + message: format!("Failed to get app data dir: {e}"), + category: ErrorCategory::Process, + details: None, + })?; + + let win = WebviewWindowBuilder::new( + &app, + label, + tauri::WebviewUrl::External("about:blank".parse().unwrap()), + ) + .title(&title) + .inner_size(1024.0, 720.0) + .min_inner_size(400.0, 300.0) + .decorations(true) + .resizable(true) + .center() + .visible(false) + .data_directory(data_dir) + .user_agent(WEBVIEW_USER_AGENT) + .build() + .map_err(|e| ErrorDto { + code: "SYS_POPUP_FAILED".to_string(), + message: format!("Failed to open auth popup: {e}"), + category: ErrorCategory::Process, + details: None, + })?; + + if let Err(e) = cookie_native::register_new_window_handler(&win) { + tracing::warn!("auth popup: NewWindowRequested handler failed: {e}"); + } + + if let Err(e) = cookie_native::seed_cookies_native(&win, &seed_cookies) { + tracing::warn!("auth popup: native cookie seeding failed: {e}"); + } + + let nav_rx = cookie_native::on_navigation_completed(&win).ok(); + let _ = win.eval(format!("window.location.href = '{}';", url)); + + let win_clone = win.clone(); + tauri::async_runtime::spawn(async move { + if let Some(rx) = nav_rx { + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await; + } else { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = win_clone.show(); + let _ = win_clone.set_focus(); + tracing::info!("auth popup opened: {url} ({title})"); + }); + + Ok(()) +} + /// Open a simple WebView popup window for a given URL (no auth needed). /// /// Used for public pages like forgot password, customer service, etc. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 383e281..fec283b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,6 +156,7 @@ pub fn run() { commands::system::resize_gash_popup, commands::system::open_member_popup, commands::system::open_customer_service, + commands::system::open_auth_popup, commands::system::get_web_token, commands::system::cleanup_game_cache, commands::auth::open_gamepass_login, diff --git a/src/features/toolbox/ToolsTab.tsx b/src/features/toolbox/ToolsTab.tsx index cee7c89..7abc15e 100644 --- a/src/features/toolbox/ToolsTab.tsx +++ b/src/features/toolbox/ToolsTab.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "../../lib/i18n"; +import { useAuthStore } from "../../lib/stores/auth-store"; import { commands } from "../../lib/tauri"; import { Modal } from "../shared/Modal"; @@ -49,6 +50,7 @@ function ToolCardItem({ card }: { card: ToolCard }) { export function ToolsTab() { const { t } = useTranslation(); const { weekday, date, isMaintenanceDay } = getMaintenanceInfo(); + const activeSessionId = useAuthStore((s) => s.activeSessionId); const [cleaning, setCleaning] = useState(false); const [cleanResult, setCleanResult] = useState(null); const [showConfirm, setShowConfirm] = useState(false); @@ -131,7 +133,8 @@ export function ToolsTab() { desc: t("toolbox.tools.report_hack_desc"), onClick: () => commands - .openWebPopup( + .openAuthPopup( + activeSessionId ?? "", "https://event.beanfun.com/customerservice/PluginReporting/PlayerReport.aspx", t("toolbox.tools.report_hack"), ) @@ -147,7 +150,7 @@ export function ToolsTab() { onClick: () => commands .openWebPopup( - "https://event.beanfun.com/MapleStory/eventad/EventAD.aspx?EventADID=3453", + "https://beanfun-event.beanfun.com/EventAD_Mobile/EventAD?eventAdId=3453", t("toolbox.tools.report_team"), ) .catch(() => {}), @@ -186,7 +189,7 @@ export function ToolsTab() { onClick: () => commands .openWebPopup( - "https://brendonmay.github.io/hexaCalculator/", + "https://phantasmicsky.github.io/NodestoneBuilder/", t("toolbox.tools.core_calc"), ) .catch(() => {}), diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index d1c473b..00873ad 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -106,6 +106,8 @@ export const commands = { openGashPopup: (sessionId: string) => invoke("open_gash_popup", { sessionId }), openMemberPopup: (sessionId: string) => invoke("open_member_popup", { sessionId }), openCustomerService: () => invoke("open_customer_service"), + openAuthPopup: (sessionId: string, url: string, title: string) => + invoke("open_auth_popup", { sessionId, url, title }), pingSession: (sessionId: string) => invoke("ping_session", { sessionId }), getRemainPoint: (sessionId: string) => invoke("get_remain_point", { sessionId }), From 346c82ae805c6023709494b348d780bee8bfa6e7 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 00:17:34 +0800 Subject: [PATCH 11/22] fix: remove duplicate update check on startup Frontend was calling commands.checkUpdate() in addition to listening for the backend 'update-available' event, causing the update dialog to appear twice. Now only listens for the backend event. --- src/App.tsx | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b948e3a..02573ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { commands } from "./lib/tauri"; import { useTranslation } from "./lib/i18n"; import { useUiStore } from "./lib/stores/ui-store"; import { useUpdateStore } from "./lib/stores/update-store"; @@ -123,7 +122,8 @@ export function App() { }; }, []); - // Check for updates after app is ready + // Check for updates after app is ready — listen for backend event only + // (backend already checks on startup and emits "update-available") useEffect(() => { if (!ready) return; @@ -132,23 +132,7 @@ export function App() { useUpdateStore.getState().setAvailableUpdate(event.payload); }); - // Small delay to ensure UI is rendered before showing update dialog - const timer = setTimeout(() => { - commands - .checkUpdate() - .then((info) => { - if (info) { - setPendingUpdate(info); - useUpdateStore.getState().setAvailableUpdate(info); - } - }) - .catch((e) => { - commands.logFrontendError("warn", "App", `update check failed: ${e}`); - }); - }, 1500); - return () => { - clearTimeout(timer); unlisten.then((f) => f()); }; }, [ready]); From 00940eedcbc6c4dd0658f9bf0f33c8b17237211c Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 00:17:49 +0800 Subject: [PATCH 12/22] fix: persist account view mode in localStorage Card/list toggle now saves to localStorage and restores on next visit. Still auto-defaults to list when >4 accounts if no saved preference exists. --- src/features/launcher/AccountGrid.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/features/launcher/AccountGrid.tsx b/src/features/launcher/AccountGrid.tsx index 0f75dd0..077418f 100644 --- a/src/features/launcher/AccountGrid.tsx +++ b/src/features/launcher/AccountGrid.tsx @@ -22,9 +22,17 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP const refreshAccounts = useRefreshAccounts(); const [contextMenu, setContextMenu] = useState(null); const [copiedId, setCopiedId] = useState(null); - const [viewMode, setViewMode] = useState(() => - (accounts?.length ?? 0) > 4 ? "list" : "card", - ); + const [viewMode, setViewMode] = useState(() => { + const saved = localStorage.getItem("maplelink-account-view"); + if (saved === "card" || saved === "list") return saved; + return (accounts?.length ?? 0) > 4 ? "list" : "card"; + }); + + function toggleViewMode() { + const next = viewMode === "card" ? "list" : "card"; + setViewMode(next); + localStorage.setItem("maplelink-account-view", next); + } const handleContextMenu = useCallback((e: React.MouseEvent, accountId: string) => { e.preventDefault(); @@ -47,7 +55,7 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP {t("launcher.accounts")} {/* View toggle */} + {qrData?.deeplink && ( + + )} )} diff --git a/src/lib/types.ts b/src/lib/types.ts index dca50a5..df6e68f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -64,6 +64,7 @@ export interface QrCodeData { sessionKey: string; qrImageUrl: string; verificationToken: string; + deeplink: string; } export interface QrPollResult { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 77a8c4d..f893cf4 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -29,6 +29,7 @@ "login.qr.refresh": "Refresh QR Code", "login.qr.copy": "Copy QR", "login.qr.enlarge": "Enlarge", + "login.qr.copy_deeplink": "Copy Link", "login.qr.shrink": "Shrink", "login.qr.zoom_hint": "Scroll to zoom · Click outside to close", "login.totp.instruction": "Enter the 6-digit code from your authenticator app", @@ -110,6 +111,7 @@ "toolbox.tools.cleanup_confirm": "Clear game cache, crash dumps and temp files?", "toolbox.tools.report_hack": "Report Hacks", "toolbox.tools.report_hack_desc": "Report cheating programs to GM", + "toolbox.tools.tw_only": "TW region only", "toolbox.tools.report_team": "Royal Team", "toolbox.tools.report_team_desc": "Report suspicious behavior", "toolbox.tools.starforce": "Star Force Calc", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 70d660a..06c5705 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -29,6 +29,7 @@ "login.qr.refresh": "刷新 QR Code", "login.qr.copy": "复制 QR", "login.qr.enlarge": "放大", + "login.qr.copy_deeplink": "复制链接", "login.qr.shrink": "缩小", "login.qr.zoom_hint": "滚轮缩放 · 点击外部关闭", "login.totp.instruction": "请输入验证器上的 6 位数字", @@ -110,6 +111,7 @@ "toolbox.tools.cleanup_confirm": "确定要清理游戏缓存、错误报告和无用资料吗?", "toolbox.tools.report_hack": "外挂检举", "toolbox.tools.report_hack_desc": "GM 上线核实检举外挂程序", + "toolbox.tools.tw_only": "仅限 TW 地区", "toolbox.tools.report_team": "皇家纠察队", "toolbox.tools.report_team_desc": "录影检举可疑游戏行为", "toolbox.tools.starforce": "星力计算器", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index f60194f..f393969 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -29,6 +29,7 @@ "login.qr.refresh": "重新整理 QR Code", "login.qr.copy": "複製 QR", "login.qr.enlarge": "放大", + "login.qr.copy_deeplink": "複製連結", "login.qr.shrink": "縮小", "login.qr.zoom_hint": "滾輪縮放 · 點擊外部關閉", "login.totp.instruction": "請輸入驗證器上的 6 位數字", @@ -110,6 +111,7 @@ "toolbox.tools.cleanup_confirm": "確定要清理遊戲暫存檔、錯誤報告和無用資料嗎?", "toolbox.tools.report_hack": "外掛檢舉", "toolbox.tools.report_hack_desc": "GM 上線核實檢舉外掛程式", + "toolbox.tools.tw_only": "僅限 TW 地區", "toolbox.tools.report_team": "皇家糾察隊", "toolbox.tools.report_team_desc": "錄影檢舉可疑遊戲行為", "toolbox.tools.starforce": "星力計算器", From c4e1ebb927861bf92bc799a1d31c3cc391e3fb9e Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 01:02:34 +0800 Subject: [PATCH 18/22] fix: report hack opens in internal popup, not system browser (#13) Changed from shell open (system browser) to openWebPopup (internal WebView popup window) for the report hack tool. --- src/features/toolbox/ToolsTab.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/features/toolbox/ToolsTab.tsx b/src/features/toolbox/ToolsTab.tsx index ed2a33f..657dbbd 100644 --- a/src/features/toolbox/ToolsTab.tsx +++ b/src/features/toolbox/ToolsTab.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { useTranslation } from "../../lib/i18n"; -import { useAuthStore } from "../../lib/stores/auth-store"; import { commands } from "../../lib/tauri"; import { Modal } from "../shared/Modal"; @@ -50,7 +49,6 @@ function ToolCardItem({ card }: { card: ToolCard }) { export function ToolsTab() { const { t } = useTranslation(); const { weekday, date, isMaintenanceDay } = getMaintenanceInfo(); - const activeSessionId = useAuthStore((s) => s.activeSessionId); const [cleaning, setCleaning] = useState(false); const [cleanResult, setCleanResult] = useState(null); const [showConfirm, setShowConfirm] = useState(false); @@ -133,13 +131,11 @@ export function ToolsTab() { desc: t("toolbox.tools.report_hack_desc"), onClick: () => commands - .openAuthPopup( - activeSessionId ?? "", + .openWebPopup( "https://event.beanfun.com/customerservice/PluginReporting/PlayerReport.aspx", t("toolbox.tools.report_hack"), ) .catch(() => {}), - disabled: !activeSessionId, }} /> Date: Wed, 22 Apr 2026 01:02:48 +0800 Subject: [PATCH 19/22] fix: beans exchange uses auth popup with cookie seeding (#13) Changed from system browser to openAuthPopup so the deposit page receives beanfun session cookies and doesn't require the user to re-login. --- src/features/launcher/MainPage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/launcher/MainPage.tsx b/src/features/launcher/MainPage.tsx index 7efb8e5..aeae280 100644 --- a/src/features/launcher/MainPage.tsx +++ b/src/features/launcher/MainPage.tsx @@ -493,9 +493,12 @@ function BeansPopupMenu({ } async function handleExchange() { - // Same URL as original Beanfun client (m.beanfun.com/Deposite) try { - await commands.openWebPopup("https://m.beanfun.com/Deposite", t("launcher.beans_exchange")); + await commands.openAuthPopup( + sessionId, + "https://m.beanfun.com/Deposite", + t("launcher.beans_exchange"), + ); } catch { /* ignore */ } From 6bd0b403ddca99b27a15a98990d7f8155ab5ef37 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 01:03:47 +0800 Subject: [PATCH 20/22] style: format AccountContextMenu style prop --- src/features/launcher/AccountContextMenu.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/launcher/AccountContextMenu.tsx b/src/features/launcher/AccountContextMenu.tsx index f04bd8a..2b200c0 100644 --- a/src/features/launcher/AccountContextMenu.tsx +++ b/src/features/launcher/AccountContextMenu.tsx @@ -382,7 +382,9 @@ export function AccountContextMenu({ position, account, onClose }: AccountContex ref={menuRef} role="menu" className="fixed z-50 min-w-[170px] animate-[ctxIn_0.15s_ease] rounded-[10px] border border-border bg-[var(--surface)] py-1.5 shadow-[0_8px_32px_rgba(0,0,0,0.3)] backdrop-blur-[20px]" - style={clampedPos ? { left: clampedPos.x, top: clampedPos.y } : { left: -9999, top: -9999 }} + style={ + clampedPos ? { left: clampedPos.x, top: clampedPos.y } : { left: -9999, top: -9999 } + } > {t("launcher.context.copy_account")} From b8c781926acffc63074212b530856dd27b8df483 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 01:07:27 +0800 Subject: [PATCH 21/22] fix: separate copied state for QR image and deeplink buttons Copy QR and Copy Deeplink were sharing the same 'copied' state, so clicking Copy Deeplink showed 'Copied' on the QR button. Now uses separate linkCopied state with green highlight on the correct button. --- src/features/login/QrLoginForm.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/features/login/QrLoginForm.tsx b/src/features/login/QrLoginForm.tsx index f26df79..218859a 100644 --- a/src/features/login/QrLoginForm.tsx +++ b/src/features/login/QrLoginForm.tsx @@ -19,6 +19,7 @@ export function QrLoginForm({ onBack }: QrLoginFormProps) { ); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); const [enlarged, setEnlarged] = useState(false); const intervalRef = useRef | null>(null); const startedRef = useRef(false); @@ -268,11 +269,15 @@ export function QrLoginForm({ onBack }: QrLoginFormProps) { onClick={async () => { if (!qrData?.deeplink) return; await navigator.clipboard.writeText(qrData.deeplink); - setCopied(true); - setTimeout(() => setCopied(false), 1500); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 1500); }} title={t("login.qr.copy_deeplink")} - className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-text-dim transition-colors hover:bg-[var(--surface-hover)] hover:text-accent" + className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] transition-colors ${ + linkCopied + ? "text-green-400" + : "text-text-dim hover:bg-[var(--surface-hover)] hover:text-accent" + }`} > - {t("login.qr.copy_deeplink")} + {linkCopied ? t("common.copied") : t("login.qr.copy_deeplink")} )} From 1caf33fbf062b903d87aaeb4ac0100a867abc7a5 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Wed, 22 Apr 2026 01:14:04 +0800 Subject: [PATCH 22/22] perf: delay backend update check to avoid race with frontend listener Add 2-second delay before backend update check so the frontend event listener is registered before the event fires. This also prevents the black screen on startup caused by the proxy probe running before the window is fully rendered. --- src-tauri/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fec283b..87f2b25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -279,6 +279,9 @@ pub fn run() { if update_service::should_check_on_startup(auto_update_enabled) { let app_handle_for_update = app.handle().clone(); tauri::async_runtime::spawn(async move { + // Small delay to ensure frontend listener is registered + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let version = update_service::current_version(); let include_prerelease = update_channel == models::config::UpdateChannel::PreRelease;