🐛 fix(window): 加固 Windows 启动最大化与窗口尺寸记忆落盘

- windowState/windowBounds 与启动偏好一致,立即写入本地持久化
- 启动最大化首次立即执行并加重试,成功后固化 maximized 状态
- 仅在 hydration 完成后恢复,避免默认 1024×768 半窗被误记
- 补充 store 与启动恢复相关测试断言
This commit is contained in:
Syngnat
2026-07-09 12:35:12 +08:00
parent dec9b8317c
commit 566339f09f
4 changed files with 86 additions and 24 deletions

View File

@@ -388,13 +388,19 @@ describe('tool center menu entries', () => {
it('settles Windows cold-start window layout without requiring a taskbar restore', () => {
expect(appSource).toContain("const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {");
expect(appSource).toContain("markStartupWindowRestorePending();");
expect(appSource).toContain('markStartupWindowRestorePending(3200)');
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("void fixWindowScaleIfNeeded('startup');");
expect(appSource).toContain('const startupLayoutFixTimers = [220, 1000, 1900].map((delayMs) => (');
expect(appSource).toContain('if (isStartupWindowRestorePending())');
expect(appSource).toContain('clearStartupWindowRestorePending();');
// 启动恢复顺序:开关最大化 → 记忆最大化 → 记忆尺寸
expect(appSource).toContain('// 1) 「启动时最大化」开关优先Windows 按 Maximize 处理)');
expect(appSource).toContain('// 2) 记忆用户上次窗口态:最大化/全屏');
expect(appSource).toContain('// 3) 普通窗口:恢复用户调整过的尺寸和位置');
});
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {

View File

@@ -1041,9 +1041,10 @@ function App() {
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
let restoredOnce = false;
const maxApplyAttempts = 8;
const applyRetryDelayMs = 350;
const settleDelayMs = 180;
const useMaximiseForStartup = isWindowsPlatform();
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
@@ -1064,13 +1065,25 @@ function App() {
return false;
};
const markAppliedMaximisedOrFullscreen = (mode: 'maximised' | 'fullscreen') => {
// 启动偏好成功后立刻固化 windowState避免宽限期内被写成 normal 导致下次半窗
if (mode === 'maximised' || useMaximiseForStartup) {
useStore.getState().setWindowState('maximized');
} else {
useStore.getState().setWindowState('fullscreen');
}
clearStartupWindowRestorePending();
};
// mode:
// - maximised: 始终最大化Windows 启动偏好 / 记忆的 maximized / Windows 上的 fullscreen 记忆)
// - fullscreen: 非 Windows 优先真全屏,失败再最大化
// 第 1 次立即执行delay=0避免 Windows 先闪 1024×768 半窗再最大化
const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
const delayMs = attempt <= 1 ? 0 : applyRetryDelayMs;
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
@@ -1078,7 +1091,7 @@ function App() {
void Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
clearStartupWindowRestorePending();
markAppliedMaximisedOrFullscreen(mode);
return;
}
try {
@@ -1089,7 +1102,7 @@ function App() {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
clearStartupWindowRestorePending();
markAppliedMaximisedOrFullscreen(mode);
return;
}
await WindowMaximise();
@@ -1100,41 +1113,57 @@ function App() {
}
if (await checkStartupPreferenceApplied()) {
clearStartupWindowRestorePending();
markAppliedMaximisedOrFullscreen(mode);
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowChrome(attempt + 1, mode);
} else {
// 最终仍失败:结束宽限,避免长期阻断 bounds 记忆;下次启动会再试
clearStartupWindowRestorePending();
void emitWindowDiagnostic('warn:startup-maximise-failed', {
mode,
attempts: attempt,
});
}
});
}, applyRetryDelayMs);
}, delayMs);
};
const restoreWindowState = async () => {
if (cancelled) return;
// 仅在 hydration 完成后跑一次(或显式重入);避免未水合默认态先写半窗 bounds
if (!useStore.persist.hasHydrated()) {
return;
}
if (restoredOnce) {
return;
}
restoredOnce = true;
const state = useStore.getState();
// startupFullscreen 设置优先
// 1) 「启动时最大化」开关优先Windows 按 Maximize 处理)
if (state.startupFullscreen) {
markStartupWindowRestorePending();
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
return;
}
// 根据上次保存的窗口状态恢复
// 2) 记忆用户上次窗口态:最大化/全屏
const savedState = state.windowState;
if (savedState === 'fullscreen') {
// Windows 上记忆的 fullscreen 也走最大化,避免真全屏后标题栏交互困难
markStartupWindowRestorePending();
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
return;
}
if (savedState === 'maximized') {
// 必须重试Windows 冷启动 HWND/WebView2 未就绪时单次 Maximise 经常失败,
// 会残留 main.go 默认 1024x768 贴左上角;任务栏恢复后才“突然正常”。
markStartupWindowRestorePending();
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, 'maximised');
return;
}
// 普通窗口:恢复尺寸和位置
// 3) 普通窗口:恢复用户调整过的尺寸和位置
const bounds = state.windowBounds;
if (!bounds || bounds.width < 400 || bounds.height < 300) {
// 无记忆尺寸时Windows 默认左上角小窗看起来像“只开了一半”,改为居中可用尺寸
@@ -1144,6 +1173,7 @@ function App() {
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowBounds(nextBounds);
state.setWindowState('normal');
void emitWindowDiagnostic('adjust:startup-default-window-bounds', {
to: nextBounds,
});
@@ -1169,6 +1199,7 @@ function App() {
}
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowState('normal');
} catch (e) {
console.warn('Failed to restore window bounds', e);
}
@@ -1181,6 +1212,8 @@ function App() {
if (cancelled) {
return;
}
// hydration 完成后再恢复,确保读到 startupFullscreen / windowState / windowBounds
restoredOnce = false;
void restoreWindowState();
});

