fix(client/update): macOS 自更新失败韧性——回滚校验/提权自愈防换丢+codesign验签闸+超时不硬动+退出码检

- C3(critical): swap_plain 静默回滚后校验 $APP 是否真的复原(引入返回码语义 0/1/2/3),
  提权 ROOT_SH 改为自愈式(优先从 $BACKUP 复原、$NEW_APP 兜底直接就位),不再假设 $APP
  一定存在;新增终检,任何失败组合下都保证 /Applications 里最终有一个可用 app,不再
  出现"app 消失、零提示"。
- I1: PID 等待超时(~30s)后追加 5s 宽限,仍未退出则放弃换装、不硬动运行中的 bundle。
- I2: 换装前对 $NEW_APP 跑 codesign --verify --deep --strict,验签不过拒绝换装(回退访达)。
- I3: open "$APP" 检退出码,失败记日志(Gatekeeper/TCC 拦截时不再误记 relaunched)。
- M1: Dart 侧 helper chmod +x 检退出码,失败清理半截暂存目录;shell 侧 ROOT_SH chmod
  同样检退出码。

正常成功路径行为不变;helper 抽出后 sh -n/bash -n 语法检查通过;flutter analyze 0 issues。
逐失败分支(自愈从 BACKUP 复原、仅剩 NEW_APP 兜底就位、彻底不可恢复诚实失败)已用隔离目录
跑真实 mv 逻辑人工核对,报告见 .superpowers/sdd/fix-macos-updater-report.md。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 15:52:35 +08:00
parent 1e102a5a79
commit 3acd3ca59d
+95 -24
View File
@@ -166,7 +166,15 @@ Future<void> _macInstall(String zipPath) async {
// 写 helper、分离启动、退出让其换装重启。参数:PID/现.app/新.app/暂存/zip。
final helper = '$staging/pangolin-update.sh';
await File(helper).writeAsString(_macUpdateHelper);
await Process.run('/bin/chmod', <String>['+x', helper]);
final chmod = await Process.run('/bin/chmod', <String>['+x', helper]);
if (chmod.exitCode != 0) {
// helper 跑不起来:清掉半截暂存(解压出的新 app + 不可执行的 helper),回退访达手动流程。
try {
await Directory(staging).delete(recursive: true);
} catch (_) {}
await _macReveal(zipPath);
return;
}
await Process.start(
'/bin/sh',
<String>[helper, '$pid', appPath, newApp, staging, zipPath],
@@ -195,8 +203,12 @@ Future<String?> _findDotApp(String dir) async {
return null;
}
/// macOS 自更新 helper 脚本(分离进程跑):等主 app 退出 → 原子换 bundle(失败回滚,
/// 属主非本人则 osascript 提权)→ 清 quarantine → open 重启。
/// macOS 自更新 helper 脚本(分离进程跑):等主 app 完全退出(超时+宽限后仍未退出则放弃,
/// 不硬动运行中的 bundle)→ codesign 验签新版(不过拒绝换装,Developer-ID 完整性闸)→
/// 原子换 bundle(失败必回滚并校验回滚是否真的成功,不假设 mv 一定成功;仍失败则 osascript
/// 提权,提权脚本自身具备「从 $BACKUP/$NEW_APP 自愈」逻辑,不假设 $APP 一定还在)→ 终检
/// $APP 确实存在才继续 → 清 quarantine → open 重启(检退出码,失败记日志尽力而为)。
/// 任何失败组合下都保证 /Applications 不会出现「app 消失、零提示」。
const String _macUpdateHelper = r'''#!/bin/sh
# Pangolin macOS 自更新 helper —— 由 app_updater.dart 生成、分离进程启动。
# 参数:PID(主app进程) APP(现.app) NEW_APP(解压出的新.app) STAGING(暂存目录) ZIP(下载的zip,清理用)
@@ -204,52 +216,111 @@ PID="$1"; APP="$2"; NEW_APP="$3"; STAGING="$4"; ZIP="$5"
exec >>"$STAGING/update.log" 2>&1
echo "[helper] start pid=$PID app=$APP new=$NEW_APP"
# 1. 等主 app 完全退出(最多 ~30s 兜底)
BACKUP="${APP}.pangolin-old"
# 放弃自动换装:访达定位新版供用户手动拖,保留现场(STAGING/BACKUP)便于排查/手动恢复。
fallback_reveal() {
echo "[helper] fallback: $1"
/usr/bin/open -R "$NEW_APP" 2>/dev/null
}
# 1. 等主 app 完全退出(最多 ~30s;超时再给 5s 宽限;仍未退出则放弃,不硬动运行中的 bundle)
i=0
while kill -0 "$PID" 2>/dev/null; do
sleep 0.3; i=$((i+1))
[ "$i" -gt 100 ] && { echo "[helper] wait timeout"; break; }
[ "$i" -gt 100 ] && break
done
if kill -0 "$PID" 2>/dev/null; then
echo "[helper] wait timeout at ~30s, grace +5s"
sleep 5
if kill -0 "$PID" 2>/dev/null; then
fallback_reveal "app still running after grace period, refusing to touch bundle"
exit 1
fi
fi
sleep 0.5
BACKUP="${APP}.pangolin-old"
# 2. 签名验签闸:换装前必须验证新版签名完整(Developer-ID 分发完整性),不过拒绝换装
if ! /usr/bin/codesign --verify --deep --strict "$NEW_APP" 2>/dev/null; then
fallback_reveal "codesign verify failed on new app, refusing swap"
exit 1
fi
# 2. 静默换:mv 旧→备份 → mv 新→原位;任何一步失败自动回滚(admin 用户对 /Applications 可写 → 无弹窗)
# 3. 静默换装(无提权):自愈式 swap —— 若 $APP 因上次失败缺失但 $BACKUP 还在,先自愈复原再换;
# 换装失败一律回滚,并校验回滚是否真的成功(不假设 mv 一定成功)。
# 返回码:0=成功 1=失败但 $APP 完好(未动/已回滚) 2=$APP 缺失且无 $BACKUP 可自愈 3=回滚也失败($APP 缺失,严重)
swap_plain() {
if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then
/bin/mv "$BACKUP" "$APP" 2>/dev/null
fi
if [ ! -d "$APP" ]; then
return 2
fi
/bin/rm -rf "$BACKUP" 2>/dev/null
/bin/mv "$APP" "$BACKUP" 2>/dev/null || return 1
/bin/mv "$NEW_APP" "$APP" 2>/dev/null || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; return 1; }
/bin/rm -rf "$BACKUP" 2>/dev/null
return 0
if /bin/mv "$NEW_APP" "$APP" 2>/dev/null; then
/bin/rm -rf "$BACKUP" 2>/dev/null
return 0
fi
/bin/mv "$BACKUP" "$APP" 2>/dev/null
if [ -d "$APP" ]; then
return 1
fi
return 3
}
if swap_plain; then
swap_plain
rc=$?
if [ "$rc" -eq 0 ]; then
echo "[helper] swap_plain ok"
else
echo "[helper] swap_plain failed -> osascript 提权"
# 3. 提权兜底:把带路径的换装命令写进 root 脚本,osascript 弹一次系统密码框以 root 跑
echo "[helper] swap_plain failed (rc=$rc) -> osascript 提权自愈"
# 4. 提权兜底:ROOT_SH 自身自愈,不假设 $APP 一定存在——
# 优先从 $BACKUP 复原、再正常 swap;$APP/$BACKUP 都没了则以 $NEW_APP 直接就位兜底。
ROOT_SH="$STAGING/swap-root.sh"
{
echo '#!/bin/sh'
echo "/bin/rm -rf \"$BACKUP\""
echo "/bin/mv \"$APP\" \"$BACKUP\" || exit 1"
echo "/bin/mv \"$NEW_APP\" \"$APP\" || { /bin/mv \"$BACKUP\" \"$APP\"; exit 1; }"
echo "/bin/rm -rf \"$BACKUP\""
echo "APP=\"$APP\""
echo "NEW_APP=\"$NEW_APP\""
echo "BACKUP=\"$BACKUP\""
echo 'if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then /bin/mv "$BACKUP" "$APP"; fi'
echo 'if [ ! -d "$APP" ] && [ -d "$NEW_APP" ]; then'
echo ' /bin/mv "$NEW_APP" "$APP" || exit 1'
echo ' exit 0'
echo 'fi'
echo 'if [ ! -d "$APP" ]; then exit 1; fi'
echo '/bin/rm -rf "$BACKUP"'
echo '/bin/mv "$APP" "$BACKUP" || exit 1'
echo '/bin/mv "$NEW_APP" "$APP" || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; exit 1; }'
echo '/bin/rm -rf "$BACKUP"'
echo 'exit 0'
} > "$ROOT_SH"
/bin/chmod +x "$ROOT_SH"
if ! /bin/chmod +x "$ROOT_SH"; then
fallback_reveal "chmod ROOT_SH failed"
exit 1
fi
if ! /usr/bin/osascript -e "do shell script \"/bin/sh '$ROOT_SH'\" with administrator privileges"; then
echo "[helper] osascript failed/canceled -> 回退访达定位"
/usr/bin/open -R "$NEW_APP"
fallback_reveal "osascript failed or canceled"
exit 1
fi
fi
# 4. 清 quarantine(已公证+staple,防御性)+ open 重启新版
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null
/usr/bin/open "$APP"
echo "[helper] relaunched"
# 5. 终检:/Applications 里必须有可用的 app 才继续,否则宁可停手也不再往下动(不清 quarantine、不 open)
if [ ! -d "$APP" ]; then
fallback_reveal "final check: app still missing after all recovery attempts"
exit 1
fi
# 5. 清
# 6. 清 quarantine(已公证+staple,防御性)+ open 重启新版;open 失败(Gatekeeper/TCC 拦截)记日志,尽力而为
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null
if /usr/bin/open "$APP"; then
echo "[helper] relaunched"
else
echo "[helper] open \"$APP\" failed, app updated on disk but not launched (Gatekeeper/TCC?); user needs to open manually"
fi
# 7. 清理(仅在换装成功、$APP 确认可用后才清;失败路径保留现场供排查)
/bin/rm -f "$ZIP" 2>/dev/null
/bin/rm -rf "$STAGING/new" 2>/dev/null
exit 0