🐛 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

@@ -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'));

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;
};

View File

@@ -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>;

View File

@@ -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);
}

View File

@@ -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)

View 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")
}

View 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")
}
}

View 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
}

View 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)
}
}

View File

@@ -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)})

View File

@@ -39,6 +39,7 @@ var desktopOnlyAppMethods = map[string]struct{}{
"SetMacNativeWindowControls": {},
"SetApplicationBrandIcon": {},
"ResetWebViewZoom": {},
"RefreshWebViewBounds": {},
"SelectDataRootDirectory": {},
"GetDataRootDirectoryInfo": {},
"ApplyDataRootDirectory": {},

View File

@@ -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") {