🐛 fix(window): 遵循启动最大化偏好并恢复窗口尺寸

- 原生窗口统一以普通状态启动,避免关闭偏好后仍被强制最大化
- 关闭偏好时恢复持久化尺寸与位置,并阻止启动瞬态覆盖窗口数据
- Windows、Linux 与 macOS 开启偏好时统一使用窗口最大化
- 更新启动窗口文案与跨平台回归测试

Refs #703
This commit is contained in:
Syngnat
2026-07-28 10:43:06 +08:00
parent 3156d8df7f
commit c39fa3e8fe
13 changed files with 101 additions and 220 deletions

View File

@@ -146,8 +146,8 @@ import {
isStartupWindowRestorePending,
markStartupWindowRestorePending,
resolveDefaultStartupWindowBounds,
resolveStartupWindowRestoreMode,
resolveWorkAreaFillWindowBounds,
shouldPreferWindowsStartupMaximise,
} from './utils/windowStartupLayout';
import {
SHORTCUT_ACTION_META,
@@ -749,8 +749,9 @@ function App() {
const setUiScale = useStore(state => state.setUiScale);
const fontSize = useStore(state => state.fontSize);
const setFontSize = useStore(state => state.setFontSize);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
// Keep reading the legacy persisted field; its product meaning is now startup maximise.
const startupMaximised = useStore(state => state.startupFullscreen);
const setStartupMaximised = useStore(state => state.setStartupFullscreen);
const autoCheckForUpdates = useStore(state => state.autoCheckForUpdates);
const setAutoCheckForUpdates = useStore(state => state.setAutoCheckForUpdates);
const autoCheckForUpdatesIntervalMinutes = useStore(state => state.autoCheckForUpdatesIntervalMinutes);
@@ -1346,16 +1347,9 @@ function App() {
const maxApplyAttempts = 8;
const applyRetryDelayMs = 350;
const settleDelayMs = 180;
const useMaximiseForStartup = isWindowsPlatform();
const startupRestoreGraceMs = 6000;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
@@ -1366,13 +1360,9 @@ function App() {
return false;
};
const markAppliedMaximisedOrFullscreen = (mode: 'maximised' | 'fullscreen') => {
// 启动偏好成功后立刻固化 windowState避免宽限期内被写成 normal 导致下次半窗
if (mode === 'maximised' || useMaximiseForStartup) {
useStore.getState().setWindowState('maximized');
} else {
useStore.getState().setWindowState('fullscreen');
}
const markStartupMaximised = () => {
// 启动偏好成功后立刻同步实际窗口态,避免 settle 宽限期留下瞬态 normal。
useStore.getState().setWindowState('maximized');
clearStartupWindowRestorePending();
};
@@ -1386,7 +1376,7 @@ function App() {
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,
@@ -1396,11 +1386,9 @@ function App() {
}
};
// mode:
// - maximised: 始终最大化Windows 启动偏好 / 记忆的 maximized / Windows 上的 fullscreen 记忆)
// - fullscreen: 非 Windows 优先真全屏,失败再最大化
// 第 1 次立即执行delay=0避免 Windows 先闪 1024×768 半窗再最大化
const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {
// Windows、Linux 与 macOS 的启动偏好都使用普通窗口最大化,不进入系统全屏。
// 第 1 次立即执行delay=0缩短普通窗口首帧到目标窗口态的过渡。
const applyStartupWindowChrome = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
@@ -1412,37 +1400,25 @@ function App() {
void Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
markStartupMaximised();
return;
}
try {
if (mode === 'maximised') {
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} else {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} catch (e) {
console.warn("Wails Window APIs unavailable", e);
}
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
markStartupMaximised();
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowChrome(attempt + 1, mode);
applyStartupWindowChrome(attempt + 1);
} else {
// 最终仍失败Windows 铺满工作区兜底,再结束宽限
void emitWindowDiagnostic('warn:startup-maximise-failed', {
mode,
attempts: attempt,
});
applyWindowsWorkAreaFillFallback();
@@ -1458,16 +1434,17 @@ function App() {
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);
try {
if (await WindowIsFullscreen()) {
WindowUnfullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
}
if (await WindowIsMaximised()) {
WindowUnmaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
}
} catch (e) {
console.warn('Failed to restore normal window chrome', e);
}
const state = useStore.getState();
const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport());
@@ -1481,10 +1458,10 @@ function App() {
from: bounds,
to: nextBounds,
});
state.setWindowBounds(nextBounds);
}
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowBounds(nextBounds);
state.setWindowState('normal');
};
@@ -1500,62 +1477,38 @@ function App() {
restoredOnce = true;
const state = useStore.getState();
// 1) 「启动时最大化」开关优先Windows 按 Maximize 处理)
if (state.startupFullscreen) {
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
const restoreMode = resolveStartupWindowRestoreMode(
state.startupFullscreen,
);
if (restoreMode !== 'normal') {
markStartupWindowRestorePending(startupRestoreGraceMs);
applyStartupWindowChrome(1);
return;
}
// 2) 记忆用户上次窗口态:最大化/全屏
const savedState = state.windowState;
if (savedState === 'fullscreen') {
// Windows 上记忆的 fullscreen 也走最大化,避免真全屏后标题栏交互困难
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
return;
}
if (savedState === 'maximized') {
// 必须重试Windows 冷启动 HWND/WebView2 未就绪时单次 Maximise 经常失败,
// 会残留 main.go 默认 1024x768 贴左上角;任务栏恢复后才“突然正常”。
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, 'maximised');
return;
}
// 3) 普通窗口:恢复用户调整过的尺寸和位置
// Windows无记忆 / 历史半窗 / 84% 默认小窗 → 直接最大化,而不是再落到浮动半窗
// The disabled preference is strict: restore a normal window even if
// an older build persisted an automatic maximised/fullscreen state.
markStartupWindowRestorePending(startupRestoreGraceMs);
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 已在上方走最大化
if (isWindowsPlatform()) {
try {
try {
if (!bounds || bounds.width < 400 || bounds.height < 300) {
if (isWindowsPlatform()) {
const nextBounds = resolveDefaultStartupWindowBounds(viewport);
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowBounds(nextBounds);
state.setWindowState('normal');
await restoreNormalWindowBounds(nextBounds);
void emitWindowDiagnostic('adjust:startup-default-window-bounds', {
to: nextBounds,
});
} catch (e) {
console.warn('Failed to apply default Windows startup bounds', e);
} else {
state.setWindowState('normal');
}
return;
}
return;
}
try {
await restoreNormalWindowBounds(bounds);
} catch (e) {
console.warn('Failed to restore window bounds', e);
} finally {
clearStartupWindowRestorePending();
}
};
@@ -1566,7 +1519,7 @@ function App() {
if (cancelled) {
return;
}
// hydration 完成后再恢复,确保读到 startupFullscreen / windowState / windowBounds
// hydration 完成后再恢复,确保读到启动最大化偏好与 windowBounds
restoredOnce = false;
void restoreWindowState();
});
@@ -1589,7 +1542,7 @@ function App() {
let lastSaved = '';
const saveWindowState = async () => {
if (cancelled || !hydrated) {
if (cancelled || !hydrated || isStartupWindowRestorePending()) {
return;
}
try {
@@ -1598,13 +1551,9 @@ function App() {
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
// 启动最大化/全屏尚未 settle 时,禁止把状态写回 normal
// 否则下次冷启动会落到默认 1024x768 左上角Windows 首次打开“只显示一半”)。
// 启动窗口恢复尚未 settle 时,不保存中间态和中间尺寸。
if (isStartupWindowRestorePending()) {
if (!isFs && !isMax) {
return;
}
clearStartupWindowRestorePending();
return;
}
// 保存窗口状态
@@ -1625,7 +1574,7 @@ function App() {
safeWindowRuntimeCall(() => WindowGetSize(), null),
safeWindowRuntimeCall(() => WindowGetPosition(), null),
]);
if (!size || !pos) return;
if (!size || !pos || isStartupWindowRestorePending()) return;
const w = Math.trunc(Number(size.w || 0));
const h = Math.trunc(Number(size.h || 0));
const x = Math.trunc(Number(pos.x || 0));
@@ -1661,7 +1610,7 @@ function App() {
if (cancelled || !hydrated) {
return;
}
// 启动最大化 settle 期间不要抢跑普通 bounds 校正
// 启动窗口恢复期间不要抢跑普通 bounds 校正
if (isStartupWindowRestorePending()) {
return;
}
@@ -2336,8 +2285,6 @@ function App() {
}, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus, t]);
const isMacRuntime = runtimePlatform === 'darwin'
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
const isWindowsRuntime = runtimePlatform === 'windows'
|| (runtimePlatform === '' && isWindowsPlatform());
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
const activeShortcutPlatform = getShortcutPlatform(isMacRuntime);
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(
@@ -6422,14 +6369,10 @@ function App() {
{renderThemeSettingsSection(
t('app.theme.startup_window.title'),
renderThemeSettingsRow({
label: isWindowsRuntime
? t('app.theme.startup_window.fullscreen_windows')
: t('app.theme.startup_window.fullscreen'),
hint: isWindowsRuntime
? t('app.theme.startup_window.windows_hint')
: t('app.theme.startup_window.hint'),
label: t('app.theme.startup_window.maximised'),
hint: t('app.theme.startup_window.hint'),
control: (
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
<Switch checked={startupMaximised} onChange={(checked) => setStartupMaximised(checked)} />
),
}),
)}
@@ -7228,11 +7171,11 @@ function App() {
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.startup_window.title')}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span>{isWindowsRuntime ? t('app.theme.startup_window.fullscreen_windows') : t('app.theme.startup_window.fullscreen')}</span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
<span>{t('app.theme.startup_window.maximised')}</span>
<Switch checked={startupMaximised} onChange={(checked) => setStartupMaximised(checked)} />
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
{isWindowsRuntime ? t('app.theme.startup_window.windows_hint') : t('app.theme.startup_window.hint')}
{t('app.theme.startup_window.hint')}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>

View File

@@ -3229,7 +3229,7 @@ describe('store appearance persistence', () => {
expect(hydrated.useStore.getState().autoCheckForUpdatesIntervalMinutes).toBe(30);
});
it('persists window state and bounds immediately so Windows reopen keeps maximise or size memory', async () => {
it('persists window state and bounds immediately across store reloads', async () => {
const { useStore } = await importStore();
useStore.getState().setWindowState('maximized');

View File

@@ -1771,6 +1771,7 @@ interface AppState {
appearance: AppearanceSettings;
uiScale: number;
fontSize: number;
/** Legacy persisted name; true means maximise the startup window on every desktop platform. */
startupFullscreen: boolean;
/** 启动后与定时静默检查更新;默认开启 */
autoCheckForUpdates: boolean;
@@ -5338,7 +5339,7 @@ export const useStore = create<AppState>()(
setWindowState: (state) => {
const nextState = sanitizeWindowState(state);
set({ windowState: nextState });
// 最大化/普通态也要同步写盘,否则下次冷启动会落到默认 1024×768「半窗」
// 与窗口尺寸一致即时落盘,避免退出阶段丢失最后一次观测状态。
writePersistedStatePatch({ windowState: nextState });
},

View File

@@ -5,9 +5,8 @@ import {
isStartupWindowRestorePending,
markStartupWindowRestorePending,
resolveDefaultStartupWindowBounds,
resolveStartupWindowRestoreMode,
resolveWorkAreaFillWindowBounds,
shouldPreferWindowsStartupMaximise,
WINDOWS_STARTUP_MAXIMISE_AREA_RATIO,
} from './windowStartupLayout';
describe('windowStartupLayout', () => {
@@ -99,34 +98,11 @@ describe('windowStartupLayout', () => {
});
});
it('prefers maximise for missing, legacy 1024×768, and undersized default windows', () => {
const viewport = {
availWidth: 1920,
availHeight: 1080,
availLeft: 0,
availTop: 0,
};
it('keeps startup normal when the explicit fullscreen preference is disabled', () => {
expect(resolveStartupWindowRestoreMode(false)).toBe('normal');
});
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);
it('maximises the startup window on every desktop platform when enabled', () => {
expect(resolveStartupWindowRestoreMode(true)).toBe('maximised');
});
});

View File

@@ -16,20 +16,17 @@ 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;
export type StartupWindowRestoreMode = 'normal' | 'maximised';
/**
* Resolve a usable first-launch window when no persisted bounds exist.
* Windows defaults to top-left 1024x768 which looks "half open" on modern screens.
* The explicit startup preference is authoritative. A disabled preference must
* not be overridden by a previously maximised window or a size heuristic.
*/
export const resolveStartupWindowRestoreMode = (
startupMaximised: boolean,
): StartupWindowRestoreMode => startupMaximised ? 'maximised' : 'normal';
/** Resolve a centered normal window when no persisted bounds exist. */
export const resolveDefaultStartupWindowBounds = (
viewport: StartupVisibleViewport,
): StartupWindowBounds => {
@@ -62,7 +59,7 @@ 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.
* so the shell still looks full instead of lingering in a normal window.
*/
export const resolveWorkAreaFillWindowBounds = (
viewport: StartupVisibleViewport,
@@ -84,41 +81,9 @@ export const resolveWorkAreaFillWindowBounds = (
};
};
/**
* 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. */
/** Mark a short grace window while startup window restoration is still settling. */
export const markStartupWindowRestorePending = (durationMs = 2800): void => {
const duration = Math.max(0, Math.trunc(Number(durationMs) || 0));
startupWindowRestorePendingUntil = Date.now() + duration;