🐛 fix(window): 直接调 WebView2 zoom factor 零感知修复 Windows 字体异常

- 新增 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 快捷键注册测试
This commit is contained in:
Syngnat
2026-05-15 16:01:18 +08:00
parent 32d51f3c25
commit 2580e4d6f3
10 changed files with 304 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

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