🔧 fix(updater): 修复Mac更新重启无效并增强Windows便携替换可靠性

- 修复 macOS 点击“立即重启”后无反应,增加 Quit 后兜底退出
- 增强 macOS 更新脚本:日志、AppTranslocation 目标回退、管理员权限回退与自动 xattr 清除 quarantine
- 增强 Windows 便携更新:move/copy 重试、失败可观测日志、保留非提权替换策略
This commit is contained in:
Syngnat
2026-02-06 12:12:45 +08:00
parent e8ef6b0b38
commit c2c88d743b

View File

@@ -28,9 +28,9 @@ const (
)
type updateState struct {
lastCheck *UpdateInfo
lastCheck *UpdateInfo
downloading bool
staged *stagedUpdate
staged *stagedUpdate
}
type UpdateInfo struct {
@@ -46,12 +46,12 @@ type UpdateInfo struct {
}
type AppInfo struct {
Version string `json:"version"`
Author string `json:"author"`
RepoURL string `json:"repoUrl,omitempty"`
IssueURL string `json:"issueUrl,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
RepoURL string `json:"repoUrl,omitempty"`
IssueURL string `json:"issueUrl,omitempty"`
ReleaseURL string `json:"releaseUrl,omitempty"`
BuildTime string `json:"buildTime,omitempty"`
BuildTime string `json:"buildTime,omitempty"`
}
type stagedUpdate struct {
@@ -62,11 +62,11 @@ type stagedUpdate struct {
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
@@ -95,12 +95,12 @@ func (a *App) CheckForUpdates() connection.QueryResult {
func (a *App) GetAppInfo() connection.QueryResult {
info := AppInfo{
Version: getCurrentVersion(),
Author: getCurrentAuthor(),
RepoURL: "https://github.com/" + updateRepo,
IssueURL: "https://github.com/" + updateRepo + "/issues",
Version: getCurrentVersion(),
Author: getCurrentAuthor(),
RepoURL: "https://github.com/" + updateRepo,
IssueURL: "https://github.com/" + updateRepo + "/issues",
ReleaseURL: "https://github.com/" + updateRepo + "/releases",
BuildTime: strings.TrimSpace(AppBuildTime),
BuildTime: strings.TrimSpace(AppBuildTime),
}
return connection.QueryResult{Success: true, Message: "OK", Data: info}
}
@@ -156,6 +156,9 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
go func() {
time.Sleep(300 * time.Millisecond)
wailsRuntime.Quit(a.ctx)
// 兜底退出,避免某些平台/窗口状态下 Quit 未真正结束进程,导致更新脚本一直等待。
time.Sleep(2 * time.Second)
os.Exit(0)
}()
return connection.QueryResult{Success: true, Message: "更新已开始安装"}
@@ -422,32 +425,33 @@ func launchUpdateScript(staged *stagedUpdate) error {
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, pid)
logPath := filepath.Join(staged.StagedDir, "update.log")
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本target=%s script=%s log=%s", targetExe, scriptPath, logPath)
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
return cmd.Start()
}
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
targetApp := detectMacAppPath(targetExe)
if targetApp == "" {
targetApp = "/Applications/GoNavi.app"
}
targetApp := resolveMacUpdateTarget(targetExe)
mountDir := filepath.Join(staged.StagedDir, "mnt")
if err := os.MkdirAll(mountDir, 0o755); err != nil {
return err
}
logPath := filepath.Join(staged.StagedDir, "update.log")
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, pid)
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return err
}
cmd := exec.Command("/bin/sh", scriptPath)
cmd := exec.Command("/bin/bash", scriptPath)
logger.Infof("启动 macOS 更新脚本target=%s script=%s log=%s", targetApp, scriptPath, logPath)
return cmd.Start()
}
@@ -462,49 +466,126 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
return cmd.Start()
}
func buildWindowsScript(source, target, stagedDir string, pid int) string {
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
return fmt.Sprintf(`@echo off
setlocal
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=%s"
set "TARGET=%s"
set "STAGED=%s"
set "LOG_FILE=%s"
set PID=%d
call :log updater started
if not exist "%%SOURCE%%" (
call :log source file not found: %%SOURCE%%
exit /b 1
)
:waitloop
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
if %%ERRORLEVEL%%==0 (
timeout /t 1 /nobreak >nul
goto waitloop
)
move /Y "%%SOURCE%%" "%%TARGET%%" >nul
start "" "%%TARGET%%"
rmdir /S /Q "%%STAGED%%"
call :log host process exited
set /a RETRY=0
:move_retry
move /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%%==0 goto move_done
copy /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 20 (
timeout /t 1 /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%% NEQ 0 (
call :log relaunch failed
exit /b 1
)
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
call :log update finished
exit /b 0
`, source, target, stagedDir, pid)
:log
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
exit /b 0
`, source, target, stagedDir, logPath, pid)
}
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir string, pid int) string {
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -e
set -euo pipefail
PID=%d
DMG="%s"
TARGET_APP="%s"
STAGED="%s"
MOUNT_DIR="%s"
LOG_FILE="%s"
log() {
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE"
}
run_admin_install() {
/usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$LOG_FILE"
on run argv
set srcPath to item 1 of argv
set dstPath to item 2 of argv
set logPath to item 3 of argv
do shell script "rm -rf " & quoted form of dstPath & " && cp -R " & quoted form of srcPath & " " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges
end run
APPLESCRIPT
}
run_admin_xattr() {
/usr/bin/osascript <<'APPLESCRIPT' "$TARGET_APP" "$LOG_FILE"
on run argv
set dstPath to item 1 of argv
set logPath to item 2 of argv
do shell script "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges
end run
APPLESCRIPT
}
log "updater started"
while kill -0 $PID 2>/dev/null; do
sleep 1
done
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR"
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1)
log "host process exited"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1 || true)
if [ -z "$APP_SRC" ]; then
hdiutil detach "$MOUNT_DIR" -quiet || true
log "no .app found inside dmg"
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
exit 1
fi
rm -rf "$TARGET_APP"
cp -R "$APP_SRC" "$TARGET_APP"
hdiutil detach "$MOUNT_DIR" -quiet
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"
open "$TARGET_APP"
`, pid, dmgPath, targetApp, stagedDir, mountDir)
log "install target: $TARGET_APP"
if ! rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || ! cp -R "$APP_SRC" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
log "direct install failed, trying admin install"
run_admin_install >>"$LOG_FILE" 2>&1
fi
if ! xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
log "direct xattr failed, trying admin xattr"
run_admin_xattr >>"$LOG_FILE" 2>&1 || true
fi
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true
open "$TARGET_APP" >>"$LOG_FILE" 2>&1
log "relaunch requested"
`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath)
}
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
@@ -543,6 +624,20 @@ func detectMacAppPath(exePath string) string {
return ""
}
func resolveMacUpdateTarget(exePath string) string {
targetApp := detectMacAppPath(exePath)
if targetApp == "" {
return "/Applications/GoNavi.app"
}
targetApp = filepath.Clean(targetApp)
// Gatekeeper App Translocation 路径不可用于稳定覆盖更新,统一回退到 /Applications。
if strings.Contains(targetApp, string(filepath.Separator)+"AppTranslocation"+string(filepath.Separator)) {
logger.Warnf("检测到 AppTranslocation 运行路径,更新目标回退至 /Applications/GoNavi.app%s", targetApp)
return "/Applications/GoNavi.app"
}
return targetApp
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")