🐛 fix(window): 修复 Windows 启动最大化显示不全

- 校验原生窗口状态与 WebView2 surface 覆盖率
- 在窗口线程刷新 WebView2 bounds,并为异步窗口命令增加状态屏障
- 完善工作区兜底和多显示器坐标转换
- 补充启动最大化、状态轮询及跨平台回归测试

Fixes #824
This commit is contained in:
Syngnat
2026-08-03 23:26:15 +08:00
parent 1403393178
commit f2e773efc5
17 changed files with 657 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveWailsWindowVisibleViewport } from './wailsWindowViewport';
import { resolveWailsWindowSetPosition, resolveWailsWindowVisibleViewport } from './wailsWindowViewport';
describe('wailsWindowViewport', () => {
it('keeps browser work-area offsets for platforms that use absolute screen coordinates', () => {
@@ -40,4 +40,20 @@ describe('wailsWindowViewport', () => {
availTop: 0,
});
});
it('converts negative secondary-monitor coordinates to Wails monitor-local input', () => {
expect(resolveWailsWindowSetPosition(
{ x: -1600, y: 80 },
{ availWidth: 1728, availHeight: 1040, availLeft: -1728, availTop: 40 },
{ useMonitorLocalOrigin: true },
)).toEqual({ x: 128, y: 40 });
});
it('maps an offset work-area origin to local zero without double-applying it', () => {
expect(resolveWailsWindowSetPosition(
{ x: 1920, y: 40 },
{ availWidth: 1600, availHeight: 900, availLeft: 1920, availTop: 40 },
{ useMonitorLocalOrigin: true },
)).toEqual({ x: 0, y: 0 });
});
});

View File

@@ -17,6 +17,11 @@ type ViewportFallback = {
innerHeight?: number;
};
type WindowPositionLike = {
x?: number;
y?: number;
};
const toFiniteInteger = (value: unknown, fallback = 0): number => {
const next = Math.trunc(Number(value));
return Number.isFinite(next) ? next : fallback;
@@ -50,3 +55,24 @@ export const resolveWailsWindowVisibleViewport = (
availTop: useMonitorLocalOrigin ? 0 : toFiniteInteger(screenLike?.availTop),
};
};
/**
* Convert global screen coordinates to the monitor-local coordinates expected by
* Wails WindowSetPosition on Windows. Callers that use global coordinates can keep
* comparing WindowGetPosition against the original target.
*/
export const resolveWailsWindowSetPosition = (
position: WindowPositionLike,
viewport: WailsWindowVisibleViewport,
options?: { useMonitorLocalOrigin?: boolean },
): { x: number; y: number } => {
const x = toFiniteInteger(position.x);
const y = toFiniteInteger(position.y);
if (options?.useMonitorLocalOrigin !== true) {
return { x, y };
}
return {
x: x - toFiniteInteger(viewport.availLeft),
y: y - toFiniteInteger(viewport.availTop),
};
};

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearStartupWindowRestorePending,
isStartupMaximisedWindowSettled,
isStartupWindowRestorePending,
markStartupWindowRestorePending,
resolveDefaultStartupWindowBounds,
@@ -105,4 +106,56 @@ describe('windowStartupLayout', () => {
it('maximises the startup window on every desktop platform when enabled', () => {
expect(resolveStartupWindowRestoreMode(true)).toBe('maximised');
});
it('does not accept a stale Windows WebView surface as a settled maximised window', () => {
expect(isStartupMaximisedWindowSettled({
isMaximised: true,
isWindows: true,
surfaceWidth: 1432,
surfaceHeight: 892,
viewport: {
availWidth: 1920,
availHeight: 1050,
},
})).toBe(false);
});
it('accepts a Windows WebView surface that covers the maximised work area', () => {
expect(isStartupMaximisedWindowSettled({
isMaximised: true,
isWindows: true,
surfaceWidth: 1912,
surfaceHeight: 1042,
viewport: {
availWidth: 1920,
availHeight: 1050,
},
})).toBe(true);
});
it('keeps state-only maximise detection on non-Windows platforms', () => {
expect(isStartupMaximisedWindowSettled({
isMaximised: true,
isWindows: false,
surfaceWidth: 800,
surfaceHeight: 600,
viewport: {
availWidth: 1920,
availHeight: 1050,
},
})).toBe(true);
});
it('never settles when the native window is not maximised', () => {
expect(isStartupMaximisedWindowSettled({
isMaximised: false,
isWindows: true,
surfaceWidth: 1920,
surfaceHeight: 1050,
viewport: {
availWidth: 1920,
availHeight: 1050,
},
})).toBe(false);
});
});

