From b70295778843de044a82ae11a04170a2715cb7f4 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 12 Jul 2026 21:55:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(client/update):=20macOS=20=E8=87=AA?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=94=B9=E4=B8=BA=E8=87=AA=E5=8A=A8=E8=A3=85?= =?UTF-8?q?=E8=BF=9B=20/Applications=20+=20=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原「下载到 Downloads → 访达定位 → 手动拖入」半自动流程改为全自动: - 下 zip → ditto 解压到暂存 → 写分离 helper 脚本、Process.start(detached)、exit(0) - helper 等主进程退出 → 原子换 bundle(mv 旧→.old → mv 新→原位,任一步失败自动回滚) → 清 quarantine → open 重启新版 - 权限:主 app 未开沙箱;admin 用户对 /Applications 可写 → 静默无弹窗(案例①,绝大多数) .app 属主非本人 → osascript 弹一次系统原生密码框提权(案例②/③) - 兜底:非 /Applications/不可写/解压失败/提权取消 → 回退旧的访达定位手动流程,绝不残废 app - 不碰 sysext:运行中的扩展从 /Library/SystemExtensions 跑,换 .app 与手动拖同路径, 新版启动照常 OSSystemExtensionRequest 验证:flutter analyze 干净;helper sh -n + shellcheck 通过;假 bundle dry-run 换装+重启+清理跑通。 Co-Authored-By: Claude Opus 4.8 (1M context) --- client/lib/core/update/app_updater.dart | 135 +++++++++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/client/lib/core/update/app_updater.dart b/client/lib/core/update/app_updater.dart index 356de55..f11155c 100644 --- a/client/lib/core/update/app_updater.dart +++ b/client/lib/core/update/app_updater.dart @@ -4,8 +4,11 @@ // - Android:http 流式下 APK 到临时目录 → open_filex 拉起系统安装器 // (open_filex 内部封装 FileProvider;manifest 需 REQUEST_INSTALL_PACKAGES) // - Windows:下 exe → Process.start(detached) 起安装 → exit(0) 让其覆盖 -// - macOS :下 zip → open 解压 + 访达显示,提示手动拖入「应用程序」 -// (本 app 带系统扩展,不自动替换 bundle,避免破坏 sysext) +// - macOS :下 zip → ditto 解压 → 分离 helper 等主进程退出后原子换 /Applications +// 里的 .app + open 重启(未开沙箱,admin 用户可写 /Applications → 全程无弹窗; +// .app 属主非本人则 osascript 弹一次系统密码框提权;不可写/异常则回退访达定位手动拖)。 +// 运行中的 sysext 从 /Library/SystemExtensions 跑,换 .app 不影响它—— +// 与手动拖入同路径,新版启动照常走 OSSystemExtensionRequest。 // - iOS :不能 App 内装 → 外链(TestFlight/App Store) // 进度用本地 ValueNotifier 驱动一个不可关闭的进度对话框;失败降级浏览器下载。 import 'dart:async'; @@ -124,12 +127,134 @@ Future _install(String path) async { exit(0); // 退出让安装器覆盖 } if (Platform.isMacOS) { - // 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。 - await Process.run('open', [path]); // Archive Utility 解压 - await Process.run('open', ['-R', path]); // 访达定位 + await _macInstall(path); // 成功则 exit(0) 不返回;回退则内部走访达定位后返回 } } +/// macOS 自动安装:解压 → 分离 helper 换 /Applications 的 .app + 重启。 +/// 成功路径 exit(0)(不返回);不满足条件/失败则回退访达定位(现状半自动流程)后正常返回, +/// 由调用方提示手动拖入。 +Future _macInstall(String zipPath) async { + final exe = Platform.resolvedExecutable; // …/pangolin_vpn.app/Contents/MacOS/pangolin_vpn + const marker = '/Contents/MacOS/'; + final mi = exe.indexOf(marker); + final appPath = mi > 0 ? exe.substring(0, mi) : ''; // …/pangolin_vpn.app + // 仅当能定位到 .app bundle 才尝试自动装;异常布局(如无 bundle 运行)回退。 + if (appPath.isEmpty || !appPath.toLowerCase().endsWith('.app')) { + await _macReveal(zipPath); + return; + } + + try { + final tmp = await getTemporaryDirectory(); + final staging = '${tmp.path}/pangolin-update-${DateTime.now().millisecondsSinceEpoch}'; + final extractDir = '$staging/new'; + await Directory(extractDir).create(recursive: true); + + // ditto 解 PKZip(zip 由 --keepParent 打,顶层含 pangolin_vpn.app);失败即回退。 + final ex = await Process.run('/usr/bin/ditto', ['-x', '-k', zipPath, extractDir]); + if (ex.exitCode != 0) { + await _macReveal(zipPath); + return; + } + final newApp = await _findDotApp(extractDir); + if (newApp == null) { + await _macReveal(zipPath); + return; + } + + // 写 helper、分离启动、退出让其换装重启。参数:PID/现.app/新.app/暂存/zip。 + final helper = '$staging/pangolin-update.sh'; + await File(helper).writeAsString(_macUpdateHelper); + await Process.run('/bin/chmod', ['+x', helper]); + await Process.start( + '/bin/sh', + [helper, '$pid', appPath, newApp, staging, zipPath], + mode: ProcessStartMode.detached, + ); + await Future.delayed(const Duration(milliseconds: 300)); + exit(0); // helper 等本进程退出后原子换 bundle + open 重启 + } catch (_) { + await _macReveal(zipPath); // 任何意外 → 保底手动流程 + } +} + +/// 回退:Archive Utility 解压 zip + 访达高亮(与旧版一致的半自动流程)。 +Future _macReveal(String zipPath) async { + await Process.run('open', [zipPath]); // 解压 + await Process.run('open', ['-R', zipPath]); // 访达定位 +} + +/// 在解压目录里找顶层 .app(ditto --keepParent 打的 zip 解出 pangolin_vpn.app)。 +Future _findDotApp(String dir) async { + final d = Directory(dir); + if (!await d.exists()) return null; + await for (final e in d.list(followLinks: false)) { + if (e is Directory && e.path.toLowerCase().endsWith('.app')) return e.path; + } + return null; +} + +/// macOS 自更新 helper 脚本(分离进程跑):等主 app 退出 → 原子换 bundle(失败回滚, +/// 属主非本人则 osascript 提权)→ 清 quarantine → open 重启。 +const String _macUpdateHelper = r'''#!/bin/sh +# Pangolin macOS 自更新 helper —— 由 app_updater.dart 生成、分离进程启动。 +# 参数:PID(主app进程) APP(现.app) NEW_APP(解压出的新.app) STAGING(暂存目录) ZIP(下载的zip,清理用) +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 兜底) +i=0 +while kill -0 "$PID" 2>/dev/null; do + sleep 0.3; i=$((i+1)) + [ "$i" -gt 100 ] && { echo "[helper] wait timeout"; break; } +done +sleep 0.5 + +BACKUP="${APP}.pangolin-old" + +# 2. 静默换:mv 旧→备份 → mv 新→原位;任何一步失败自动回滚(admin 用户对 /Applications 可写 → 无弹窗) +swap_plain() { + /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 swap_plain; then + echo "[helper] swap_plain ok" +else + echo "[helper] swap_plain failed -> osascript 提权" + # 3. 提权兜底:把带路径的换装命令写进 root 脚本,osascript 弹一次系统密码框以 root 跑 + 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\"" + } > "$ROOT_SH" + /bin/chmod +x "$ROOT_SH" + 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" + 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. 清理 +/bin/rm -f "$ZIP" 2>/dev/null +/bin/rm -rf "$STAGING/new" 2>/dev/null +exit 0 +'''; + Future _openInBrowser(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) {