Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8ce7e67
fix: remove window border and attempt DWM border color removal
lshw54 Apr 21, 2026
5c11a6b
fix: QR enlarged view shows all controls and resizes correctly
lshw54 Apr 21, 2026
c5629c7
feat: persistent update banner and cached update info
lshw54 Apr 21, 2026
b253a31
feat: TOTP auto-submit on 6-digit completion
lshw54 Apr 21, 2026
220cce4
fix: clamp context menu position to window bounds (#13)
lshw54 Apr 21, 2026
c6b4e87
fix: improve context menu boundary clamping (#13)
lshw54 Apr 21, 2026
7c9e959
fix: customer service opens in internal WebView, fix beans exchange (…
lshw54 Apr 21, 2026
e499f7a
fix: enable all tool buttons in toolbox (#13)
lshw54 Apr 21, 2026
713d57a
feat: add card/list view toggle for account grid (#13)
lshw54 Apr 21, 2026
3e60638
fix: report hack with cookie seeding, fix URLs (#13)
lshw54 Apr 21, 2026
346c82a
fix: remove duplicate update check on startup
lshw54 Apr 21, 2026
00940ee
fix: persist account view mode in localStorage
lshw54 Apr 21, 2026
fe01425
fix: report hack uses web_token auth URL (#13)
lshw54 Apr 21, 2026
cb91689
docs: update code standards in README to match CI workflow
lshw54 Apr 21, 2026
f24b329
fix: update check fallback for missed backend events
lshw54 Apr 21, 2026
8233494
fix: increase login window height and add auth popup cookie hosts
lshw54 Apr 21, 2026
5cecbbb
feat: add QR deeplink copy button
lshw54 Apr 21, 2026
c4e1ebb
fix: report hack opens in internal popup, not system browser (#13)
lshw54 Apr 21, 2026
5cc292a
fix: beans exchange uses auth popup with cookie seeding (#13)
lshw54 Apr 21, 2026
6bd0b40
style: format AccountContextMenu style prop
lshw54 Apr 21, 2026
b8c7819
fix: separate copied state for QR image and deeplink buttons
lshw54 Apr 21, 2026
1caf33f
perf: delay backend update check to avoid race with frontend listener
lshw54 Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 101 additions & 7 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
_ => {
Expand Down Expand Up @@ -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;
Expand All @@ -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(())
}

Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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::<u32>() as u32,
);
}
}
}

if let tauri::WindowEvent::Destroyed = event {
let label = window.label().to_string();
let app_handle = window.app_handle().clone();
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/services/beanfun_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1307,6 +1309,19 @@ async fn tw_qr_start(client: &Client) -> Result<QrCodeData, LoginError> {
.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::<Vec<_>>())
);

if qr_image.is_empty() {
return Err(parse_error_str("no QR image in InitLogin response"));
}
Expand All @@ -1323,6 +1338,7 @@ async fn tw_qr_start(client: &Client) -> Result<QrCodeData, LoginError> {
session_key: skey,
qr_image_url,
verification_token,
deeplink,
})
}

Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json5
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"label": "main",
"title": "MAPLELINK",
"width": 350,
"height": 580,
"height": 620,
"decorations": false,
"transparent": false,
"resizable": false,
Expand Down
78 changes: 68 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -77,8 +78,34 @@ function SplashScreen() {
export function App() {
useThemeEffect();
const configLoading = useInitialConfigSync();
const { t } = useTranslation();
const ready = !configLoading;
const [pendingUpdate, setPendingUpdate] = useState<UpdateInfoDto | null>(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(() => {
Expand All @@ -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<UpdateInfoDto>("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 <SplashScreen />;

return (
<div className="flex h-screen flex-col bg-[var(--bg)] text-[var(--text)]">
<Titlebar />
{showBanner && (
<div className="flex shrink-0 items-center justify-between bg-[rgba(232,162,58,0.12)] px-3 py-1.5 backdrop-blur-sm">
<button
onClick={() => setPendingUpdate(availableUpdate)}
className="flex-1 text-left text-[11px] text-accent transition-opacity hover:opacity-80"
>
🔔 {t("app.update_banner").replace("{{version}}", availableUpdate.version)}
</button>
<button
onClick={() => setBannerDismissed(true)}
className="ml-2 shrink-0 rounded p-0.5 text-text-faint transition-colors hover:text-[var(--text)]"
aria-label="Dismiss"
>
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M3 3L9 9M9 3L3 9" />
</svg>
</button>
</div>
)}
<main className="relative flex-1 overflow-hidden">
<PageRouter />
</main>
Expand Down
Loading