mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 18:14:24 +08:00
🐛 fix(window): 修复 Windows 启动最大化显示不全
- 校验原生窗口状态与 WebView2 surface 覆盖率 - 在窗口线程刷新 WebView2 bounds,并为异步窗口命令增加状态屏障 - 完善工作区兜底和多显示器坐标转换 - 补充启动最大化、状态轮询及跨平台回归测试 Fixes #824
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
frontend/src/utils/windowTransition.test.ts
Normal file
42
frontend/src/utils/windowTransition.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
frontend/src/utils/windowTransition.ts
Normal file
36
frontend/src/utils/windowTransition.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user