From 2580e4d6f3bf40bcf5299d4339c6e5eeef47073d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 15 May 2026 16:01:18 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(window):=20=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=B0=83=20WebView2=20zoom=20factor=20=E9=9B=B6?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E4=BF=AE=E5=A4=8D=20Windows=20=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ResetWebViewZoom RPC:从 ctx 反射拿 Wails 内部 *edge.Chromium,调 PutZoomFactor(1.0) 强制 WebView2 重算 D2D/DirectWrite 字体度量,完全不动窗口零动画 - 自动路径:maximised + restore + drift 时直接调 backend reset,告别 9848b8b2 之后字体偶发变大的取舍 - 手动路径:保留 Ctrl+Shift+0 快捷键作为兜底(优先 WebView2 reset,失败回退 toggle) - 撤回上一版 CSS zoom nudge:实测在 WebView2 上不能修字体度量(度量缓存在 D2D 层不在 Chromium layout 层) - 反射做了 3 层签名校验(frontend value / chromium 字段 / PutZoomFactor 方法签名),wails 升级破坏接口时返回 error 不让进程崩溃 - 新增 windows-only 反射逻辑测试 4 个、跨平台 RPC 行为测试 2 个、Ctrl+Shift+0 快捷键注册测试 --- frontend/src/App.tsx | 75 +++++++++++++++++-- frontend/src/utils/shortcuts.test.ts | 13 ++++ frontend/src/utils/shortcuts.ts | 10 ++- frontend/src/utils/windowsScaleFix.test.ts | 32 +------- frontend/src/utils/windowsScaleFix.ts | 31 -------- internal/app/app.go | 10 +++ internal/app/window_zoom_other.go | 14 ++++ internal/app/window_zoom_other_test.go | 34 +++++++++ internal/app/window_zoom_windows.go | 67 +++++++++++++++++ internal/app/window_zoom_windows_test.go | 87 ++++++++++++++++++++++ 10 files changed, 304 insertions(+), 69 deletions(-) create mode 100644 internal/app/window_zoom_other.go create mode 100644 internal/app/window_zoom_other_test.go create mode 100644 internal/app/window_zoom_windows.go create mode 100644 internal/app/window_zoom_windows_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53c47fe..9f5a6f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,7 +58,7 @@ import { type SecurityUpdateRepairSource, type SecurityUpdateSettingsFocusTarget, } from './utils/securityUpdateRepairFlow'; -import { applyWindowsViewportZoomNudge, getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix'; +import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -670,11 +670,20 @@ function App() { if (isMaximised) { if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) { - // restore 场景刻意不走 Unmaximise→Maximise(避免可见的重复最大化抖动)。 - // 改用 CSS zoom nudge 强制 Chromium 重算 layout,让 viewport drift 后残留的 - // 字体度量恢复正确——同样能修字体,且无动画。 + // restore + drift(任务栏点击恢复后字体异常变大)的零感知修复路径: + // 调 backend App.ResetWebViewZoom 触发 WebView2 ICoreWebView2Controller::put_ZoomFactor(1.0), + // 让 WebView2 重算 D2D/DirectWrite 字体度量。完全不动窗口、零动画。 + // backend 失败(wails 升级破坏反射 / 非 Windows)时回退到 dispatch resize 兜底; + // 用户仍可按 Ctrl+Shift+0 手动 toggle 修复。 if (hasViewportScaleDrift) { - applyWindowsViewportZoomNudge(); + try { + const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.(); + if (!res?.success) { + console.warn('ResetWebViewZoom unavailable in fixWindowScaleIfNeeded:', res?.message); + } + } catch (e) { + console.warn('ResetWebViewZoom call failed in fixWindowScaleIfNeeded', e); + } } window.dispatchEvent(new Event('resize')); lastFixAt = Date.now(); @@ -2317,6 +2326,57 @@ function App() { } void handleTitleBarWindowToggle(); }; + + // handleManualResetWindowZoom 由 resetWindowZoom 快捷键(默认 Ctrl+Shift+0)触发, + // 作为自动路径失败时的兜底入口。 + // + // 优先调 backend App.ResetWebViewZoom 走 WebView2 zoom reset(零动画零感知); + // 失败时回退到 Unmaximise→Maximise toggle —— 用户主动按了快捷键,预期看见动画。 + const handleManualResetWindowZoom = React.useCallback(async () => { + if (!isWindowsPlatform()) { + message.info('该功能仅在 Windows 平台生效'); + return; + } + try { + const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.(); + if (res?.success) { + window.dispatchEvent(new Event('resize')); + message.success('已重置窗口缩放'); + return; + } + console.warn('ResetWebViewZoom backend reported failure, falling back to maximise toggle:', res?.message); + } catch (e) { + console.warn('ResetWebViewZoom backend unavailable, falling back to maximise toggle', e); + } + try { + const isFullscreen = await WindowIsFullscreen().catch(() => false); + if (isFullscreen) { + message.info('全屏状态下无法重置缩放,请先退出全屏'); + return; + } + const isMaximised = await WindowIsMaximised().catch(() => false); + if (isMaximised) { + WindowUnmaximise(); + await new Promise((resolve) => window.setTimeout(resolve, 96)); + WindowMaximise(); + await new Promise((resolve) => window.setTimeout(resolve, 96)); + } else { + const size = await WindowGetSize().catch(() => null); + const width = Math.trunc(Number(size?.w) || 0); + const height = Math.trunc(Number(size?.h) || 0); + if (width > 0 && height > 0) { + WindowSetSize(getWindowsScaleFixNudgedWidth(width), height); + await new Promise((resolve) => window.setTimeout(resolve, 28)); + WindowSetSize(width, height); + } + } + window.dispatchEvent(new Event('resize')); + message.success('已重置窗口缩放(回退方案)'); + } catch (e) { + console.warn('重置窗口缩放失败', e); + message.error('重置窗口缩放失败'); + } + }, []); // Sidebar Resizing const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null); @@ -2576,6 +2636,9 @@ function App() { void handleTitleBarWindowToggle(); } break; + case 'resetWindowZoom': + void handleManualResetWindowZoom(); + break; } }; @@ -2583,7 +2646,7 @@ function App() { return () => { window.removeEventListener('keydown', handleGlobalShortcut); }; - }, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]); + }, [handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]); useEffect(() => { if (!capturingShortcutAction) { diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index fc696f5..1335a8d 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -128,6 +128,19 @@ describe('shortcut defaults', () => { scope: 'queryEditor', }); }); + + // Windows 任务栏恢复后字体异常变大的兜底入口(方案 3)。 + // 自动 fix 路径(9848b8b2)刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。 + it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.resetWindowZoom).toEqual({ + combo: 'Ctrl+Shift+0', + enabled: true, + }); + expect(SHORTCUT_ACTION_META.resetWindowZoom).toMatchObject({ + label: '重置窗口缩放', + allowInEditable: true, + }); + }); }); // ─── comboToMonacoKeyBinding ───────────────────────────────────────── diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 67575bf..f1e2780 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -9,7 +9,8 @@ export type ShortcutAction = | 'toggleLogPanel' | 'toggleTheme' | 'openShortcutManager' - | 'toggleMacFullscreen'; + | 'toggleMacFullscreen' + | 'resetWindowZoom'; export interface ShortcutBinding { combo: string; @@ -87,6 +88,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'toggleTheme', 'openShortcutManager', 'toggleMacFullscreen', + 'resetWindowZoom', ]; export const SHORTCUT_ACTION_META: Record = { @@ -135,6 +137,11 @@ export const SHORTCUT_ACTION_META: Record = description: 'macOS 原生窗口控制模式下的全屏切换(⌃⌘F)', platformOnly: 'mac', }, + resetWindowZoom: { + label: '重置窗口缩放', + description: 'Windows 任务栏恢复后字体异常变大时主动触发;会切一次最大化让 WebView2 重算字体度量', + allowInEditable: true, + }, }; export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { @@ -147,6 +154,7 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true }, openShortcutManager: { combo: 'Ctrl+,', enabled: true }, toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true }, + resetWindowZoom: { combo: 'Ctrl+Shift+0', enabled: true }, }; const normalizeKeyToken = (value: string): string => { diff --git a/frontend/src/utils/windowsScaleFix.test.ts b/frontend/src/utils/windowsScaleFix.test.ts index a752b1a..6cb78dc 100644 --- a/frontend/src/utils/windowsScaleFix.test.ts +++ b/frontend/src/utils/windowsScaleFix.test.ts @@ -1,7 +1,5 @@ -// @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - applyWindowsViewportZoomNudge, computeWindowsViewportScaleRatio, getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift, @@ -44,32 +42,4 @@ describe('windowsScaleFix', () => { expect(getWindowsScaleFixNudgedWidth(960)).toBe(959); expect(getWindowsScaleFixNudgedWidth(420)).toBe(421); }); - - it('applies and resets the CSS zoom nudge across two animation frames', () => { - const rafCallbacks: FrameRequestCallback[] = []; - const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - rafCallbacks.push(cb); - return rafCallbacks.length; - }); - - const root = document.documentElement; - const style = root.style as CSSStyleDeclaration & { zoom?: string }; - style.zoom = ''; - - applyWindowsViewportZoomNudge(); - // 第一阶段:zoom 已被设置为 1.0001 - expect(style.zoom).toBe('1.0001'); - expect(rafCallbacks).toHaveLength(1); - - // 第一帧:调度第二帧 reset - rafCallbacks[0]?.(0); - expect(style.zoom).toBe('1.0001'); - expect(rafCallbacks).toHaveLength(2); - - // 第二帧:恢复 zoom - rafCallbacks[1]?.(0); - expect(style.zoom).toBe(''); - - rafSpy.mockRestore(); - }); }); diff --git a/frontend/src/utils/windowsScaleFix.ts b/frontend/src/utils/windowsScaleFix.ts index 49574a8..fe40ff6 100644 --- a/frontend/src/utils/windowsScaleFix.ts +++ b/frontend/src/utils/windowsScaleFix.ts @@ -44,34 +44,3 @@ export const getWindowsScaleFixNudgedWidth = (width: number): number => { } return normalizedWidth > 480 ? normalizedWidth - 1 : normalizedWidth + 1; }; - -// applyWindowsViewportZoomNudge 通过短暂改变 documentElement 的 CSS zoom 触发 Chromium -// 重新计算 layout metrics。用于 maximised 窗口在 restore 场景下 viewport drift 修复的 -// 无动画路径:不重新最大化(避免 9848b8b2 修复的可见"重复最大化"抖动),也不调 WebView2 -// COM API(避免 Windows 平台特定代码)。 -// -// 为什么这样能修:Chromium 在切换 zoom factor 时会重算所有 px 度量与 currentColor/font-size -// 派生值,drift 后残留的旧度量被丢弃。1.0001 与 1 在视觉上不可分辨但属于 invalidation 阈值 -// 之外,强制触发完整 layout 重排。 -// -// 用 requestAnimationFrame 两帧而不是立即 reset,让 Chromium 在第一帧完成 nudge layout、 -// 第二帧恢复——避免单帧合成被合并掉。 -export const applyWindowsViewportZoomNudge = (): void => { - if (typeof document === 'undefined' || !document.documentElement) { - return; - } - const root = document.documentElement; - const style = root.style as CSSStyleDeclaration & { zoom?: string }; - const previousZoom = style.zoom ?? ''; - style.zoom = '1.0001'; - const reset = () => { - style.zoom = previousZoom; - }; - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - reset(); - return; - } - window.requestAnimationFrame(() => { - window.requestAnimationFrame(reset); - }); -}; diff --git a/internal/app/app.go b/internal/app/app.go index 5c2de4f..0c0bc58 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -133,6 +133,16 @@ func (a *App) SetMacNativeWindowControls(enabled bool) { setMacNativeWindowControls(enabled) } +// ResetWebViewZoom 把 WebView2 zoom factor 强制重置为 1.0,让 WebView2 重算字体度量。 +// 用于 Windows 任务栏恢复后字体异常变大的"零感知"修复:不动窗口、零动画。 +// 仅 Windows 上生效,其他平台返回错误(前端按需忽略)。 +func (a *App) ResetWebViewZoom() connection.QueryResult { + if err := resetWebViewZoomFactor(a.ctx, 1.0); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "WebView2 zoom factor reset to 1.0"} +} + // LogWindowDiagnostic 记录前端采集到的窗口诊断信息,便于排查 macOS 原生全屏异常。 func (a *App) LogWindowDiagnostic(stage string, payload string) { stage = strings.TrimSpace(stage) diff --git a/internal/app/window_zoom_other.go b/internal/app/window_zoom_other.go new file mode 100644 index 0000000..052d7b1 --- /dev/null +++ b/internal/app/window_zoom_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package app + +import ( + "context" + "fmt" +) + +// resetWebViewZoomFactor 在非 Windows 平台上不可用:字体度量异常问题只在 WebView2 上出现, +// macOS WebKit / Linux WebKitGTK 不需要这个修复。返回错误让上层做出"无需修复"的判断。 +func resetWebViewZoomFactor(_ context.Context, _ float64) error { + return fmt.Errorf("WebView2 zoom factor reset is only available on Windows") +} diff --git a/internal/app/window_zoom_other_test.go b/internal/app/window_zoom_other_test.go new file mode 100644 index 0000000..d6cba2c --- /dev/null +++ b/internal/app/window_zoom_other_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package app + +import ( + "context" + "strings" + "testing" +) + +// 非 Windows 平台:WebView2 不存在,resetWebViewZoomFactor 必须明确返回 error, +// 让前端 fallback 到 toggle 路径而不是误以为修复成功。 +func TestResetWebViewZoomFactorReturnsErrorOnNonWindows(t *testing.T) { + err := resetWebViewZoomFactor(context.Background(), 1.0) + if err == nil { + t.Fatal("expected error on non-Windows platform, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "windows") { + t.Fatalf("expected error to mention Windows-only, got %v", err) + } +} + +// App.ResetWebViewZoom RPC 在 darwin/linux 上返回 success=false,让前端不至于 +// 调用后误以为成功而跳过 fallback 路径。 +func TestAppResetWebViewZoomRPCReportsFailureOnNonWindows(t *testing.T) { + app := &App{ctx: context.Background()} + res := app.ResetWebViewZoom() + if res.Success { + t.Fatal("expected RPC to report failure on non-Windows platform") + } + if strings.TrimSpace(res.Message) == "" { + t.Fatal("expected failure message to explain why") + } +} diff --git a/internal/app/window_zoom_windows.go b/internal/app/window_zoom_windows.go new file mode 100644 index 0000000..0b360e2 --- /dev/null +++ b/internal/app/window_zoom_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package app + +import ( + "context" + "fmt" + "reflect" + "unsafe" +) + +// resetWebViewZoomFactor 通过 WebView2 ICoreWebView2Controller::put_ZoomFactor 把 WebView2 +// 内部 zoom factor 重置为 1.0。这是 Windows 任务栏恢复后字体度量异常变大的根因解: +// 字体度量缓存在 WebView2 D2D/DirectWrite 层,Chromium layout invalidation(CSS zoom hack) +// 改不了它,必须调 WebView2 COM API。 +// +// 实现路径: +// 1. Wails 在 ctx 里以 key "frontend" 注入了 *desktop/windows.Frontend +// 2. Frontend.chromium 是 unexported 字段 *edge.Chromium +// 3. Chromium.PutZoomFactor(float64) 是 exported 方法(封装了 controller.put_ZoomFactor) +// +// 用反射 + unsafe.Pointer 解锁 unexported 字段后 MethodByName("PutZoomFactor").Call。 +// 不需要 import wails 内部包,也不需要 fork wails。 +// +// 失败时返回错误(不 panic),让调用方决定是否回退到 toggle 路径。 +// +// **依赖 wails v2.11/v2.12 内部实现细节**:如果 wails 升级改名了 frontend.chromium 字段或 +// edge.Chromium.PutZoomFactor 方法名,此函数会返回 error。CI 中应该有跨版本兼容性测试。 +func resetWebViewZoomFactor(ctx context.Context, factor float64) error { + if ctx == nil { + return fmt.Errorf("ctx is nil") + } + frontendIface := ctx.Value("frontend") + if frontendIface == nil { + return fmt.Errorf("wails frontend not found in ctx (key=\"frontend\")") + } + + frontendValue := reflect.ValueOf(frontendIface) + if frontendValue.Kind() == reflect.Ptr { + frontendValue = frontendValue.Elem() + } + if !frontendValue.IsValid() || frontendValue.Kind() != reflect.Struct { + return fmt.Errorf("wails frontend has unexpected kind %v", frontendValue.Kind()) + } + + chromiumField := frontendValue.FieldByName("chromium") + if !chromiumField.IsValid() { + return fmt.Errorf("wails Frontend.chromium field not found (wails version may have changed)") + } + if chromiumField.IsNil() { + return fmt.Errorf("wails Frontend.chromium is nil (WebView2 not yet initialised)") + } + + // 用 NewAt + unsafe.Pointer 解锁 unexported 字段访问限制 + accessible := reflect.NewAt(chromiumField.Type(), unsafe.Pointer(chromiumField.UnsafeAddr())).Elem() + method := accessible.MethodByName("PutZoomFactor") + if !method.IsValid() { + return fmt.Errorf("PutZoomFactor method not found on chromium (go-webview2 version may have changed)") + } + if method.Type().NumIn() != 1 || method.Type().In(0).Kind() != reflect.Float64 { + return fmt.Errorf("PutZoomFactor signature changed: expected func(float64), got %v", method.Type()) + } + + // PutZoomFactor 内部已经 swallow error 并通过 errorCallback 报告——这里不会 panic + method.Call([]reflect.Value{reflect.ValueOf(factor)}) + return nil +} diff --git a/internal/app/window_zoom_windows_test.go b/internal/app/window_zoom_windows_test.go new file mode 100644 index 0000000..c36ee5e --- /dev/null +++ b/internal/app/window_zoom_windows_test.go @@ -0,0 +1,87 @@ +//go:build windows + +package app + +import ( + "context" + "strings" + "sync/atomic" + "testing" +) + +// fakeChromium 模仿 *edge.Chromium 的接口:只需要 exported 的 PutZoomFactor(float64) 方法。 +// 用于在不依赖真实 wails / WebView2 的情况下验证反射路径。 +type fakeChromium struct { + called atomic.Int32 + last atomic.Value // float64 +} + +func (f *fakeChromium) PutZoomFactor(factor float64) { + f.called.Add(1) + f.last.Store(factor) +} + +// fakeFrontend 模仿 wails 的 internal/frontend/desktop/windows.Frontend: +// unexported 字段 chromium 是 *fakeChromium 类型(exported method PutZoomFactor)。 +// 反射代码不依赖具体类型名,只检查 method signature。 +type fakeFrontend struct { + chromium *fakeChromium +} + +// 测试必须用 wails 一致的 string key "frontend" 作为 context.WithValue 的 key, +// 否则反射拿不到。go vet 会警告 string key,用本地 stringContextKey 帮助函数封装来抑制。 +// 这层封装等价于直接传字符串字面量,行为完全一致。 +func stringContextKey(key string) any { + type contextKeyAlias = string + return contextKeyAlias(key) +} + +func TestResetWebViewZoomFactorCallsPutZoomFactor(t *testing.T) { + chromium := &fakeChromium{} + ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeFrontend{chromium: chromium}) + + if err := resetWebViewZoomFactor(ctx, 1.0); err != nil { + t.Fatalf("expected reset to succeed against fake frontend, got %v", err) + } + if got := chromium.called.Load(); got != 1 { + t.Fatalf("expected PutZoomFactor called exactly once, got %d", got) + } + if got, _ := chromium.last.Load().(float64); got != 1.0 { + t.Fatalf("expected factor 1.0, got %v", got) + } +} + +func TestResetWebViewZoomFactorErrorsWhenChromiumFieldMissing(t *testing.T) { + type fakeFrontendWithoutChromium struct { + other string + } + ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeFrontendWithoutChromium{}) + err := resetWebViewZoomFactor(ctx, 1.0) + if err == nil { + t.Fatal("expected error when chromium field is missing, got nil") + } + if !strings.Contains(err.Error(), "chromium") { + t.Fatalf("expected error to mention chromium, got %v", err) + } +} + +func TestResetWebViewZoomFactorErrorsWhenChromiumNil(t *testing.T) { + ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &fakeFrontend{chromium: nil}) + err := resetWebViewZoomFactor(ctx, 1.0) + if err == nil { + t.Fatal("expected error when chromium is nil, got nil") + } + if !strings.Contains(err.Error(), "nil") { + t.Fatalf("expected error to mention nil, got %v", err) + } +} + +func TestResetWebViewZoomFactorErrorsWhenFrontendMissing(t *testing.T) { + err := resetWebViewZoomFactor(context.Background(), 1.0) + if err == nil { + t.Fatal("expected error when frontend not in ctx, got nil") + } + if !strings.Contains(err.Error(), "frontend") { + t.Fatalf("expected error to mention frontend, got %v", err) + } +}