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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user