mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
🐛 fix(window): 加固 Windows 冷启动半窗,原生最大化并兜底铺满工作区
- Windows 启动 WindowStartState 改为 Maximised,默认尺寸提升到 1440×900 - 无记忆/历史 1024×768/覆盖率过低的普通窗记忆改为启动最大化 - Maximise 多次失败时铺满工作区兜底,避免残留浮动半窗 - 用户刻意拉大的普通窗仍先 Unmaximise 再恢复尺寸位置
This commit is contained in:
@@ -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())');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
21
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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user