🐛 fix(update/macos): 加固在线更新自动安装脚本与进程隔离

- 安装脚本 Setsid 脱离主进程,避免 Quit 后脚本被连带结束
- 等待宿主退出增加 120s 超时,防止无限挂起
- 支持 dmg/zip 解包定位 .app,稳健解析 Contents/MacOS 可执行文件
- 不再 rm -rf 脚本所在 STAGED 目录,降低安装中途自删风险
- 替换失败明确走管理员权限回退,增强 relaunch(open -n -a / nohup)
- 补充 macOS 更新脚本单测
This commit is contained in:
Syngnat
2026-07-09 13:35:41 +08:00
parent d067e2c00a
commit d31bb978f2
4 changed files with 246 additions and 34 deletions

View File

@@ -1622,9 +1622,19 @@ func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
return err
}
// 用 bash 执行脚本Setsid 脱离主进程会话,避免 Quit 后脚本被 SIGHUP
cmd := exec.Command("/bin/bash", scriptPath)
logger.Infof("启动 macOS 更新脚本target=%s script=%s log=%s", targetApp, scriptPath, logPath)
return cmd.Start()
configureDetachedUpdateCommand(cmd)
logger.Infof("启动 macOS 更新脚本target=%s script=%s log=%s package=%s", targetApp, scriptPath, logPath, staged.FilePath)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 macOS 更新脚本进程句柄失败:%v", err)
}
}
return nil
}
func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
@@ -1635,7 +1645,14 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
}
cmd := exec.Command("/bin/sh", scriptPath)
return cmd.Start()
configureDetachedUpdateCommand(cmd)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
_ = cmd.Process.Release()
}
return nil
}
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
@@ -1781,22 +1798,101 @@ func buildWindowsLaunchCommand(scriptPath string) *exec.Cmd {
return cmd
}
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
func buildMacScript(packagePath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -euo pipefail
set -uo pipefail
PID=%d
DMG="%s"
PACKAGE="%s"
TARGET_APP="%s"
STAGED="%s"
MOUNT_DIR="%s"
LOG_FILE="%s"
TMP_APP="${TARGET_APP}.new"
BACKUP_APP="${TARGET_APP}.backup"
APP_BIN_NAME=$(basename "$TARGET_APP" .app)
APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME"
EXTRACT_DIR="${STAGED}/_extract"
WAIT_PID_SECONDS=0
MAX_WAIT_PID_SECONDS=120
APP_SRC=""
APP_BIN_REL=""
DETACH_NEEDED=0
log() {
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE"
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE" 2>/dev/null || true
}
cleanup_mount() {
if [ "$DETACH_NEEDED" = "1" ]; then
/usr/bin/hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || \
/usr/bin/hdiutil detach "$MOUNT_DIR" -force -quiet >>"$LOG_FILE" 2>&1 || true
DETACH_NEEDED=0
fi
}
resolve_app_binary_rel() {
local app_root="$1"
local preferred
preferred=$(basename "$TARGET_APP" .app)
if [ -n "$preferred" ] && [ -x "$app_root/Contents/MacOS/$preferred" ]; then
APP_BIN_REL="Contents/MacOS/$preferred"
return 0
fi
local found
found=$(/usr/bin/find "$app_root/Contents/MacOS" -maxdepth 1 -type f -perm -111 2>/dev/null | /usr/bin/head -n 1 || true)
if [ -n "$found" ]; then
APP_BIN_REL="Contents/MacOS/$(basename "$found")"
return 0
fi
return 1
}
prepare_app_source_from_package() {
local ext
ext=$(printf '%%s' "${PACKAGE##*.}" | tr '[:upper:]' '[:lower:]')
case "$ext" in
dmg)
log "attaching dmg: $PACKAGE"
/bin/mkdir -p "$MOUNT_DIR" >>"$LOG_FILE" 2>&1 || true
if ! /usr/bin/hdiutil attach "$PACKAGE" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1; then
log "hdiutil attach failed, retry without quiet"
if ! /usr/bin/hdiutil attach "$PACKAGE" -nobrowse -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1; then
log "hdiutil attach failed for $PACKAGE"
return 1
fi
fi
DETACH_NEEDED=1
APP_SRC=$(/usr/bin/find "$MOUNT_DIR" -maxdepth 2 -name "*.app" -type d 2>/dev/null | /usr/bin/head -n 1 || true)
if [ -z "$APP_SRC" ]; then
log "no .app found inside dmg mount: $MOUNT_DIR"
return 1
fi
;;
zip)
log "extracting zip package: $PACKAGE"
/bin/rm -rf "$EXTRACT_DIR" >>"$LOG_FILE" 2>&1 || true
/bin/mkdir -p "$EXTRACT_DIR" >>"$LOG_FILE" 2>&1 || true
if ! /usr/bin/ditto -x -k "$PACKAGE" "$EXTRACT_DIR" >>"$LOG_FILE" 2>&1; then
if ! /usr/bin/unzip -qo "$PACKAGE" -d "$EXTRACT_DIR" >>"$LOG_FILE" 2>&1; then
log "extract zip failed: $PACKAGE"
return 1
fi
fi
APP_SRC=$(/usr/bin/find "$EXTRACT_DIR" -maxdepth 3 -name "*.app" -type d 2>/dev/null | /usr/bin/head -n 1 || true)
if [ -z "$APP_SRC" ]; then
log "no .app found inside zip: $PACKAGE"
return 1
fi
;;
*)
log "unsupported mac package type: $PACKAGE"
return 1
;;
esac
if ! resolve_app_binary_rel "$APP_SRC"; then
log "no executable found in package app: $APP_SRC"
return 1
fi
log "package app source: $APP_SRC binary=$APP_BIN_REL"
return 0
}
run_admin_replace() {
@@ -1823,68 +1919,92 @@ APPLESCRIPT
}
replace_app_direct() {
rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
/bin/rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
/usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1
if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then
log "tmp app binary missing: $TMP_APP/$APP_BIN_REL"
return 1
fi
xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true
/usr/bin/xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true
if [ -d "$TARGET_APP" ]; then
mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1
/bin/mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1
fi
if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
if ! /bin/mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
log "move new app failed, trying rollback"
rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
/bin/rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
if [ -d "$BACKUP_APP" ]; then
mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
/bin/mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
fi
return 1
fi
rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
/bin/rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
/usr/bin/xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
return 0
}
relaunch_app() {
if /usr/bin/open -n -a "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
return 0
fi
if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
return 0
fi
log "open -n failed, trying binary launch"
"$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
log "open failed, trying binary launch"
nohup "$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
return 0
}
log "updater started"
while kill -0 $PID 2>/dev/null; do
sleep 1
log "updater started package=$PACKAGE target=$TARGET_APP pid=$PID"
while /bin/kill -0 "$PID" 2>/dev/null; do
if [ "$WAIT_PID_SECONDS" -ge "$MAX_WAIT_PID_SECONDS" ]; then
log "host process still running after ${WAIT_PID_SECONDS}s, aborting update"
exit 1
fi
/bin/sleep 1
WAIT_PID_SECONDS=$((WAIT_PID_SECONDS + 1))
done
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
log "no .app found inside dmg"
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
log "host process exited after ${WAIT_PID_SECONDS}s"
/bin/sleep 1
if [ ! -f "$PACKAGE" ]; then
log "package file missing: $PACKAGE"
exit 1
fi
if ! prepare_app_source_from_package; then
cleanup_mount
exit 1
fi
log "install target: $TARGET_APP"
if ! replace_app_direct; then
log "direct replace failed, trying admin replace"
run_admin_replace >>"$LOG_FILE" 2>&1
if ! run_admin_replace >>"$LOG_FILE" 2>&1; then
log "admin replace failed"
cleanup_mount
exit 1
fi
fi
if ! resolve_app_binary_rel "$TARGET_APP"; then
log "target app binary missing after replace: $TARGET_APP"
cleanup_mount
exit 1
fi
if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then
log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL"
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
log "target app binary not executable: $TARGET_APP/$APP_BIN_REL"
cleanup_mount
exit 1
fi
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true
cleanup_mount
# 不要删除 STAGED脚本自身所在目录只删安装包与临时解压目录
/bin/rm -rf "$EXTRACT_DIR" >>"$LOG_FILE" 2>&1 || true
/bin/rm -f "$PACKAGE" >>"$LOG_FILE" 2>&1 || true
relaunch_app
log "relaunch requested"
`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath)
exit 0
`, pid, packagePath, targetApp, stagedDir, mountDir, logPath)
}
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {

View File

@@ -0,0 +1,61 @@
package app
import (
"strings"
"testing"
)
func TestBuildMacScriptContainsHardeningGuards(t *testing.T) {
script := buildMacScript(
"/tmp/GoNavi-1.2.3-MacOS-Arm64.dmg",
"/Applications/GoNavi.app",
"/tmp/stage",
"/tmp/stage/mnt",
"/tmp/gonavi-update-macos.log",
4242,
)
mustContain := []string{
"MAX_WAIT_PID_SECONDS=120",
"hdiutil attach",
"prepare_app_source_from_package",
"resolve_app_binary_rel",
"replace_app_direct",
"run_admin_replace",
"relaunch_app",
"open -n -a",
// 安装包扩展名分支
"dmg)",
"zip)",
// 不要在脚本运行中 rm -rf 整个 STAGED脚本自身所在目录
`/bin/rm -f "$PACKAGE"`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
t.Fatalf("mac update script missing required token %q\nscript:\n%s", token, script)
}
}
if strings.Contains(script, `rm -rf "$MOUNT_DIR" "$DMG" "$STAGED"`) {
t.Fatal("mac update script must not delete STAGED while the script may still be running from it")
}
if !strings.Contains(script, "/tmp/GoNavi-1.2.3-MacOS-Arm64.dmg") {
t.Fatal("expected package path embedded in script")
}
if !strings.Contains(script, "/Applications/GoNavi.app") {
t.Fatal("expected target app path embedded in script")
}
if !strings.Contains(script, "PID=4242") {
t.Fatal("expected host pid embedded in script")
}
}
func TestResolveMacUpdateTargetFallsBackFromAppTranslocation(t *testing.T) {
got := resolveMacUpdateTarget("/private/var/folders/xx/AppTranslocation/ABC/d/GoNavi.app/Contents/MacOS/GoNavi")
if got != "/Applications/GoNavi.app" {
t.Fatalf("expected AppTranslocation fallback, got %q", got)
}
got = resolveMacUpdateTarget("/Applications/GoNavi.app/Contents/MacOS/GoNavi")
if got != "/Applications/GoNavi.app" {
t.Fatalf("expected normal app bundle path, got %q", got)
}
}

View File

@@ -0,0 +1,21 @@
//go:build !windows
package app
import (
"os/exec"
"syscall"
)
// configureDetachedUpdateCommand 让安装脚本脱离主进程会话,避免 Quit/Exit 后脚本被连带结束。
func configureDetachedUpdateCommand(cmd *exec.Cmd) {
if cmd == nil {
return
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
}

View File

@@ -0,0 +1,10 @@
//go:build windows
package app
import "os/exec"
// Windows 安装脚本已通过 configureWindowsUpdateCommand 做 CREATE_NO_WINDOW 等隔离。
func configureDetachedUpdateCommand(cmd *exec.Cmd) {
configureWindowsUpdateCommand(cmd)
}