diff --git a/client/lib/core/update/app_updater.dart b/client/lib/core/update/app_updater.dart index f11155c..92e6b29 100644 --- a/client/lib/core/update/app_updater.dart +++ b/client/lib/core/update/app_updater.dart @@ -166,7 +166,15 @@ Future _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', ['+x', helper]); + final chmod = await Process.run('/bin/chmod', ['+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', [helper, '$pid', appPath, newApp, staging, zipPath], @@ -195,8 +203,12 @@ Future _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