diff --git a/README.en.md b/README.en.md index d01ff0f..1bd0384 100644 --- a/README.en.md +++ b/README.en.md @@ -191,12 +191,15 @@ cargo tauri build # production build ```bash # Rust -cargo fmt --all # format -cargo clippy -- -D warnings # lint +cargo fmt --all --check # format check +cargo clippy --all-targets -- -D warnings # lint +cargo test # unit + property tests # TypeScript -npm run lint # ESLint -npm run format # Prettier +npm run lint # ESLint +npx prettier --check "src/**/*.{ts,tsx,css,json}" # format check +npx tsc -b # type check +npm run format # Prettier format # Git commits follow Conventional Commits # feat: / fix: / refactor: / chore: ... diff --git a/README.md b/README.md index f961b3b..40b4eeb 100644 --- a/README.md +++ b/README.md @@ -191,12 +191,15 @@ cargo tauri build # 正式建置 ```bash # Rust -cargo fmt --all # 格式化 -cargo clippy -- -D warnings # 靜態分析 +cargo fmt --all --check # 格式檢查 +cargo clippy --all-targets -- -D warnings # 靜態分析 +cargo test # 單元測試 + 屬性測試 # TypeScript -npm run lint # ESLint 檢查 -npm run format # Prettier 格式化 +npm run lint # ESLint 檢查 +npx prettier --check "src/**/*.{ts,tsx,css,json}" # 格式檢查 +npx tsc -b # 型別檢查 +npm run format # Prettier 格式化 # Git commit 遵循 Conventional Commits # feat: / fix: / refactor: / chore: ... 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/commands/system.rs b/src-tauri/src/commands/system.rs index f6d6c12..995b7f7 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -42,8 +42,8 @@ pub fn log_frontend_error(level: String, module: String, message: String) -> Res #[tauri::command] 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" => (350.0, 620.0), + "login-enlarged" => (540.0, 780.0), "main" => (760.0, 530.0), "toolbox" => (750.0, 490.0), _ => { @@ -719,9 +719,10 @@ pub async fn open_member_popup( /// Open customer service page in system browser. /// -/// Customer service pages don't require auth — just open the URL directly. +/// Customer service pages don't require auth — open in internal WebView popup. #[tauri::command] pub async fn open_customer_service( + app: tauri::AppHandle, state: tauri::State<'_, crate::models::app_state::AppState>, ) -> Result<(), ErrorDto> { let config = state.config.read().await; @@ -733,16 +734,109 @@ pub async fn open_customer_service( "https://tw.beanfun.com/customerservice/www/main.aspx" } }; + let url = url.to_string(); drop(config); - open::that(url).map_err(|e| ErrorDto { - code: "SYS_OPEN_FAILED".to_string(), - message: format!("Failed to open: {e}"), + 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); + + // Seed cookies from the session jar — covers all beanfun domains + let seed_cookies = cookie_native::cookies_from_jar( + &ss.cookie_jar, + &[ + &format!("https://{host}/"), + "https://beanfun.com/", + "https://event.beanfun.com/", + "https://m.beanfun.com/", + "https://login.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, })?; - tracing::info!("customer service opened: {url}"); + 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(); + let url_log = url.clone(); + let title_log = title.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_log} ({title_log})"); + }); + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 020cb4a..87f2b25 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, @@ -278,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; @@ -363,6 +367,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-tauri/src/services/beanfun_service.rs b/src-tauri/src/services/beanfun_service.rs index 53b2a17..40c4ea7 100644 --- a/src-tauri/src/services/beanfun_service.rs +++ b/src-tauri/src/services/beanfun_service.rs @@ -37,6 +37,8 @@ pub struct QrCodeData { /// Cached `__RequestVerificationToken` from the login page. /// Used for subsequent `CheckLoginStatus` POST requests. pub verification_token: String, + /// Beanfun app deeplink URL for mobile QR scanning. + pub deeplink: String, } /// Polling result for an in-progress QR-code login. @@ -1307,6 +1309,19 @@ async fn tw_qr_start(client: &Client) -> Result { .as_str() .unwrap_or_default(); + let deeplink = init_json["ResultData"]["DeepLink"] + .as_str() + .or_else(|| init_json["ResultData"]["strUrl"].as_str()) + .unwrap_or_default() + .to_string(); + + tracing::debug!( + "InitLogin ResultData keys: {:?}", + init_json["ResultData"] + .as_object() + .map(|o| o.keys().collect::>()) + ); + if qr_image.is_empty() { return Err(parse_error_str("no QR image in InitLogin response")); } @@ -1323,6 +1338,7 @@ async fn tw_qr_start(client: &Client) -> Result { session_key: skey, qr_image_url, verification_token, + deeplink, }) } diff --git a/src-tauri/tauri.conf.json5 b/src-tauri/tauri.conf.json5 index 16c2250..558d8b3 100644 --- a/src-tauri/tauri.conf.json5 +++ b/src-tauri/tauri.conf.json5 @@ -16,7 +16,7 @@ "label": "main", "title": "MAPLELINK", "width": 350, - "height": 580, + "height": 620, "decorations": false, "transparent": false, "resizable": false, diff --git a/src/App.tsx b/src/App.tsx index ea03f4a..75ec4a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; +import { useTranslation } from "./lib/i18n"; import { commands } from "./lib/tauri"; import { useUiStore } from "./lib/stores/ui-store"; import { useUpdateStore } from "./lib/stores/update-store"; @@ -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(() => { @@ -96,37 +123,68 @@ export function App() { }; }, []); - // Check for updates after app is ready + // Listen for backend update-available event + fallback frontend check. + // listen() is async so the backend event may fire before the listener + // is registered. The delayed checkUpdate() catches that race. useEffect(() => { - if (!ready) return; + function onUpdate(info: UpdateInfoDto) { + setPendingUpdate(info); + useUpdateStore.getState().setAvailableUpdate(info); + } const unlisten = listen("update-available", (event) => { - setPendingUpdate(event.payload); + onUpdate(event.payload); }); - // Small delay to ensure UI is rendered before showing update dialog + // Fallback: if backend event was missed, check after a short delay const timer = setTimeout(() => { + if (useUpdateStore.getState().availableUpdate) return; // already got it commands .checkUpdate() .then((info) => { - if (info) setPendingUpdate(info); + if (info) onUpdate(info); }) - .catch((e) => { - commands.logFrontendError("warn", "App", `update check failed: ${e}`); - }); - }, 1500); + .catch(() => {}); + }, 3000); return () => { clearTimeout(timer); unlisten.then((f) => f()); }; - }, [ready]); + }, []); if (!ready) return ; return (
+ {showBanner && ( +
+ + +
+ )}
diff --git a/src/features/launcher/AccountContextMenu.tsx b/src/features/launcher/AccountContextMenu.tsx index a08b581..2b200c0 100644 --- a/src/features/launcher/AccountContextMenu.tsx +++ b/src/features/launcher/AccountContextMenu.tsx @@ -212,6 +212,27 @@ export function AccountContextMenu({ position, account, onClose }: AccountContex const [modalView, setModalView] = useState(null); const [editError, setEditError] = useState(false); const modalViewRef = useRef(null); + const [clampedPos, setClampedPos] = useState<{ x: number; y: number } | null>(null); + + // Clamp menu position to stay within window bounds. + // Uses rAF to ensure the menu is fully painted before measuring. + useEffect(() => { + if (!position) return; + + const raf = requestAnimationFrame(() => { + const el = menuRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const pad = 8; + const menuW = rect.width || 170; + const menuH = rect.height || 300; + const x = Math.min(position.x, window.innerWidth - menuW - pad); + const y = Math.min(position.y, window.innerHeight - menuH - pad); + setClampedPos({ x: Math.max(pad, x), y: Math.max(pad, y) }); + }); + + return () => cancelAnimationFrame(raf); + }, [position]); // Keep ref in sync useEffect(() => { @@ -361,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={{ left: position.x, top: position.y }} + style={ + clampedPos ? { left: clampedPos.x, top: clampedPos.y } : { left: -9999, top: -9999 } + } > {t("launcher.context.copy_account")} diff --git a/src/features/launcher/AccountGrid.tsx b/src/features/launcher/AccountGrid.tsx index 2764b00..077418f 100644 --- a/src/features/launcher/AccountGrid.tsx +++ b/src/features/launcher/AccountGrid.tsx @@ -14,12 +14,25 @@ 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(() => { + 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(); @@ -38,7 +51,46 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP return (
- {t("launcher.accounts")} +
+ {t("launcher.accounts")} + {/* View toggle */} + +
@@ -49,82 +101,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 +142,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/features/launcher/MainPage.tsx b/src/features/launcher/MainPage.tsx index 8b0ab87..aeae280 100644 --- a/src/features/launcher/MainPage.tsx +++ b/src/features/launcher/MainPage.tsx @@ -493,14 +493,12 @@ function BeansPopupMenu({ } async function handleExchange() { - if (region === "TW") { - onClose(); - return; - } - // Exchange uses a different URL — for now open in browser try { - const { open } = await import("@tauri-apps/plugin-shell"); - await open("https://m.beanfun.com/Deposite"); + await commands.openAuthPopup( + sessionId, + "https://m.beanfun.com/Deposite", + t("launcher.beans_exchange"), + ); } catch { /* ignore */ } diff --git a/src/features/login/QrLoginForm.tsx b/src/features/login/QrLoginForm.tsx index b8fc67b..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); @@ -125,22 +126,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")}
-
- )} + )} +
+
+ {qrData?.deeplink && ( + + )}
)} @@ -276,7 +309,7 @@ export function QrLoginForm({ onBack }: QrLoginFormProps) { )}
- {!enlarged && status === "expired" && ( + {status === "expired" && ( - )} +
); } 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 */} - {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/features/toolbox/ToolsTab.tsx b/src/features/toolbox/ToolsTab.tsx index a04203e..657dbbd 100644 --- a/src/features/toolbox/ToolsTab.tsx +++ b/src/features/toolbox/ToolsTab.tsx @@ -129,7 +129,13 @@ export function ToolsTab() { iconBg: "bg-[rgba(234,179,8,0.1)]", name: t("toolbox.tools.report_hack"), desc: t("toolbox.tools.report_hack_desc"), - disabled: true, + onClick: () => + commands + .openWebPopup( + "https://event.beanfun.com/customerservice/PluginReporting/PlayerReport.aspx", + t("toolbox.tools.report_hack"), + ) + .catch(() => {}), }} /> + commands + .openWebPopup( + "https://beanfun-event.beanfun.com/EventAD_Mobile/EventAD?eventAdId=3453", + t("toolbox.tools.report_team"), + ) + .catch(() => {}), }} />
@@ -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://phantasmicsky.github.io/NodestoneBuilder/", + t("toolbox.tools.core_calc"), + ) + .catch(() => {}), }} /> 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/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 }), 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 012f8f9..f893cf4 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", @@ -28,12 +29,14 @@ "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", "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", @@ -108,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", @@ -167,6 +171,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 974247f..06c5705 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": "账号登录", @@ -28,12 +29,14 @@ "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 位数字", "login.totp.title": "两步骤验证", "login.totp.code": "验证码", "login.totp.submit": "验证", + "login.totp.auto_submit": "输入完成自动验证", "login.totp.verifying": "验证中...", "login.verify.title": "安全验证", @@ -108,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": "星力计算器", @@ -167,6 +171,8 @@ "launcher.play": "开始游戏", "launcher.launching": "启动中...", "launcher.accounts": "账号列表", + "launcher.view_list": "列表视图", + "launcher.view_card": "卡片视图", "launcher.no_accounts": "未找到账号", "launcher.refresh": "刷新", "launcher.refreshing": "刷新中...", @@ -195,7 +201,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 e29de75..f393969 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": "帳號登入", @@ -28,12 +29,14 @@ "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 位數字", "login.totp.title": "兩步驟驗證", "login.totp.code": "驗證碼", "login.totp.submit": "驗證", + "login.totp.auto_submit": "輸入完成自動驗證", "login.totp.verifying": "驗證中...", "login.verify.title": "安全驗證", @@ -108,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": "星力計算器", @@ -173,6 +177,8 @@ "launcher.play": "開始遊戲", "launcher.launching": "啟動中...", "launcher.accounts": "帳號列表", + "launcher.view_list": "列表檢視", + "launcher.view_card": "卡片檢視", "launcher.no_accounts": "找不到帳號", "launcher.refresh": "重新整理", "launcher.refreshing": "重新整理中...", @@ -201,7 +207,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/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: