From d210b3f39c1405d71f8ae41d181c58a533a5609a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 9 Jul 2026 14:02:53 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(window):=20=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20Windows=20=E5=86=B7=E5=90=AF=E5=8A=A8=E5=8D=8A?= =?UTF-8?q?=E7=AA=97=EF=BC=8C=E5=8E=9F=E7=94=9F=E6=9C=80=E5=A4=A7=E5=8C=96?= =?UTF-8?q?=E5=B9=B6=E5=85=9C=E5=BA=95=E9=93=BA=E6=BB=A1=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows 启动 WindowStartState 改为 Maximised,默认尺寸提升到 1440×900 - 无记忆/历史 1024×768/覆盖率过低的普通窗记忆改为启动最大化 - Maximise 多次失败时铺满工作区兜底,避免残留浮动半窗 - 用户刻意拉大的普通窗仍先 Unmaximise 再恢复尺寸位置 --- frontend/src/App.tool-center.test.ts | 5 +- frontend/src/App.tsx | 95 +++++++++++++++---- .../src/utils/windowStartupLayout.test.ts | 60 ++++++++++++ frontend/src/utils/windowStartupLayout.ts | 66 +++++++++++++ main.go | 21 ++-- 5 files changed, 220 insertions(+), 27 deletions(-) diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 39fdbddc..53e6b4e6 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -392,7 +392,10 @@ describe('tool center menu entries', () => { expect(appSource).toContain("applyStartupWindowChrome(1, 'maximised');"); expect(appSource).toContain('const delayMs = attempt <= 1 ? 0 : applyRetryDelayMs'); expect(appSource).toContain('markAppliedMaximisedOrFullscreen'); - expect(appSource).toContain('resolveDefaultStartupWindowBounds(readCurrentVisibleViewport())'); + expect(appSource).toContain('shouldPreferWindowsStartupMaximise(bounds, viewport)'); + expect(appSource).toContain('applyWindowsWorkAreaFillFallback'); + expect(appSource).toContain('resolveWorkAreaFillWindowBounds(readCurrentVisibleViewport())'); + expect(appSource).toContain('restoreNormalWindowBounds'); expect(appSource).toContain("void fixWindowScaleIfNeeded('startup');"); expect(appSource).toContain('const startupLayoutFixTimers = [220, 1000, 1900].map((delayMs) => ('); expect(appSource).toContain('if (isStartupWindowRestorePending())'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d620201b..bcccb419 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -113,6 +113,8 @@ import { isStartupWindowRestorePending, markStartupWindowRestorePending, resolveDefaultStartupWindowBounds, + resolveWorkAreaFillWindowBounds, + shouldPreferWindowsStartupMaximise, } from './utils/windowStartupLayout'; import { SHORTCUT_ACTION_META, @@ -1106,6 +1108,26 @@ function App() { clearStartupWindowRestorePending(); }; + /** Maximise 多次失败时:把窗口铺满工作区,避免残留 1024×768 / 84% 浮动半窗。 */ + const applyWindowsWorkAreaFillFallback = () => { + if (!isWindowsPlatform()) { + return; + } + try { + const nextBounds = resolveWorkAreaFillWindowBounds(readCurrentVisibleViewport()); + WindowSetSize(nextBounds.width, nextBounds.height); + WindowSetPosition(nextBounds.x, nextBounds.y); + useStore.getState().setWindowBounds(nextBounds); + // 仍记为 maximized:视觉上已铺满,下次继续走最大化恢复 + useStore.getState().setWindowState('maximized'); + void emitWindowDiagnostic('adjust:startup-work-area-fill-fallback', { + to: nextBounds, + }); + } catch (e) { + console.warn('Failed to apply Windows work-area fill fallback', e); + } + }; + // mode: // - maximised: 始终最大化(Windows 启动偏好 / 记忆的 maximized / Windows 上的 fullscreen 记忆) // - fullscreen: 非 Windows 优先真全屏,失败再最大化 @@ -1150,17 +1172,54 @@ function App() { if (attempt < maxApplyAttempts) { applyStartupWindowChrome(attempt + 1, mode); } else { - // 最终仍失败:结束宽限,避免长期阻断 bounds 记忆;下次启动会再试 - clearStartupWindowRestorePending(); + // 最终仍失败:Windows 铺满工作区兜底,再结束宽限 void emitWindowDiagnostic('warn:startup-maximise-failed', { mode, attempts: attempt, }); + applyWindowsWorkAreaFillFallback(); + clearStartupWindowRestorePending(); } }); }, delayMs); }; + const restoreNormalWindowBounds = async (bounds: { + width: number; + height: number; + x: number; + y: number; + }) => { + // Windows 可能以原生 Maximised 首帧启动,恢复普通窗前先取消最大化 + if (isWindowsPlatform()) { + try { + if (await WindowIsMaximised()) { + WindowUnmaximise(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + } + } catch (e) { + console.warn('Failed to unmaximise before restoring normal bounds', e); + } + } + const state = useStore.getState(); + const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport()); + if ( + nextBounds.x !== bounds.x || + nextBounds.y !== bounds.y || + nextBounds.width !== bounds.width || + nextBounds.height !== bounds.height + ) { + void emitWindowDiagnostic('adjust:startup-window-bounds', { + from: bounds, + to: nextBounds, + }); + state.setWindowBounds(nextBounds); + } + WindowSetSize(nextBounds.width, nextBounds.height); + WindowSetPosition(nextBounds.x, nextBounds.y); + state.setWindowState('normal'); + }; + const restoreWindowState = async () => { if (cancelled) return; // 仅在 hydration 完成后跑一次(或显式重入);避免未水合默认态先写半窗 bounds @@ -1195,12 +1254,23 @@ function App() { return; } // 3) 普通窗口:恢复用户调整过的尺寸和位置 + // Windows:无记忆 / 历史半窗 / 84% 默认小窗 → 直接最大化,而不是再落到浮动半窗 const bounds = state.windowBounds; + const viewport = readCurrentVisibleViewport(); + if (isWindowsPlatform() && shouldPreferWindowsStartupMaximise(bounds, viewport)) { + markStartupWindowRestorePending(3200); + applyStartupWindowChrome(1, 'maximised'); + void emitWindowDiagnostic('adjust:startup-prefer-maximise', { + from: bounds, + reason: !bounds ? 'missing-bounds' : 'undersized-bounds', + }); + return; + } if (!bounds || bounds.width < 400 || bounds.height < 300) { - // 无记忆尺寸时,Windows 默认左上角小窗看起来像“只开了一半”,改为居中可用尺寸 + // 非 Windows:无记忆时保持系统默认;Windows 已在上方走最大化 if (isWindowsPlatform()) { try { - const nextBounds = resolveDefaultStartupWindowBounds(readCurrentVisibleViewport()); + const nextBounds = resolveDefaultStartupWindowBounds(viewport); WindowSetSize(nextBounds.width, nextBounds.height); WindowSetPosition(nextBounds.x, nextBounds.y); state.setWindowBounds(nextBounds); @@ -1215,22 +1285,7 @@ function App() { return; } try { - const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport()); - if ( - nextBounds.x !== bounds.x || - nextBounds.y !== bounds.y || - nextBounds.width !== bounds.width || - nextBounds.height !== bounds.height - ) { - void emitWindowDiagnostic('adjust:startup-window-bounds', { - from: bounds, - to: nextBounds, - }); - state.setWindowBounds(nextBounds); - } - WindowSetSize(nextBounds.width, nextBounds.height); - WindowSetPosition(nextBounds.x, nextBounds.y); - state.setWindowState('normal'); + await restoreNormalWindowBounds(bounds); } catch (e) { console.warn('Failed to restore window bounds', e); } diff --git a/frontend/src/utils/windowStartupLayout.test.ts b/frontend/src/utils/windowStartupLayout.test.ts index 9cb0d93f..fd23ba1e 100644 --- a/frontend/src/utils/windowStartupLayout.test.ts +++ b/frontend/src/utils/windowStartupLayout.test.ts @@ -5,6 +5,9 @@ import { isStartupWindowRestorePending, markStartupWindowRestorePending, resolveDefaultStartupWindowBounds, + resolveWorkAreaFillWindowBounds, + shouldPreferWindowsStartupMaximise, + WINDOWS_STARTUP_MAXIMISE_AREA_RATIO, } from './windowStartupLayout'; describe('windowStartupLayout', () => { @@ -69,4 +72,61 @@ describe('windowStartupLayout', () => { vi.setSystemTime(new Date('2026-07-09T10:00:01.001Z')); expect(isStartupWindowRestorePending()).toBe(false); }); + + it('fills the OS work area as a maximise-failure fallback', () => { + expect(resolveWorkAreaFillWindowBounds({ + availWidth: 1920, + availHeight: 1040, + availLeft: 0, + availTop: 0, + })).toEqual({ + width: 1920, + height: 1040, + x: 0, + y: 0, + }); + + expect(resolveWorkAreaFillWindowBounds({ + availWidth: 1600, + availHeight: 900, + availLeft: 1920, + availTop: 40, + })).toEqual({ + width: 1600, + height: 900, + x: 1920, + y: 40, + }); + }); + + it('prefers maximise for missing, legacy 1024×768, and undersized default windows', () => { + const viewport = { + availWidth: 1920, + availHeight: 1080, + availLeft: 0, + availTop: 0, + }; + + expect(shouldPreferWindowsStartupMaximise(null, viewport)).toBe(true); + expect(shouldPreferWindowsStartupMaximise({ + width: 1024, + height: 768, + x: 0, + y: 0, + }, viewport)).toBe(true); + + // 84%×84% default area ≈ 0.706 < 0.78 → 最大化 + const defaultBounds = resolveDefaultStartupWindowBounds(viewport); + expect((defaultBounds.width * defaultBounds.height) / (1920 * 1080)) + .toBeLessThan(WINDOWS_STARTUP_MAXIMISE_AREA_RATIO); + expect(shouldPreferWindowsStartupMaximise(defaultBounds, viewport)).toBe(true); + + // 用户刻意拉大的普通窗应保留 + expect(shouldPreferWindowsStartupMaximise({ + width: 1760, + height: 980, + x: 80, + y: 40, + }, viewport)).toBe(false); + }); }); diff --git a/frontend/src/utils/windowStartupLayout.ts b/frontend/src/utils/windowStartupLayout.ts index 8fdd3c3c..fd4e6bca 100644 --- a/frontend/src/utils/windowStartupLayout.ts +++ b/frontend/src/utils/windowStartupLayout.ts @@ -16,6 +16,16 @@ export type StartupWindowBounds = { const MIN_STARTUP_WIDTH = 900; const MIN_STARTUP_HEIGHT = 600; +/** + * 工作区覆盖率低于该阈值时,视为「半窗 / 默认小窗」记忆,Windows 启动改走最大化。 + * 84%×84% 居中默认窗的面积比约为 0.706,会被捕获;用户刻意拉大的普通窗通常更高。 + */ +export const WINDOWS_STARTUP_MAXIMISE_AREA_RATIO = 0.78; + +/** Align with historical main.go Width/Height defaults that look half-open on modern screens. */ +const LEGACY_DEFAULT_WIDTH = 1024; +const LEGACY_DEFAULT_HEIGHT = 768; + /** * Resolve a usable first-launch window when no persisted bounds exist. * Windows defaults to top-left 1024x768 which looks "half open" on modern screens. @@ -50,6 +60,62 @@ export const resolveDefaultStartupWindowBounds = ( }; }; +/** + * Fill the OS work area (taskbar excluded). Used when Maximise API fails on Windows + * so the shell still looks "full" instead of lingering at 1024×768 / 84% floating. + */ +export const resolveWorkAreaFillWindowBounds = ( + viewport: StartupVisibleViewport, +): StartupWindowBounds => { + const availWidth = Math.max(0, Math.trunc(Number(viewport.availWidth) || 0)); + const availHeight = Math.max(0, Math.trunc(Number(viewport.availHeight) || 0)); + const availLeft = Math.trunc(Number(viewport.availLeft) || 0); + const availTop = Math.trunc(Number(viewport.availTop) || 0); + + if (availWidth <= 0 || availHeight <= 0) { + return resolveDefaultStartupWindowBounds(viewport); + } + + return { + width: Math.max(MIN_STARTUP_WIDTH, availWidth), + height: Math.max(MIN_STARTUP_HEIGHT, availHeight), + x: availLeft, + y: availTop, + }; +}; + +/** + * Decide whether Windows cold-start should prefer maximise over restoring bounds. + * - 无记忆 / 非法尺寸 → 最大化 + * - 仍像旧默认 1024×768 → 最大化 + * - 覆盖工作区面积过低(含历史 84% 居中默认窗)→ 最大化 + */ +export const shouldPreferWindowsStartupMaximise = ( + bounds: StartupWindowBounds | null | undefined, + viewport: StartupVisibleViewport, +): boolean => { + if (!bounds) { + return true; + } + const width = Math.trunc(Number(bounds.width) || 0); + const height = Math.trunc(Number(bounds.height) || 0); + if (width < 400 || height < 300) { + return true; + } + if (width <= LEGACY_DEFAULT_WIDTH && height <= LEGACY_DEFAULT_HEIGHT) { + return true; + } + + const availWidth = Math.max(0, Math.trunc(Number(viewport.availWidth) || 0)); + const availHeight = Math.max(0, Math.trunc(Number(viewport.availHeight) || 0)); + if (availWidth <= 0 || availHeight <= 0) { + return false; + } + + const areaRatio = (width * height) / (availWidth * availHeight); + return areaRatio < WINDOWS_STARTUP_MAXIMISE_AREA_RATIO; +}; + let startupWindowRestorePendingUntil = 0; /** Mark a short grace window while startup maximise/fullscreen is still settling. */ diff --git a/main.go b/main.go index 1afc3b70..2b62d74c 100644 --- a/main.go +++ b/main.go @@ -58,14 +58,23 @@ func main() { }, true) } + // Windows 冷启动:原生先最大化,避免 main 默认小窗先闪一帧; + // 前端 hydration 后再按用户记忆(最大化 / 普通尺寸)精细恢复。 + // 其它平台仍用 Normal,由前端恢复逻辑接管。 + windowStartState := options.Normal + if strings.EqualFold(strings.TrimSpace(runtime.GOOS), "windows") { + windowStartState = options.Maximised + } + // Create application with options err := wails.Run(&options.App{ - Title: "GoNavi", - Width: 1024, - Height: 768, - MinWidth: 900, - MinHeight: 600, - Frameless: true, + Title: "GoNavi", + Width: 1440, + Height: 900, + MinWidth: 900, + MinHeight: 600, + WindowStartState: windowStartState, + Frameless: true, AssetServer: &assetserver.Options{ Assets: assets, },