🐛 fix(window): 加固 Windows 冷启动半窗,原生最大化并兜底铺满工作区

- Windows 启动 WindowStartState 改为 Maximised,默认尺寸提升到 1440×900
- 无记忆/历史 1024×768/覆盖率过低的普通窗记忆改为启动最大化
- Maximise 多次失败时铺满工作区兜底,避免残留浮动半窗
- 用户刻意拉大的普通窗仍先 Unmaximise 再恢复尺寸位置
This commit is contained in:
Syngnat
2026-07-09 14:02:53 +08:00
parent 901b436107
commit d210b3f39c
5 changed files with 220 additions and 27 deletions

View File

@@ -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())');

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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. */

21
main.go
View File

@@ -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,
},