View File

@@ -18,6 +18,19 @@ const MIN_STARTUP_HEIGHT = 600;
export type StartupWindowRestoreMode = 'normal' | 'maximised';
export type StartupWindowSurfaceSnapshot = {
surfaceWidth: number;
surfaceHeight: number;
viewport: StartupVisibleViewport;
};
export type StartupMaximisedWindowSnapshot = StartupWindowSurfaceSnapshot & {
isMaximised: boolean;
isWindows: boolean;
};
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.
@@ -26,6 +39,40 @@ export const resolveStartupWindowRestoreMode = (
startupMaximised: boolean,
): StartupWindowRestoreMode => startupMaximised ? 'maximised' : 'normal';
/**
* Determine whether the native maximised state has also reached the WebView surface.
* Windows can expose WS_MAXIMIZE before WebView2 updates its controller bounds, so
* state alone is not enough there. Other platforms retain the state-only contract.
*/
export const isStartupMaximisedWindowSettled = (
snapshot: StartupMaximisedWindowSnapshot,
): boolean => {
if (!snapshot.isMaximised) {
return false;
}
if (!snapshot.isWindows) {
return true;
}
return isStartupWindowSurfaceCoveringViewport(snapshot);
};
export const isStartupWindowSurfaceCoveringViewport = (
snapshot: StartupWindowSurfaceSnapshot,
): boolean => {
const availWidth = Math.max(0, Math.trunc(Number(snapshot.viewport.availWidth) || 0));
const availHeight = Math.max(0, Math.trunc(Number(snapshot.viewport.availHeight) || 0));
if (availWidth <= 0 || availHeight <= 0) {
return true;
}
const surfaceWidth = Math.max(0, Math.trunc(Number(snapshot.surfaceWidth) || 0));
const surfaceHeight = Math.max(0, Math.trunc(Number(snapshot.surfaceHeight) || 0));
return surfaceWidth >= Math.trunc(availWidth * MIN_MAXIMISED_SURFACE_COVERAGE)
&& surfaceHeight >= Math.trunc(availHeight * MIN_MAXIMISED_SURFACE_COVERAGE);
};
/** Resolve a centered normal window when no persisted bounds exist. */
export const resolveDefaultStartupWindowBounds = (
viewport: StartupVisibleViewport,

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { waitForWindowCondition } from './windowTransition';
describe('windowTransition', () => {
it('waits until a fire-and-forget native transition becomes observable', async () => {
const read = vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const wait = vi.fn().mockResolvedValue(undefined);
await expect(waitForWindowCondition({ read, wait, maxChecks: 4, intervalMs: 25 })).resolves.toBe(true);
expect(read).toHaveBeenCalledTimes(3);
expect(wait).toHaveBeenCalledTimes(2);
expect(wait).toHaveBeenNthCalledWith(1, 25);
});
it('does not treat an unobserved transition as complete', async () => {
const read = vi.fn().mockResolvedValue(false);
const wait = vi.fn().mockResolvedValue(undefined);
await expect(waitForWindowCondition({ read, wait, maxChecks: 3 })).resolves.toBe(false);
expect(read).toHaveBeenCalledTimes(3);
});
it('stops polling when the startup restoration task is cancelled', async () => {
let cancelled = false;
const read = vi.fn().mockResolvedValue(false);
const wait = vi.fn().mockImplementation(async () => {
cancelled = true;
});
await expect(waitForWindowCondition({
read,
wait,
isCancelled: () => cancelled,
maxChecks: 5,
})).resolves.toBe(false);
expect(read).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,36 @@
export type WindowConditionPollOptions = {
read: () => boolean | Promise<boolean>;
wait: (delayMs: number) => Promise<void>;
isCancelled?: () => boolean;
maxChecks?: number;
intervalMs?: number;
};
/** Poll a fire-and-forget native window transition until its observable state settles. */
export const waitForWindowCondition = async ({
read,
wait,
isCancelled = () => false,
maxChecks = 16,
intervalMs = 40,
}: WindowConditionPollOptions): Promise<boolean> => {
const checks = Math.max(1, Math.trunc(Number(maxChecks) || 0));
const delayMs = Math.max(0, Math.trunc(Number(intervalMs) || 0));
for (let check = 0; check < checks; check += 1) {
if (isCancelled()) {
return false;
}
try {
if (await read()) {
return true;
}
} catch (_) {
// A transient runtime read must not turn a fire-and-forget command into success.
}
if (check + 1 < checks) {
await wait(delayMs);
}
}
return false;
};