🐛 fix(window): 修复窗口尺寸与最大化状态未记忆

- 启动时恢复上次普通窗口尺寸与最大化状态
- 在标题栏最大化和退出前采样最新原生窗口状态
- 最大化前保留普通窗口恢复边界
- 补充启动恢复与退出持久化回归测试
This commit is contained in:
Syngnat
2026-08-11 11:33:33 +08:00
parent cafabd124a
commit d690b83d55
6 changed files with 150 additions and 28 deletions

View File

@@ -13,6 +13,41 @@ const appCss = readFileSync(
describe('settings center tool entries', () => {
it('captures native window bounds before maximising and before the final quit flush', () => {
const startupRestoreStart = appSource.indexOf('const restoreWindowState = async');
const startupRestoreEnd = appSource.indexOf('if (useStore.persist.hasHydrated())', startupRestoreStart);
const startupRestoreSource = appSource.slice(startupRestoreStart, startupRestoreEnd);
const restoreNormalBoundsBeforeMaximise = startupRestoreSource.indexOf('applyRestoredWindowBounds(bounds);');
const startupMaximiseCall = startupRestoreSource.indexOf('applyStartupWindowChrome(1);');
expect(startupRestoreStart).toBeGreaterThanOrEqual(0);
expect(startupRestoreEnd).toBeGreaterThan(startupRestoreStart);
expect(restoreNormalBoundsBeforeMaximise).toBeGreaterThanOrEqual(0);
expect(startupMaximiseCall).toBeGreaterThan(restoreNormalBoundsBeforeMaximise);
const titleBarToggleStart = appSource.indexOf('const handleTitleBarWindowToggle = async');
const titleBarToggleEnd = appSource.indexOf('const handleTitleBarDoubleClick =', titleBarToggleStart);
const titleBarToggleSource = appSource.slice(titleBarToggleStart, titleBarToggleEnd);
const captureBeforeMaximise = titleBarToggleSource.indexOf('await captureMainWindowStateRef.current();');
const maximiseCall = titleBarToggleSource.indexOf('WindowMaximise();', captureBeforeMaximise);
expect(titleBarToggleStart).toBeGreaterThanOrEqual(0);
expect(titleBarToggleEnd).toBeGreaterThan(titleBarToggleStart);
expect(captureBeforeMaximise).toBeGreaterThanOrEqual(0);
expect(maximiseCall).toBeGreaterThan(captureBeforeMaximise);
const confirmedActionStart = appSource.indexOf('const runConfirmedAction = async');
const confirmedActionEnd = appSource.indexOf('if (confirmedAction)', confirmedActionStart);
const confirmedActionSource = appSource.slice(confirmedActionStart, confirmedActionEnd);
const captureOnQuit = confirmedActionSource.indexOf('captureWindowState:');
const flushOnQuit = confirmedActionSource.indexOf('flushAppState:');
expect(confirmedActionStart).toBeGreaterThanOrEqual(0);
expect(confirmedActionEnd).toBeGreaterThan(confirmedActionStart);
expect(captureOnQuit).toBeGreaterThanOrEqual(0);
expect(flushOnQuit).toBeGreaterThan(captureOnQuit);
});
it('keeps the resize minimise probe independent from DPR debounce and clears it on unmount', () => {
const scaleEffectStart = appSource.indexOf('let minimisedCheckTimer: number | null = null;');
const dprScheduleStart = appSource.indexOf('const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {', scaleEffectStart);

View File

@@ -228,6 +228,7 @@ import {
collectApplicationQuitUnsavedSQLTargets,
saveApplicationQuitUnsavedSQLTargets,
} from './utils/sqlEditorApplicationQuit';
import { prepareApplicationQuitPersistence } from './utils/applicationQuitPersistence';
import { flushQueryTabDraftSnapshots } from './utils/sqlFileTabDrafts';
import {
APP_APPLICATION_QUIT_MODAL_Z_INDEX,
@@ -1155,6 +1156,7 @@ function App() {
const windowDiagSequenceRef = React.useRef(0);
const windowDiagLastSignatureRef = React.useRef('');
const windowDiagLastAtRef = React.useRef(0);
const captureMainWindowStateRef = React.useRef<() => Promise<void>>(async () => undefined);
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
const securityUpdateStatusMeta = useMemo(
() => getSecurityUpdateStatusMeta(securityUpdateStatus, t),
@@ -1584,24 +1586,12 @@ function App() {
}, delayMs);
};
const restoreNormalWindowBounds = async (bounds: {
const applyRestoredWindowBounds = (bounds: {
width: number;
height: number;
x: number;
y: number;
}) => {
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 viewport = readCurrentVisibleViewport();
const nextBounds = resolveVisibleStartupWindowBounds(bounds, viewport);
@@ -1622,7 +1612,28 @@ function App() {
});
WindowSetPosition(setPosition.x, setPosition.y);
state.setWindowBounds(nextBounds);
state.setWindowState('normal');
};
const restoreNormalWindowBounds = async (bounds: {
width: number;
height: number;
x: number;
y: number;
}) => {
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);
}
applyRestoredWindowBounds(bounds);
useStore.getState().setWindowState('normal');
};
const restoreWindowState = async () => {
@@ -1637,19 +1648,28 @@ function App() {
restoredOnce = true;
const state = useStore.getState();
const bounds = state.windowBounds;
const restoreMode = resolveStartupWindowRestoreMode(
state.startupFullscreen,
state.windowState,
);
if (restoreMode !== 'normal') {
if (bounds && bounds.width >= 400 && bounds.height >= 300) {
try {
// Seed the OS restore rectangle before maximising so a later
// unmaximise returns to the user's last normal window bounds.
applyRestoredWindowBounds(bounds);
} catch (e) {
console.warn('Failed to prepare remembered normal window bounds', e);
}
}
markStartupWindowRestorePending(startupRestoreGraceMs);
applyStartupWindowChrome(1);
return;
}
// The disabled preference is strict: restore a normal window even if
// an older build persisted an automatic maximised/fullscreen state.
// Without a remembered maximised state, restore the last normal bounds.
markStartupWindowRestorePending(startupRestoreGraceMs);
const bounds = state.windowBounds;
const viewport = readCurrentVisibleViewport();
try {
if (!bounds || bounds.width < 400 || bounds.height < 300) {
@@ -1752,6 +1772,7 @@ function App() {
// 静默忽略
}
};
captureMainWindowStateRef.current = saveWindowState;
const scheduleWindowStateSave = (delayMs = 120) => {
if (cancelled || !hydrated) {
@@ -1885,6 +1906,9 @@ function App() {
});
return () => {
cancelled = true;
if (captureMainWindowStateRef.current === saveWindowState) {
captureMainWindowStateRef.current = async () => undefined;
}
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
@@ -2792,8 +2816,11 @@ function App() {
const runConfirmedAction = async (): Promise<boolean> => {
let accepted = false;
try {
flushQueryTabDraftSnapshots();
await flushAppStatePersistence();
await prepareApplicationQuitPersistence({
captureWindowState: () => captureMainWindowStateRef.current(),
flushDrafts: flushQueryTabDraftSnapshots,
flushAppState: flushAppStatePersistence,
});
if (confirmedAction) {
accepted = await confirmedAction();
} else {
@@ -4421,9 +4448,17 @@ function App() {
if (isMaximised) {
WindowUnmaximise();
} else {
// Preserve the latest normal bounds before the native maximise transition
// makes WindowGetSize report the maximised surface.
await captureMainWindowStateRef.current();
WindowMaximise();
}
await new Promise((resolve) => window.setTimeout(resolve, 96));
await waitForWindowCondition({
read: async () => (await WindowIsMaximised()) !== isMaximised,
wait: (delayMs) => new Promise((resolve) => window.setTimeout(resolve, delayMs)),
maxChecks: 16,
intervalMs: 40,
});
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-set-maximise-state');
} catch (_) {

View File

@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import { prepareApplicationQuitPersistence } from './applicationQuitPersistence';
describe('prepareApplicationQuitPersistence', () => {
it('captures the latest native window before flushing persisted app state', async () => {
const calls: string[] = [];
const captureWindowState = vi.fn(async () => {
calls.push('capture-window');
});
const flushDrafts = vi.fn(() => {
calls.push('flush-drafts');
});
const flushAppState = vi.fn(async () => {
calls.push('flush-app-state');
});
await prepareApplicationQuitPersistence({
captureWindowState,
flushDrafts,
flushAppState,
});
expect(calls).toEqual([
'capture-window',
'flush-drafts',
'flush-app-state',
]);
});
});

View File

@@ -0,0 +1,15 @@
export type ApplicationQuitPersistenceTasks = {
captureWindowState: () => Promise<void>;
flushDrafts: () => void;
flushAppState: () => Promise<void>;
};
export const prepareApplicationQuitPersistence = async ({
captureWindowState,
flushDrafts,
flushAppState,
}: ApplicationQuitPersistenceTasks): Promise<void> => {
await captureWindowState();
flushDrafts();
await flushAppState();
};

View File

@@ -99,12 +99,16 @@ describe('windowStartupLayout', () => {
});
});
it('keeps startup normal when the explicit fullscreen preference is disabled', () => {
expect(resolveStartupWindowRestoreMode(false)).toBe('normal');
it('restores the last normal window when startup maximise is disabled', () => {
expect(resolveStartupWindowRestoreMode(false, 'normal')).toBe('normal');
});
it('restores a user-maximised window when startup maximise is disabled', () => {
expect(resolveStartupWindowRestoreMode(false, 'maximized')).toBe('maximised');
});
it('maximises the startup window on every desktop platform when enabled', () => {
expect(resolveStartupWindowRestoreMode(true)).toBe('maximised');
expect(resolveStartupWindowRestoreMode(true, 'normal')).toBe('maximised');
});
it('does not accept a stale Windows WebView surface as a settled maximised window', () => {

View File

@@ -17,6 +17,7 @@ const MIN_STARTUP_WIDTH = 900;
const MIN_STARTUP_HEIGHT = 600;
export type StartupWindowRestoreMode = 'normal' | 'maximised';
export type PersistedMainWindowState = 'normal' | 'maximized' | 'fullscreen';
export type StartupWindowSurfaceSnapshot = {
surfaceWidth: number;
@@ -31,13 +32,15 @@ export type StartupMaximisedWindowSnapshot = StartupWindowSurfaceSnapshot & {
const MIN_MAXIMISED_SURFACE_COVERAGE = 0.95;
/**
* The explicit startup preference is authoritative. A disabled preference must
* not be overridden by a previously maximised window or a size heuristic.
*/
/** Force maximise when requested; otherwise restore the last observed window state. */
export const resolveStartupWindowRestoreMode = (
startupMaximised: boolean,
): StartupWindowRestoreMode => startupMaximised ? 'maximised' : 'normal';
persistedWindowState: PersistedMainWindowState = 'normal',
): StartupWindowRestoreMode => (
startupMaximised || persistedWindowState === 'maximized' || persistedWindowState === 'fullscreen'
? 'maximised'
: 'normal'
);
/**
* Determine whether the native maximised state has also reached the WebView surface.