mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 07:03:34 +08:00
🐛 fix(window): 修复 Windows 启动最大化显示不全
- 校验原生窗口状态与 WebView2 surface 覆盖率 - 在窗口线程刷新 WebView2 bounds,并为异步窗口命令增加状态屏障 - 完善工作区兜底和多显示器坐标转换 - 补充启动最大化、状态轮询及跨平台回归测试 Fixes #824
This commit is contained in:
@@ -149,6 +149,8 @@ import {
|
||||
import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix';
|
||||
import {
|
||||
clearStartupWindowRestorePending,
|
||||
isStartupMaximisedWindowSettled,
|
||||
isStartupWindowSurfaceCoveringViewport,
|
||||
isStartupWindowRestorePending,
|
||||
markStartupWindowRestorePending,
|
||||
resolveDefaultStartupWindowBounds,
|
||||
@@ -197,7 +199,7 @@ import {
|
||||
type WindowsScaleCheckTrigger,
|
||||
} from './utils/windowStateUi';
|
||||
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
|
||||
import { resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport';
|
||||
import { resolveWailsWindowSetPosition, resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
@@ -207,6 +209,7 @@ import {
|
||||
} from './utils/aiEntryLayout';
|
||||
import { DEFAULT_AI_PANEL_WIDTH, resolveOverlayAIPanelWidth, shouldOverlayAIPanel } from './utils/aiPanelLayout';
|
||||
import { safeWindowRuntimeCall } from './utils/wailsRuntime';
|
||||
import { waitForWindowCondition } from './utils/windowTransition';
|
||||
import {
|
||||
hasNativeDetachedWindowManager,
|
||||
openNativeAIChatWindow,
|
||||
@@ -1356,41 +1359,164 @@ function App() {
|
||||
const applyRetryDelayMs = 350;
|
||||
const settleDelayMs = 180;
|
||||
const startupRestoreGraceMs = 6000;
|
||||
let refreshWebViewBoundsUnavailableLogged = false;
|
||||
let refreshWebViewBoundsDisabled = false;
|
||||
const wait = (delayMs: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
|
||||
|
||||
const waitForMaximisedState = (expected: boolean): Promise<boolean> => waitForWindowCondition({
|
||||
read: async () => (await WindowIsMaximised()) === expected,
|
||||
wait,
|
||||
isCancelled: () => cancelled,
|
||||
maxChecks: 16,
|
||||
intervalMs: 40,
|
||||
});
|
||||
|
||||
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (await WindowIsMaximised()) {
|
||||
return true;
|
||||
}
|
||||
const isMaximised = await WindowIsMaximised();
|
||||
return isStartupMaximisedWindowSettled({
|
||||
isMaximised,
|
||||
isWindows: isWindowsPlatform(),
|
||||
surfaceWidth: window.innerWidth,
|
||||
surfaceHeight: window.innerHeight,
|
||||
viewport: readCurrentVisibleViewport(),
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryRefreshStartupWebViewBounds = async (): Promise<boolean> => {
|
||||
if (
|
||||
!isWindowsPlatform()
|
||||
|| refreshWebViewBoundsDisabled
|
||||
|| (window as any).__GONAVI_WEB_RUNTIME__?.buildType === 'web'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.RefreshWebViewBounds !== 'function') {
|
||||
refreshWebViewBoundsDisabled = true;
|
||||
if (!refreshWebViewBoundsUnavailableLogged) {
|
||||
refreshWebViewBoundsUnavailableLogged = true;
|
||||
console.warn('RefreshWebViewBounds backend is unavailable during startup maximise');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const result = await backendApp.RefreshWebViewBounds();
|
||||
if (result?.success) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
return true;
|
||||
}
|
||||
refreshWebViewBoundsDisabled = true;
|
||||
if (!refreshWebViewBoundsUnavailableLogged) {
|
||||
refreshWebViewBoundsUnavailableLogged = true;
|
||||
console.warn('RefreshWebViewBounds failed during startup maximise:', result?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
refreshWebViewBoundsDisabled = true;
|
||||
if (!refreshWebViewBoundsUnavailableLogged) {
|
||||
refreshWebViewBoundsUnavailableLogged = true;
|
||||
console.warn('RefreshWebViewBounds call failed during startup maximise', error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const waitForStartupPreferenceApplied = (): Promise<boolean> => waitForWindowCondition({
|
||||
read: checkStartupPreferenceApplied,
|
||||
wait,
|
||||
isCancelled: () => cancelled,
|
||||
maxChecks: 10,
|
||||
intervalMs: 40,
|
||||
});
|
||||
|
||||
const repairStartupMaximisedSurface = async (): Promise<boolean> => {
|
||||
if (!isWindowsPlatform()) {
|
||||
return false;
|
||||
}
|
||||
markStartupWindowRestorePending(startupRestoreGraceMs);
|
||||
WindowUnmaximise();
|
||||
if (!await waitForMaximisedState(false)) {
|
||||
return false;
|
||||
}
|
||||
WindowMaximise();
|
||||
if (!await waitForMaximisedState(true)) {
|
||||
return false;
|
||||
}
|
||||
await tryRefreshStartupWebViewBounds();
|
||||
return waitForStartupPreferenceApplied();
|
||||
};
|
||||
|
||||
const markStartupMaximised = () => {
|
||||
// 启动偏好成功后立刻同步实际窗口态,避免 settle 宽限期留下瞬态 normal。
|
||||
useStore.getState().setWindowState('maximized');
|
||||
clearStartupWindowRestorePending();
|
||||
};
|
||||
|
||||
/** Maximise 多次失败时:把窗口铺满工作区,避免残留 1024×768 / 84% 浮动半窗。 */
|
||||
const applyWindowsWorkAreaFillFallback = () => {
|
||||
/** Maximise 多次失败时:退回普通窗口并铺满工作区,避免残留默认半窗。 */
|
||||
const applyWindowsWorkAreaFillFallback = async (): Promise<boolean> => {
|
||||
if (!isWindowsPlatform()) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const nextBounds = resolveWorkAreaFillWindowBounds(readCurrentVisibleViewport());
|
||||
markStartupWindowRestorePending(startupRestoreGraceMs);
|
||||
if (await WindowIsMaximised()) {
|
||||
WindowUnmaximise();
|
||||
if (!await waitForMaximisedState(false)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const viewport = readCurrentVisibleViewport();
|
||||
const nextBounds = resolveWorkAreaFillWindowBounds(viewport);
|
||||
const setPosition = resolveWailsWindowSetPosition(nextBounds, viewport, {
|
||||
useMonitorLocalOrigin: true,
|
||||
});
|
||||
WindowSetPosition(setPosition.x, setPosition.y);
|
||||
WindowSetSize(nextBounds.width, nextBounds.height);
|
||||
WindowSetPosition(nextBounds.x, nextBounds.y);
|
||||
const boundsApplied = await waitForWindowCondition({
|
||||
read: async () => {
|
||||
const [size, position] = await Promise.all([
|
||||
WindowGetSize(),
|
||||
WindowGetPosition(),
|
||||
]);
|
||||
return Math.abs(Math.trunc(Number(size?.w)) - nextBounds.width) <= 2
|
||||
&& Math.abs(Math.trunc(Number(size?.h)) - nextBounds.height) <= 2
|
||||
&& Math.abs(Math.trunc(Number(position?.x)) - nextBounds.x) <= 2
|
||||
&& Math.abs(Math.trunc(Number(position?.y)) - nextBounds.y) <= 2;
|
||||
},
|
||||
wait,
|
||||
isCancelled: () => cancelled,
|
||||
maxChecks: 16,
|
||||
intervalMs: 40,
|
||||
});
|
||||
if (!boundsApplied) {
|
||||
return false;
|
||||
}
|
||||
await tryRefreshStartupWebViewBounds();
|
||||
const surfaceFilled = await waitForWindowCondition({
|
||||
read: async () => isStartupWindowSurfaceCoveringViewport({
|
||||
surfaceWidth: window.innerWidth,
|
||||
surfaceHeight: window.innerHeight,
|
||||
viewport: readCurrentVisibleViewport(),
|
||||
}),
|
||||
wait,
|
||||
isCancelled: () => cancelled,
|
||||
maxChecks: 10,
|
||||
intervalMs: 40,
|
||||
});
|
||||
if (!surfaceFilled) return false;
|
||||
useStore.getState().setWindowBounds(nextBounds);
|
||||
// 兜底结果视觉上等同最大化,保持标题栏状态与实际窗口一致。
|
||||
useStore.getState().setWindowState('maximized');
|
||||
useStore.getState().setWindowState('normal');
|
||||
void emitWindowDiagnostic('adjust:startup-work-area-fill-fallback', {
|
||||
to: nextBounds,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('Failed to apply Windows work-area fill fallback', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1407,29 +1533,42 @@ function App() {
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(async () => {
|
||||
markStartupWindowRestorePending(startupRestoreGraceMs);
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
markStartupMaximised();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await WindowMaximise();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
WindowMaximise();
|
||||
if (await waitForMaximisedState(true)) {
|
||||
await tryRefreshStartupWebViewBounds();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Wails Window APIs unavailable", e);
|
||||
}
|
||||
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
if (await waitForStartupPreferenceApplied()) {
|
||||
markStartupMaximised();
|
||||
return;
|
||||
}
|
||||
if (attempt < maxApplyAttempts) {
|
||||
applyStartupWindowChrome(attempt + 1);
|
||||
} else {
|
||||
// WebView2 controller bounds may remain at the initial 1440x900 even
|
||||
// after WS_MAXIMIZE is set. Use one cold-start-only native transition
|
||||
// if the zero-animation bounds refresh could not settle the surface.
|
||||
if (await repairStartupMaximisedSurface()) {
|
||||
markStartupMaximised();
|
||||
return;
|
||||
}
|
||||
// 最终仍失败:Windows 铺满工作区兜底,再结束宽限
|
||||
void emitWindowDiagnostic('warn:startup-maximise-failed', {
|
||||
attempts: attempt,
|
||||
});
|
||||
applyWindowsWorkAreaFillFallback();
|
||||
const fallbackApplied = await applyWindowsWorkAreaFillFallback();
|
||||
if (!fallbackApplied) {
|
||||
void emitWindowDiagnostic('error:startup-work-area-fill-fallback-failed');
|
||||
}
|
||||
clearStartupWindowRestorePending();
|
||||
}
|
||||
});
|
||||
@@ -1455,7 +1594,8 @@ function App() {
|
||||
console.warn('Failed to restore normal window chrome', e);
|
||||
}
|
||||
const state = useStore.getState();
|
||||
const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport());
|
||||
const viewport = readCurrentVisibleViewport();
|
||||
const nextBounds = resolveVisibleStartupWindowBounds(bounds, viewport);
|
||||
if (
|
||||
nextBounds.x !== bounds.x ||
|
||||
nextBounds.y !== bounds.y ||
|
||||
@@ -1468,7 +1608,10 @@ function App() {
|
||||
});
|
||||
}
|
||||
WindowSetSize(nextBounds.width, nextBounds.height);
|
||||
WindowSetPosition(nextBounds.x, nextBounds.y);
|
||||
const setPosition = resolveWailsWindowSetPosition(nextBounds, viewport, {
|
||||
useMonitorLocalOrigin: isWindowsPlatform(),
|
||||
});
|
||||
WindowSetPosition(setPosition.x, setPosition.y);
|
||||
state.setWindowBounds(nextBounds);
|
||||
state.setWindowState('normal');
|
||||
};
|
||||
@@ -1646,7 +1789,8 @@ function App() {
|
||||
if (currentBounds.width <= 0 || currentBounds.height <= 0) {
|
||||
return;
|
||||
}
|
||||
const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());
|
||||
const viewport = readCurrentVisibleViewport();
|
||||
const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, viewport);
|
||||
if (
|
||||
nextBounds.x === currentBounds.x &&
|
||||
nextBounds.y === currentBounds.y &&
|
||||
@@ -1660,7 +1804,10 @@ function App() {
|
||||
to: nextBounds,
|
||||
});
|
||||
WindowSetSize(nextBounds.width, nextBounds.height);
|
||||
WindowSetPosition(nextBounds.x, nextBounds.y);
|
||||
const setPosition = resolveWailsWindowSetPosition(nextBounds, viewport, {
|
||||
useMonitorLocalOrigin: isWindowsPlatform(),
|
||||
});
|
||||
WindowSetPosition(setPosition.x, setPosition.y);
|
||||
lastSaved = `${nextBounds.width},${nextBounds.height},${nextBounds.x},${nextBounds.y}`;
|
||||
useStore.getState().setWindowBounds(nextBounds);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -450,6 +450,8 @@ export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:A
|
||||
|
||||
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RefreshWebViewBounds():Promise<connection.QueryResult>;
|
||||
|
||||
export function RemoveDriverPackage(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -886,6 +886,10 @@ export function RedisZSetRemove(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RefreshWebViewBounds() {
|
||||
return window['go']['app']['App']['RefreshWebViewBounds']();
|
||||
}
|
||||
|
||||
export function RemoveDriverPackage(arg1, arg2) {
|
||||
return window['go']['app']['App']['RemoveDriverPackage'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -425,6 +425,27 @@ func (a *App) ResetWebViewZoom() (result connection.QueryResult) {
|
||||
return connection.QueryResult{Success: true, Message: "WebView2 zoom factor reset to 1.0"}
|
||||
}
|
||||
|
||||
// RefreshWebViewBounds synchronises WebView2 controller bounds with the native
|
||||
// Windows client rect. It repairs a startup maximise race without toggling the window.
|
||||
func (a *App) RefreshWebViewBounds() (result connection.QueryResult) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Errorf("刷新 WebView2 窗口边界失败:%v", recovered)
|
||||
result = connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("failed to refresh WebView2 bounds: %v", recovered),
|
||||
}
|
||||
}
|
||||
}()
|
||||
if a == nil || a.ctx == nil {
|
||||
return connection.QueryResult{Success: false, Message: "application context is unavailable"}
|
||||
}
|
||||
if err := refreshWebViewBounds(a.ctx); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "WebView2 bounds refreshed"}
|
||||
}
|
||||
|
||||
// LogWindowDiagnostic 记录前端采集到的窗口诊断信息,便于排查 macOS 原生全屏异常。
|
||||
func (a *App) LogWindowDiagnostic(stage string, payload string) {
|
||||
stage = strings.TrimSpace(stage)
|
||||
|
||||
12
internal/app/window_bounds_other.go
Normal file
12
internal/app/window_bounds_other.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func refreshWebViewBounds(context.Context) error {
|
||||
return fmt.Errorf("WebView2 bounds refresh is only available on Windows")
|
||||
}
|
||||
27
internal/app/window_bounds_other_test.go
Normal file
27
internal/app/window_bounds_other_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRefreshWebViewBoundsReturnsErrorOnNonWindows(t *testing.T) {
|
||||
err := refreshWebViewBounds(context.Background())
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "windows") {
|
||||
t.Fatalf("expected Windows-only error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRefreshWebViewBoundsRPCReportsFailureOnNonWindows(t *testing.T) {
|
||||
app := &App{ctx: context.Background()}
|
||||
result := app.RefreshWebViewBounds()
|
||||
if result.Success {
|
||||
t.Fatal("expected RPC to report failure on non-Windows platform")
|
||||
}
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatal("expected failure message to explain why")
|
||||
}
|
||||
}
|
||||
79
internal/app/window_bounds_windows.go
Normal file
79
internal/app/window_bounds_windows.go
Normal file
@@ -0,0 +1,79 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
const refreshWebViewBoundsInvokeTimeout = 2 * time.Second
|
||||
|
||||
// refreshWebViewBounds forces WebView2's controller bounds to match the current
|
||||
// native client rect. Wails normally does this from WM_SIZE, but a late startup
|
||||
// maximise can expose WS_MAXIMIZE before that resize reaches the WebView surface.
|
||||
func refreshWebViewBounds(ctx context.Context) (err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("refresh WebView2 bounds panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("ctx is nil")
|
||||
}
|
||||
|
||||
frontendValue, err := resolveWailsFrontendValue(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chromiumValue, err := accessibleWailsFrontendField(frontendValue, "chromium")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mainWindowValue, err := accessibleWailsFrontendField(frontendValue, "mainWindow")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resize := chromiumValue.MethodByName("Resize")
|
||||
if !resize.IsValid() {
|
||||
return fmt.Errorf("Resize method not found on chromium (go-webview2 version may have changed)")
|
||||
}
|
||||
if resize.Type().NumIn() != 0 || resize.Type().NumOut() != 0 {
|
||||
return fmt.Errorf("Resize signature changed: expected func(), got %v", resize.Type())
|
||||
}
|
||||
|
||||
invoke := mainWindowValue.MethodByName("Invoke")
|
||||
if !invoke.IsValid() {
|
||||
return fmt.Errorf("mainWindow.Invoke method not found (wails version may have changed)")
|
||||
}
|
||||
if invoke.Type().NumIn() != 1 || invoke.Type().In(0).Kind() != reflect.Func || invoke.Type().In(0).NumIn() != 0 || invoke.Type().In(0).NumOut() != 0 || invoke.Type().NumOut() != 0 {
|
||||
return fmt.Errorf("mainWindow.Invoke signature changed: expected func(func()), got %v", invoke.Type())
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
if err := safeCallInvoke(invoke, func() {
|
||||
done <- safeCallResizeWebView(resize)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(refreshWebViewBoundsInvokeTimeout):
|
||||
return fmt.Errorf("timed out waiting for mainWindow.Invoke to refresh WebView2 bounds")
|
||||
}
|
||||
}
|
||||
|
||||
func safeCallResizeWebView(resize reflect.Value) (err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = fmt.Errorf("Resize panicked while refreshing WebView2 bounds: %v", value)
|
||||
}
|
||||
}()
|
||||
resize.Call(nil)
|
||||
return nil
|
||||
}
|
||||
122
internal/app/window_bounds_windows_test.go
Normal file
122
internal/app/window_bounds_windows_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeBoundsChromium struct {
|
||||
resized atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeBoundsChromium) Resize() {
|
||||
f.resized.Add(1)
|
||||
}
|
||||
|
||||
type fakeBoundsFrontend struct {
|
||||
chromium *fakeBoundsChromium
|
||||
mainWindow *fakeWindow
|
||||
}
|
||||
|
||||
type panicBoundsChromium struct{}
|
||||
|
||||
func (*panicBoundsChromium) Resize() {
|
||||
panic("WebView2 bounds refresh failed")
|
||||
}
|
||||
|
||||
type panicBoundsFrontend struct {
|
||||
chromium *panicBoundsChromium
|
||||
mainWindow *fakeWindow
|
||||
}
|
||||
|
||||
type missingResizeFrontend struct {
|
||||
chromium *fakeChromium
|
||||
mainWindow *fakeWindow
|
||||
}
|
||||
|
||||
func TestRefreshWebViewBoundsCallsChromiumResizeOnWindowThread(t *testing.T) {
|
||||
chromium := &fakeBoundsChromium{}
|
||||
window := &fakeWindow{}
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeBoundsFrontend{
|
||||
chromium: chromium,
|
||||
mainWindow: window,
|
||||
})
|
||||
|
||||
if err := refreshWebViewBounds(ctx); err != nil {
|
||||
t.Fatalf("expected bounds refresh to succeed against fake frontend, got %v", err)
|
||||
}
|
||||
if got := window.invoked.Load(); got != 1 {
|
||||
t.Fatalf("expected refresh to run through mainWindow.Invoke exactly once, got %d", got)
|
||||
}
|
||||
if got := chromium.resized.Load(); got != 1 {
|
||||
t.Fatalf("expected Chromium.Resize called exactly once, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshWebViewBoundsErrorsWhenChromiumNil(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeBoundsFrontend{
|
||||
chromium: nil,
|
||||
mainWindow: &fakeWindow{},
|
||||
})
|
||||
|
||||
err := refreshWebViewBounds(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "chromium") {
|
||||
t.Fatalf("expected chromium error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshWebViewBoundsErrorsWhenResizeMethodMissing(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &missingResizeFrontend{
|
||||
chromium: &fakeChromium{},
|
||||
mainWindow: &fakeWindow{},
|
||||
})
|
||||
|
||||
err := refreshWebViewBounds(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "Resize") {
|
||||
t.Fatalf("expected Resize compatibility error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshWebViewBoundsErrorsWhenMainWindowNil(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeBoundsFrontend{
|
||||
chromium: &fakeBoundsChromium{},
|
||||
mainWindow: nil,
|
||||
})
|
||||
|
||||
err := refreshWebViewBounds(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "mainWindow") {
|
||||
t.Fatalf("expected mainWindow error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshWebViewBoundsRecoversFromResizePanic(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &panicBoundsFrontend{
|
||||
chromium: &panicBoundsChromium{},
|
||||
mainWindow: &fakeWindow{},
|
||||
})
|
||||
|
||||
err := refreshWebViewBounds(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "panic") {
|
||||
t.Fatalf("expected resize panic to be converted to error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRefreshWebViewBoundsRPCReportsSuccess(t *testing.T) {
|
||||
chromium := &fakeBoundsChromium{}
|
||||
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeBoundsFrontend{
|
||||
chromium: chromium,
|
||||
mainWindow: &fakeWindow{},
|
||||
})
|
||||
|
||||
result := (&App{ctx: ctx}).RefreshWebViewBounds()
|
||||
if !result.Success {
|
||||
t.Fatalf("expected RPC success, got %q", result.Message)
|
||||
}
|
||||
if got := chromium.resized.Load(); got != 1 {
|
||||
t.Fatalf("expected RPC to refresh bounds exactly once, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func isNilReflectValue(value reflect.Value) bool {
|
||||
func safeCallInvoke(invoke reflect.Value, fn func()) (err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = fmt.Errorf("mainWindow.Invoke panicked while resetting WebView2 zoom factor: %v", value)
|
||||
err = fmt.Errorf("mainWindow.Invoke panicked: %v", value)
|
||||
}
|
||||
}()
|
||||
invoke.Call([]reflect.Value{reflect.ValueOf(fn)})
|
||||
|
||||
@@ -39,6 +39,7 @@ var desktopOnlyAppMethods = map[string]struct{}{
|
||||
"SetMacNativeWindowControls": {},
|
||||
"SetApplicationBrandIcon": {},
|
||||
"ResetWebViewZoom": {},
|
||||
"RefreshWebViewBounds": {},
|
||||
"SelectDataRootDirectory": {},
|
||||
"GetDataRootDirectoryInfo": {},
|
||||
"ApplyDataRootDirectory": {},
|
||||
|
||||
@@ -108,6 +108,7 @@ func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T)
|
||||
"ExportDatabaseSQLWithOptions", "ExportSchemaSQLWithOptions",
|
||||
"ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory",
|
||||
"SelectSavedQueryDirectory", "ApplySavedQueryDirectory", "OpenSavedQueryDirectory", "RevealSavedQueryInFolder", "SetApplicationBrandIcon",
|
||||
"RefreshWebViewBounds",
|
||||
} {
|
||||
_, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method})
|
||||
if err == nil || !strings.Contains(err.Error(), "unavailable in web runtime") {
|
||||
|
||||
Reference in New Issue
Block a user