Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ pub fn build_specta_builder<R: tauri::Runtime>() -> Builder<R> {
// see the `auth::login_gamepass_start` comment for the full
// E0401 / runtime-agnostic bindings rationale.
system::minimize_main_window::<tauri::Wry>,
// system (Settings → Maintenance: window/cache recovery). Generic over
// `R` for the same `AppHandle<R>` reason as `minimize_main_window`.
system::reset_window_position::<tauri::Wry>,
system::clear_webview2_cache::<tauri::Wry>,
// config (P10.3 — D2)
config::get_config_value,
config::get_all_config,
Expand Down
89 changes: 89 additions & 0 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,95 @@ pub async fn minimize_main_window<R: tauri::Runtime>(
Ok(())
}

/// Filename the `tauri-plugin-window-state` plugin persists the window
/// position into, under `app_config_dir()` (Windows:
/// `%APPDATA%\tw.beanfun.app\.window-state.json`). The plugin's documented
/// default; kept as a named constant so [`reset_window_position`] and the
/// plugin stay in sync if it is ever customised in `lib.rs`.
const WINDOW_STATE_FILENAME: &str = ".window-state.json";

/// Reset the main window to a safe, on-screen position.
///
/// `tauri-plugin-window-state` persists the window **position** (see
/// `lib.rs`), so a window dragged off-screen — or stranded by a disconnected
/// monitor — restores to that bad spot on the next launch. This re-centers the
/// live window and deletes the saved state file so the next launch also starts
/// centered.
///
/// # Errors
///
/// - `system.window_not_found` — the `main` window is not registered.
/// - `system.reset_window_failed` — `window.center()` returned an OS error.
#[tauri::command]
#[specta::specta]
pub async fn reset_window_position<R: tauri::Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), CommandError> {
let win = app_handle.get_webview_window("main").ok_or_else(|| {
CommandError::new(
"system.window_not_found",
"main webview window is not currently registered",
)
})?;
win.center().map_err(|err| {
CommandError::new(
"system.reset_window_failed",
format!("failed to center main window: {err}"),
)
})?;

// Best-effort: drop the persisted position so a future launch starts from
// the centered spot rather than restoring the old (possibly off-screen)
// one. Non-fatal — the live window is already centered, and the plugin
// re-saves the good position on the next clean exit.
if let Ok(config_dir) = app_handle.path().app_config_dir() {
let state_file = config_dir.join(WINDOW_STATE_FILENAME);
match std::fs::remove_file(&state_file) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!(
step = "ResetWindow.RemoveState",
error = %err,
"could not delete persisted window-state file (non-fatal)"
),
}
}
Ok(())
}