View File

@@ -2053,6 +2053,22 @@ describe('store appearance persistence', () => {
expect(reloaded.useStore.getState().startupFullscreen).toBe(true);
});
it('persists window state and bounds immediately so Windows reopen keeps maximise or size memory', async () => {
const { useStore } = await importStore();
useStore.getState().setWindowState('maximized');
useStore.getState().setWindowBounds({ width: 1400, height: 900, x: 80, y: 40 });
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.windowState).toBe('maximized');
expect(persisted.state.windowBounds).toEqual({ width: 1400, height: 900, x: 80, y: 40 });
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().windowState).toBe('maximized');
expect(reloaded.useStore.getState().windowBounds).toEqual({ width: 1400, height: 900, x: 80, y: 40 });
});
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -4044,17 +4044,24 @@ export const useStore = create<AppState>()(
setEnableHiddenColumnMemory: (enabled) =>
set({ enableHiddenColumnMemory: !!enabled }),
setWindowBounds: (bounds) =>
set({
windowBounds: {
width: Math.max(400, Math.trunc(bounds.width)),
height: Math.max(300, Math.trunc(bounds.height)),
x: Math.trunc(bounds.x),
y: Math.trunc(bounds.y),
},
}),
setWindowBounds: (bounds) => {
const nextBounds = {
width: Math.max(400, Math.trunc(bounds.width)),
height: Math.max(300, Math.trunc(bounds.height)),
x: Math.trunc(bounds.x),
y: Math.trunc(bounds.y),
};
set({ windowBounds: nextBounds });
// 与 startupFullscreen 一致:立即落盘,避免 Windows 退出时异步 persist 丢尺寸记忆
writePersistedStatePatch({ windowBounds: nextBounds });
},
setWindowState: (state) => set({ windowState: state }),
setWindowState: (state) => {
const nextState = sanitizeWindowState(state);
set({ windowState: nextState });
// 最大化/普通态也要同步写盘,否则下次冷启动会落到默认 1024×768「半窗」
writePersistedStatePatch({ windowState: nextState });
},
setSidebarWidth: (width) =>
set({ sidebarWidth: sanitizeSidebarWidth(width) }),