🐛 fix(windows): 修复在线更新挂起与 WebView2 启动闪退

- 隐藏并释放 Windows 更新脚本进程,避免在线更新打开 cmd 并挂起
- 为更新脚本等待宿主进程退出增加超时保护
- 收窄自动 WebView2 zoom reset 触发条件并补充异常兜底
- 补充 Windows 更新启动与窗口缩放回归测试
Refs #468
This commit is contained in:
Syngnat
2026-05-16 22:13:24 +08:00
parent 6c36bd0a08
commit 0cde96844d
11 changed files with 119 additions and 7 deletions

View File

@@ -72,7 +72,7 @@ import {
splitConflictsByContext,
type ConflictInfo,
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldResetWebViewZoomForScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
@@ -676,7 +676,7 @@ function App() {
// 让 WebView2 重算 D2D/DirectWrite 字体度量。完全不动窗口、零动画。
// backend 失败wails 升级破坏反射 / 非 Windows时回退到 dispatch resize 兜底;
// 用户仍可按 Ctrl+Shift+0 手动 toggle 修复。
if (hasViewportScaleDrift) {
if (shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift)) {
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (!res?.success) {

View File

@@ -4,6 +4,7 @@ import {
resolveTitleBarToggleIconKey,
resolveWindowsScaleCheckDelayMs,
shouldApplyWindowsScaleFix,
shouldResetWebViewZoomForScaleFix,
shouldToggleMaximisedWindowForScaleFix,
} from './windowStateUi';
@@ -26,6 +27,13 @@ describe('windowStateUi', () => {
expect(shouldToggleMaximisedWindowForScaleFix('restore', true)).toBe(false);
});
it('only calls the backend WebView2 zoom reset after a real restore drift', () => {
expect(shouldResetWebViewZoomForScaleFix('restore', true)).toBe(true);
expect(shouldResetWebViewZoomForScaleFix('restore', false)).toBe(false);
expect(shouldResetWebViewZoomForScaleFix('activation', true)).toBe(false);
expect(shouldResetWebViewZoomForScaleFix('ratio-change', true)).toBe(false);
});
it('debounces resize-triggered Windows scale checks until window transitions settle', () => {
expect(resolveWindowsScaleCheckDelayMs('resize')).toBeGreaterThan(0);
expect(resolveWindowsScaleCheckDelayMs('focus')).toBe(0);

View File

@@ -20,6 +20,11 @@ export const shouldToggleMaximisedWindowForScaleFix = (
hasViewportScaleDrift: boolean,
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
export const shouldResetWebViewZoomForScaleFix = (
reason: WindowScaleFixReason,
hasViewportScaleDrift: boolean,
): boolean => reason === 'restore' && hasViewportScaleDrift;
export const resolveWindowsScaleCheckDelayMs = (trigger: WindowsScaleCheckTrigger): number =>
trigger === 'resize' ? 240 : 0;

View File

@@ -136,7 +136,16 @@ func (a *App) SetMacNativeWindowControls(enabled bool) {
// ResetWebViewZoom 把 WebView2 zoom factor 强制重置为 1.0,让 WebView2 重算字体度量。
// 用于 Windows 任务栏恢复后字体异常变大的"零感知"修复:不动窗口、零动画。
// 仅 Windows 上生效,其他平台返回错误(前端按需忽略)。
func (a *App) ResetWebViewZoom() connection.QueryResult {
func (a *App) ResetWebViewZoom() (result connection.QueryResult) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Errorf("重置 WebView2 zoom 失败:%v", recovered)
result = connection.QueryResult{
Success: false,
Message: fmt.Sprintf("重置 WebView2 zoom 失败:%v", recovered),
}
}
}()
if err := resetWebViewZoomFactor(a.ctx, 1.0); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}

View File

@@ -947,7 +947,15 @@ func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error
logger.Infof("启动 Windows 更新脚本target=%s script=%s log=%s", targetExe, scriptPath, logPath)
cmd := buildWindowsLaunchCommand(scriptPath)
return cmd.Start()
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
}
}
return nil
}
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
@@ -993,6 +1001,7 @@ set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0
call :log updater started
if not exist "%SOURCE%" (
@@ -1035,7 +1044,12 @@ if /I "%SOURCE_EXT%"==".zip" (
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
if !WAIT_PID_SECONDS! GEQ 90 (
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
exit /b 1
)
timeout /t 1 /nobreak >nul
set /a WAIT_PID_SECONDS+=1
goto waitloop
)
call :log host process exited
@@ -1108,7 +1122,9 @@ exit /b 0
}
func buildWindowsLaunchCommand(scriptPath string) *exec.Cmd {
return exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
cmd := exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
configureWindowsUpdateCommand(cmd)
return cmd
}
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {

View File

@@ -0,0 +1,19 @@
//go:build windows
package app
import "testing"
func TestBuildWindowsLaunchCommandHidesConsoleWindow(t *testing.T) {
cmd := buildWindowsLaunchCommand(`C:\tmp\gonavi-update\update.cmd`)
if cmd.SysProcAttr == nil {
t.Fatalf("expected Windows update launcher to configure SysProcAttr")
}
if !cmd.SysProcAttr.HideWindow {
t.Fatalf("expected Windows update launcher to hide the console window")
}
if cmd.SysProcAttr.CreationFlags&windowsCreateNoWindow == 0 {
t.Fatalf("expected Windows update launcher to set CREATE_NO_WINDOW, flags=%#x", cmd.SysProcAttr.CreationFlags)
}
}

View File

@@ -64,6 +64,7 @@ func TestBuildWindowsScriptWin10Fixes(t *testing.T) {
{"exponential backoff tier 2", `if !RETRY! GEQ 6 set /a WAIT=3`},
{"exponential backoff tier 3", `if !RETRY! GEQ 9 set /a WAIT=5`},
{"retry limit 15", `if !RETRY! LSS 15`},
{"host exit wait timeout", `if !WAIT_PID_SECONDS! GEQ 90 (`},
{"cleanup old file", `del /F /Q "%TARGET_OLD%"`},
}
for _, fix := range win10Fixes {
@@ -110,7 +111,7 @@ func TestBuildWindowsScriptUsesDelayedErrorlevelInsideBlocks(t *testing.T) {
}
}
func TestBuildWindowsLaunchCommandUsesDirectCall(t *testing.T) {
func TestBuildWindowsLaunchCommandUsesDirectHiddenCall(t *testing.T) {
cmd := buildWindowsLaunchCommand(`C:\tmp\gonavi-update\update.cmd`)
if !strings.EqualFold(cmd.Args[0], cmd.Path) && !strings.HasSuffix(strings.ToLower(cmd.Path), `\cmd.exe`) {

View File

@@ -26,7 +26,12 @@ import (
//
// **依赖 wails v2.11/v2.12 内部实现细节**:如果 wails 升级改名了 frontend.chromium 字段或
// edge.Chromium.PutZoomFactor 方法名,此函数会返回 error。CI 中应该有跨版本兼容性测试。
func resetWebViewZoomFactor(ctx context.Context, factor float64) error {
func resetWebViewZoomFactor(ctx context.Context, factor float64) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("reset WebView2 zoom panic: %v", recovered)
}
}()
if ctx == nil {
return fmt.Errorf("ctx is nil")
}

View File

@@ -28,6 +28,16 @@ type fakeFrontend struct {
chromium *fakeChromium
}
type panicChromium struct{}
func (p *panicChromium) PutZoomFactor(float64) {
panic("webview2 zoom reset failed")
}
type panicFrontend struct {
chromium *panicChromium
}
// 测试必须用 wails 一致的 string key "frontend" 作为 context.WithValue 的 key
// 否则反射拿不到。go vet 会警告 string key用本地 stringContextKey 帮助函数封装来抑制。
// 这层封装等价于直接传字符串字面量,行为完全一致。
@@ -85,3 +95,15 @@ func TestResetWebViewZoomFactorErrorsWhenFrontendMissing(t *testing.T) {
t.Fatalf("expected error to mention frontend, got %v", err)
}
}
func TestResetWebViewZoomFactorRecoversFromPutZoomFactorPanic(t *testing.T) {
ctx := context.WithValue(context.Background(), stringContextKey("frontend"), &panicFrontend{chromium: &panicChromium{}})
err := resetWebViewZoomFactor(ctx, 1.0)
if err == nil {
t.Fatal("expected panic to be converted to error, got nil")
}
if !strings.Contains(err.Error(), "panic") {
t.Fatalf("expected error to mention panic, got %v", err)
}
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package app
import "os/exec"
func configureWindowsUpdateCommand(_ *exec.Cmd) {}

View File

@@ -0,0 +1,20 @@
//go:build windows
package app
import (
"os/exec"
"syscall"
)
const windowsCreateNoWindow = 0x08000000
func configureWindowsUpdateCommand(cmd *exec.Cmd) {
if cmd == nil {
return
}
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windowsCreateNoWindow,
}
}