/// Clear the main webview's browsing data (the WebView2 cache on Windows).
///
/// WebView2 persists its cache — including the page **zoom factor** — in the
/// user-data folder (`%APPDATA%\tw.beanfun.app\EBWebView`). A stale/corrupt
/// cache can leave the UI mis-rendered; this clears it. The app's login
/// session lives in the backend HTTP client's cookie jar (not the webview), so
/// this does **not** sign the user out. A restart is recommended so the cleared
/// state fully takes effect.
///
/// # Errors
///
/// - `system.window_not_found` — the `main` window is not registered.
/// - `system.clear_cache_failed` — the WebView2 clear call returned an error.
#[tauri::command]
#[specta::specta]
pub async fn clear_webview2_cache<R: tauri::Runtime>(
app_handle: AppHandle<R>,
) -> Result<(), CommandError> {
let win = app_handle.get_webview_window("main").ok_or_else(|| {
CommandError::new(
"system.window_not_found",
"main webview window is not currently registered",
)
})?;
win.clear_all_browsing_data().map_err(|err| {
CommandError::new(
"system.clear_cache_failed",
format!("failed to clear webview browsing data: {err}"),
)
})?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
26 changes: 19 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,16 +347,28 @@ pub fn run() {
.unwrap_or_default()
.eq_ignore_ascii_case("true");

// NOTE: we deliberately do NOT pass `--force-device-scale-factor=1`.
//
// That flag (from #257's physical-px "DPI-immune" layout) pinned the
// webview to 1:1 physical pixels. But #326 switched the router's
// `fitWindow` to size the OS window with `LogicalSize`, which Tauri
// multiplies by the window's OS scale factor. With the webview still
// rendering 1:1, at >100% Windows scaling the window came out
// `scale`× larger than the 1:1 content → the reported "window too big,
// empty space around the content" bug. Letting the webview's
// `devicePixelRatio` follow the OS scale makes `LogicalSize` and the
// CSS-px measurements in `fitWindow` (which already assume
// `screen.availWidth` is CSS px) consistent, so the window matches the
// content at every scaling.
//
// `--force-text-scale-factor=1` stays: it's an orthogonal accessibility
// guard (Windows "Text size") and does not touch device scaling.
let mut args = vec![
// Keep the existing DPI/resolution behaviour: render at
// 1:1 physical pixels and let the router's fitWindow
// compensate the logical window size.
"--force-device-scale-factor=1".to_string(),
// Treat Windows Accessibility "Text size" as 100% so
// user text-size changes do not inflate app content.
// Treat Windows Accessibility "Text size" as 100% so a user's
// text-size setting does not inflate the app layout.
"--force-text-scale-factor=1".to_string(),
];
tracing::info!("forcing WebView2 device scale and text scale factors");
tracing::info!("forcing WebView2 text scale factor to 1 (device scale left to the OS)");

if disable_hw_accel {
args.push("--disable-gpu".to_string());
Expand Down
27 changes: 27 additions & 0 deletions src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,15 @@ const zhTW = {
tradLoginTip: '使用傳統登入流程(多次跳轉),\n適合自動登入失敗或無法跳出登入視窗時使用。',
killPatcherTip: '阻止 beanfun! 啟動的更新程式 (Patcher.aspx) 自動執行。',
skipPlayWindowTip: '直接啟動遊戲,跳過 Play 視窗確認步驟。',
maintenance: '維護',
maintenanceTip: '視窗顯示異常(過大、四周空白、位置跑掉)時可嘗試。',
resetWindowPosition: '重設視窗位置',
resetWindowPositionDone: '視窗位置已重設並置中。',
clearWebviewCache: '清理 WebView2 快取',
clearWebviewCacheTitle: '清理 WebView2 快取',
clearWebviewCacheConfirm:
'這會清除 WebView2 的快取與縮放狀態,不會登出你的帳號。清理後建議重新啟動 Beanfun。要繼續嗎?',
clearWebviewCacheDone: 'WebView2 快取已清理,建議重新啟動 Beanfun。',
},
inAppBrowser: {
fallbackToSystem: '此網址不在 Beanfun 內建瀏覽器允許清單,將改以系統預設瀏覽器開啟。',
Expand Down Expand Up @@ -675,6 +684,15 @@ const zhCN = {
tradLoginTip: '使用传统登录流程(多次跳转),\n适合自动登录失败或无法跳出登录窗口时使用。',
killPatcherTip: '阻止 beanfun! 启动的更新程序 (Patcher.aspx) 自动执行。',
skipPlayWindowTip: '直接启动游戏,跳过 Play 窗口确认步骤。',
maintenance: '维护',
maintenanceTip: '窗口显示异常(过大、四周空白、位置跑掉)时可尝试。',
resetWindowPosition: '重置窗口位置',
resetWindowPositionDone: '窗口位置已重置并居中。',
clearWebviewCache: '清理 WebView2 缓存',
clearWebviewCacheTitle: '清理 WebView2 缓存',
clearWebviewCacheConfirm:
'这会清除 WebView2 的缓存与缩放状态,不会登出你的账号。清理后建议重新启动 Beanfun。要继续吗?',
clearWebviewCacheDone: 'WebView2 缓存已清理,建议重新启动 Beanfun。',
},
inAppBrowser: {
fallbackToSystem: '此网址不在 Beanfun 内建浏览器允许列表,将改以系统默认浏览器开启。',
Expand Down Expand Up @@ -926,6 +944,15 @@ const enUS = {
'Use the traditional multi-redirect login flow.\nHandy when auto-login fails or the login window does not appear.',
killPatcherTip: 'Prevent the beanfun! launcher (Patcher.aspx) from auto-running.',
skipPlayWindowTip: 'Launch the game directly, skipping the Play window confirmation step.',
maintenance: 'Maintenance',
maintenanceTip: 'Try these if the window looks wrong (too large, empty space, off-screen).',
resetWindowPosition: 'Reset window position',
resetWindowPositionDone: 'Window position reset and centered.',
clearWebviewCache: 'Clear WebView2 cache',
clearWebviewCacheTitle: 'Clear WebView2 cache',
clearWebviewCacheConfirm:
'This clears the WebView2 cache and zoom state. It will not sign you out. A Beanfun restart is recommended afterwards. Continue?',
clearWebviewCacheDone: 'WebView2 cache cleared. Restarting Beanfun is recommended.',
},
inAppBrowser: {
fallbackToSystem:
Expand Down
80 changes: 80 additions & 0 deletions src/pages/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,13 @@ import {
} from 'element-plus'
import {
ArrowLeft,
Delete,
FolderOpened,
InfoFilled,
Operation,
Refresh,
Setting as SettingIcon,
Tools,
User,
} from '@element-plus/icons-vue'
import { open as openFileDialog } from '@tauri-apps/plugin-dialog'
Expand Down Expand Up @@ -640,6 +643,41 @@ function handleBack(): void {
void router.push('/login')
}

/* --------------- Maintenance — window / cache recovery --------------- */

/**
* Re-center the OS window and clear the persisted position. Recovery for a
* window that restored off-screen or at a bad spot (the `POSITION`-only
* `tauri-plugin-window-state` can strand it there). `safeInvoke` toasts on
* failure; we add a success toast so the click has visible feedback even
* though the window jump is usually obvious.
*/
async function handleResetWindowPosition(): Promise<void> {
const result = await safeInvoke(commands.resetWindowPosition())
if (result.ok) ElMessage.success(t('settings.resetWindowPositionDone'))
}

/**
* Clear the WebView2 cache (incl. the persisted zoom that could mis-size the
* window). Confirmed first because a restart is recommended afterwards. Does
* not sign the user out — the login session lives in the backend HTTP client,
* not the webview.
*/
async function handleClearWebviewCache(): Promise<void> {
try {
await ElMessageBox.confirm(
t('settings.clearWebviewCacheConfirm'),
t('settings.clearWebviewCacheTitle'),
{ type: 'warning' },
)
} catch {
// User dismissed the confirm dialog — no-op.
return
}
const result = await safeInvoke(commands.clearWebview2Cache())
if (result.ok) ElMessage.success(t('settings.clearWebviewCacheDone'))
}

/* --------------- mount --------------- */

onMounted(() => {
Expand Down Expand Up @@ -930,6 +968,35 @@ onMounted(() => {
<p class="settings__empty-text">{{ t('settings.gameSectionEmpty') }}</p>
</section>

<!-- Maintenance section — window / cache recovery. Not a WPF-parity
section: new troubleshooting controls for the "window too big /
empty space / off-screen" reports. -->
<section class="settings__section bf-glass-panel" data-test="settings-maintenance-section">
<header class="settings__section-header">
<el-icon><Tools /></el-icon>
<span>{{ t('settings.maintenance') }}</span>
</header>
<p class="settings__empty-text">{{ t('settings.maintenanceTip') }}</p>
<div class="settings__maintenance-actions">
<el-button
class="bf-btn-secondary"
data-test="settings-reset-window"
@click="handleResetWindowPosition"
>
<el-icon><Refresh /></el-icon>
<span>{{ t('settings.resetWindowPosition') }}</span>
</el-button>
<el-button
class="bf-btn-secondary"
data-test="settings-clear-webview-cache"
@click="handleClearWebviewCache"
>
<el-icon><Delete /></el-icon>
<span>{{ t('settings.clearWebviewCache') }}</span>
</el-button>
</div>
</section>

<!-- Footer: Back button -->
<footer class="settings__footer">
<el-button
Expand Down Expand Up @@ -1119,6 +1186,19 @@ onMounted(() => {
align-self: flex-start;
}

.settings__maintenance-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}

.settings__maintenance-actions .el-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-left: 0;
}

.settings__game-path-input :deep(.el-input__inner) {
cursor: pointer;
}
Expand Down
45 changes: 45 additions & 0 deletions src/types/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,51 @@ async minimizeMainWindow() : Promise<Result<null, CommandError>> {
else return { status: "error", error: e as any };
}
},
/**
* Reset the main window to a safe, on-screen position.
*
* `tauri-plugin-window-state` persists the window **position** (see
* `lib.rs`), so a window dragged off-screen — or stranded by a disconnected
* monitor — restores to that bad spot on the next launch. This re-centers the
* live window and deletes the saved state file so the next launch also starts
* centered.
*
* # Errors
*
* - `system.window_not_found` — the `main` window is not registered.
* - `system.reset_window_failed` — `window.center()` returned an OS error.
*/
async resetWindowPosition() : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reset_window_position") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Clear the main webview's browsing data (the WebView2 cache on Windows).
*
* WebView2 persists its cache — including the page **zoom factor** — in the
* user-data folder (`%APPDATA%\tw.beanfun.app\EBWebView`). A stale/corrupt
* cache can leave the UI mis-rendered; this clears it. The app's login
* session lives in the backend HTTP client's cookie jar (not the webview), so
* this does **not** sign the user out. A restart is recommended so the cleared
* state fully takes effect.
*
* # Errors
*
* - `system.window_not_found` — the `main` window is not registered.
* - `system.clear_cache_failed` — the WebView2 clear call returned an error.
*/
async clearWebview2Cache() : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("clear_webview2_cache") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Read a single config value by `key`, falling back to `""` when
* the file is missing / unreadable / the key is absent.
Expand Down