🐛 fix(window): 修复 mac 窗口恢复越界 (#643)

## 问题描述

macOS 多屏环境下,普通窗口恢复/修正位置时可能被定位到当前屏幕外,尤其是在外接屏位于主屏左侧或上方、浏览器
`screen.availLeft/availTop` 出现全局偏移时更容易触发。

## 复现条件说明

该问题不是所有 macOS 用户都能稳定复现,更依赖特定显示器布局和窗口状态。高概率触发条件包括:

- 使用 macOS 多屏。
- 外接屏位于主屏左侧或上方,导致 `screen.availLeft` / `screen.availTop` 为负数或非零。
- GoNavi 处于普通窗口态,而不是最大化或全屏。
- 应用保存过窗口位置,并在重启、切屏、拔插显示器、focus/resize 后触发窗口 bounds 恢复或修正。

单屏 mac、外接屏仅位于主屏右侧、始终最大化/全屏打开,或未保存过异常窗口位置的用户通常不容易触发。

## 根本原因

Wails 的 `WindowSetPosition(x, y)` 在 macOS 上接收的是当前 monitor
可见区域内的局部坐标,而不是全局桌面坐标。原逻辑把 `window.screen.availLeft/availTop`
作为可见区起点传入恢复算法,导致保存的局部坐标和恢复时的全局偏移混用。

## 修复方案

- 新增 `resolveWailsWindowVisibleViewport`,集中处理 Wails 主窗口恢复使用的可见区域。
- macOS 下将可见区原点固定为当前 monitor 局部 `(0, 0)`,避免把浏览器 screen 全局偏移继续传给 Wails。
- 补充单元测试覆盖负 `availLeft` 的外接屏场景。
- 更新 App 窗口恢复源码断言测试。

## 变更文件

| 文件 | 修改说明 | 变更行数 |
|---|---|---|
| `frontend/src/App.tsx` | macOS 下使用 Wails 局部坐标解析窗口可见区 | +7 -7 |
| `frontend/src/utils/wailsWindowViewport.ts` | 新增 Wails 窗口可见区解析工具 | +52
|
| `frontend/src/utils/wailsWindowViewport.test.ts` | 覆盖 macOS 局部原点和
fallback 场景 | +43 |
| `frontend/src/App.tool-center.test.ts` | 同步窗口恢复源码断言 | +2 -1 |

## 合并注意事项

- **影响功能点**:桌面端普通窗口启动恢复、focus/resize 后窗口 bounds 修正。
- **验证方式**:
- `npm test -- --run src/utils/wailsWindowViewport.test.ts
src/utils/windowRestoreBounds.test.ts
src/utils/windowStartupLayout.test.ts src/App.tool-center.test.ts`
  - `npm run build`
- **回归风险**:低。变更集中在窗口可见区解析;非 macOS 平台保留原 `screen.availLeft/availTop` 处理。
- **回滚方式**:revert 此 PR 即可。
This commit is contained in:
Syngnat
2026-07-09 16:31:44 +08:00
committed by GitHub
4 changed files with 104 additions and 8 deletions

View File

@@ -423,7 +423,8 @@ describe('tool center menu entries', () => {
});
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
expect(appSource).toContain('const readCurrentVisibleViewport = () => ({');
expect(appSource).toContain('const readCurrentVisibleViewport = () => resolveWailsWindowVisibleViewport(');
expect(appSource).toContain('{ useMonitorLocalOrigin: isMacLikePlatform() }');
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");

View File

@@ -36,7 +36,7 @@ import {
useStore,
} from './store';
import { GlobalProxyConfig, SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isMacLikePlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { buildFontFamilyOptions, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, getLinuxCJKFontInstallHint, matchFontFamilyOption, resolveMonoFontFamily, resolveUIFontFamily, sanitizeFontFamilyInput, type FontFamilyOption, type InstalledFontFamily } from './utils/fontFamilies';
import {
DENSITY_OPTIONS,
@@ -136,6 +136,7 @@ import {
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldResetWebViewZoomForScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
import { resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
@@ -385,12 +386,11 @@ const detectNavigatorPlatform = (): string => {
return navigator.userAgent || '';
};
const readCurrentVisibleViewport = () => ({
availWidth: window.screen?.availWidth || window.innerWidth || 0,
availHeight: window.screen?.availHeight || window.innerHeight || 0,
availLeft: (window.screen as Screen & { availLeft?: number })?.availLeft || 0,
availTop: (window.screen as Screen & { availTop?: number })?.availTop || 0,
});
const readCurrentVisibleViewport = () => resolveWailsWindowVisibleViewport(
window.screen as Screen & { availLeft?: number; availTop?: number },
{ innerWidth: window.innerWidth, innerHeight: window.innerHeight },
{ useMonitorLocalOrigin: isMacLikePlatform() },
);
const getSystemThemeMode = (): 'light' | 'dark' => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { resolveWailsWindowVisibleViewport } from './wailsWindowViewport';
describe('wailsWindowViewport', () => {
it('keeps browser work-area offsets for platforms that use absolute screen coordinates', () => {
expect(resolveWailsWindowVisibleViewport(
{ availWidth: 1728, availHeight: 1040, availLeft: -1728, availTop: 40 },
{ innerWidth: 1440, innerHeight: 900 },
)).toEqual({
availWidth: 1728,
availHeight: 1040,
availLeft: -1728,
availTop: 40,
});
});
it('uses current-monitor local origin for macOS Wails window positioning', () => {
expect(resolveWailsWindowVisibleViewport(
{ availWidth: 1728, availHeight: 1040, availLeft: -1728, availTop: 40 },
{ innerWidth: 1440, innerHeight: 900 },
{ useMonitorLocalOrigin: true },
)).toEqual({
availWidth: 1728,
availHeight: 1040,
availLeft: 0,
availTop: 0,
});
});
it('falls back to window inner size when screen size is unavailable', () => {
expect(resolveWailsWindowVisibleViewport(
null,
{ innerWidth: 1280, innerHeight: 720 },
{ useMonitorLocalOrigin: true },
)).toEqual({
availWidth: 1280,
availHeight: 720,
availLeft: 0,
availTop: 0,
});
});
});

View File

@@ -0,0 +1,52 @@
export type WailsWindowVisibleViewport = {
availWidth: number;
availHeight: number;
availLeft?: number;
availTop?: number;
};
type ScreenLike = {
availWidth?: number;
availHeight?: number;
availLeft?: number;
availTop?: number;
} | null | undefined;
type ViewportFallback = {
innerWidth?: number;
innerHeight?: number;
};
const toFiniteInteger = (value: unknown, fallback = 0): number => {
const next = Math.trunc(Number(value));
return Number.isFinite(next) ? next : fallback;
};
/**
* 解析 Wails 主窗口恢复使用的可见区域。
* Wails 的 WindowSetPosition 在 macOS 上接收当前 monitor 可见区域内的局部坐标,
* 因此 macOS 不能把浏览器 screen.availLeft/availTop 的全局屏幕偏移继续传下去。
*/
export const resolveWailsWindowVisibleViewport = (
screenLike: ScreenLike,
fallback: ViewportFallback,
options?: { useMonitorLocalOrigin?: boolean },
): WailsWindowVisibleViewport => {
const availWidth = toFiniteInteger(
screenLike?.availWidth,
toFiniteInteger(fallback.innerWidth),
);
const availHeight = toFiniteInteger(
screenLike?.availHeight,
toFiniteInteger(fallback.innerHeight),
);
const useMonitorLocalOrigin = options?.useMonitorLocalOrigin === true;
return {
availWidth,
availHeight,
// macOS 修复点Wails 会自己把局部坐标映射到当前 NSScreen.visibleFrame。
availLeft: useMonitorLocalOrigin ? 0 : toFiniteInteger(screenLike?.availLeft),
availTop: useMonitorLocalOrigin ? 0 : toFiniteInteger(screenLike?.availTop),
};
};