Files
pangolin/client/lib/core/update/app_updater.dart
T
wangjia 3acd3ca59d 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
2026-07-13 15:52:35 +08:00

433 lines
16 KiB
Dart

// app_updater.dart — App 内下载并安装更新(不再开浏览器)。
//
// 对照 jiu core/update/app_updater_io.dart,但补上了 jiu 没有的 Android 原生安装:
// - Android:http 流式下 APK 到临时目录 → open_filex 拉起系统安装器
// (open_filex 内部封装 FileProvider;manifest 需 REQUEST_INSTALL_PACKAGES)
// - Windows:下 exe → Process.start(detached) 起安装 → exit(0) 让其覆盖
// - 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';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../l10n/app_text.dart';
import '../../pangolin_theme.dart';
import '../../state/update_provider.dart';
import '../../widgets/pangolin_icons.dart';
/// 更新对话框「下载更新」入口:按平台 App 内下载并安装;iOS/无直链降级浏览器。
Future<void> startInAppUpdate(BuildContext context, AppText t, AppUpdateInfo info) async {
final url = platformDownloadUrl(info.downloadUrls);
if (url == null || url.isEmpty) return;
// iOS 不允许 App 内安装 → 外链(download_urls['ios'] 应为 TestFlight/App Store)。
if (Platform.isIOS) {
await _openInBrowser(url);
return;
}
final progress = ValueNotifier<double>(0);
final installing = ValueNotifier<bool>(false);
var dialogOpen = true;
void closeDialog() {
if (context.mounted && dialogOpen) {
dialogOpen = false;
Navigator.of(context, rootNavigator: true).pop();
}
}
// 不可关闭的进度框。
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: _ProgressDialog(t: t, progress: progress, installing: installing),
),
));
try {
final savePath = await _savePath(url);
await _download(url, savePath, (p) => progress.value = p);
installing.value = true;
await _install(savePath); // Win/macOS 可能在此 exit(0),不再返回
closeDialog();
if (Platform.isMacOS && context.mounted) {
await _showInfo(context, t, t.lang.updateMacReveal);
}
} catch (_) {
closeDialog();
if (context.mounted) await _showFailed(context, t, url);
} finally {
progress.dispose();
installing.dispose();
}
}
/// 各平台下载文件的落盘路径。
Future<String> _savePath(String url) async {
final tmp = await getTemporaryDirectory();
if (Platform.isWindows) return '${tmp.path}${Platform.pathSeparator}pangolin-update-setup.exe';
if (Platform.isMacOS) {
// 下到「下载」目录便于用户在访达里操作;取不到则回退临时目录。
final dl = await getDownloadsDirectory();
final dir = dl ?? tmp;
return '${dir.path}/pangolin-update.zip';
}
return '${tmp.path}/pangolin-update.apk'; // Android
}
/// http 流式下载 + 进度回调。失败抛异常(由调用方降级)。
Future<void> _download(String url, String savePath, void Function(double) onProgress) async {
final client = http.Client();
try {
final req = http.Request('GET', Uri.parse(url));
final resp = await client.send(req);
if (resp.statusCode != 200) {
throw HttpException('HTTP ${resp.statusCode}', uri: Uri.parse(url));
}
final total = resp.contentLength ?? 0;
final file = File(savePath);
final sink = file.openWrite();
var received = 0;
try {
await for (final chunk in resp.stream) {
received += chunk.length;
sink.add(chunk);
if (total > 0) onProgress(received / total);
}
await sink.flush();
} finally {
await sink.close();
}
} finally {
client.close();
}
}
/// 下载后触发安装/打开。
Future<void> _install(String path) async {
if (Platform.isAndroid) {
await OpenFilex.open(path); // 系统安装器(open_filex 内封 FileProvider)
return;
}
if (Platform.isWindows) {
await Process.start(path, const [], mode: ProcessStartMode.detached);
await Future<void>.delayed(const Duration(milliseconds: 400));
exit(0); // 退出让安装器覆盖
}
if (Platform.isMacOS) {
await _macInstall(path); // 成功则 exit(0) 不返回;回退则内部走访达定位后返回
}
}
/// macOS 自动安装:解压 → 分离 helper 换 /Applications 的 .app + 重启。
/// 成功路径 exit(0)(不返回);不满足条件/失败则回退访达定位(现状半自动流程)后正常返回,
/// 由调用方提示手动拖入。
Future<void> _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', <String>['-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);
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],
mode: ProcessStartMode.detached,
);
await Future<void>.delayed(const Duration(milliseconds: 300));
exit(0); // helper 等本进程退出后原子换 bundle + open 重启
} catch (_) {
await _macReveal(zipPath); // 任何意外 → 保底手动流程
}
}
/// 回退:Archive Utility 解压 zip + 访达高亮(与旧版一致的半自动流程)。
Future<void> _macReveal(String zipPath) async {
await Process.run('open', <String>[zipPath]); // 解压
await Process.run('open', <String>['-R', zipPath]); // 访达定位
}
/// 在解压目录里找顶层 .app(ditto --keepParent 打的 zip 解出 pangolin_vpn.app)。
Future<String?> _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)→ 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,清理用)
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"
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 ] && 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
# 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
# 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
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
}
swap_plain
rc=$?
if [ "$rc" -eq 0 ]; then
echo "[helper] swap_plain ok"
else
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 "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"
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 -> 回退访达定位"
fallback_reveal "osascript failed or canceled"
exit 1
fi
fi
# 5. 终检:/Applications 里必须有可用的 app 才继续,否则宁可停手也不再往下动(不清 quarantine、不 open)
if [ ! -d "$APP" ]; then
fallback_reveal "final check: app still missing after all recovery attempts"
exit 1
fi
# 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
''';
Future<void> _openInBrowser(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
Future<void> _showInfo(BuildContext context, AppText t, String msg) {
final c = context.pangolin;
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
content: Text(msg, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('OK', style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
),
);
}
Future<void> _showFailed(BuildContext context, AppText t, String url) {
final c = context.pangolin;
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
content: Text(t.lang.updateDownloadFailed, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(t.lang.updateCancelBtn, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await _openInBrowser(url);
},
child: Text(t.lang.updateOpenBrowser, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
),
);
}
class _ProgressDialog extends StatelessWidget {
const _ProgressDialog({required this.t, required this.progress, required this.installing});
final AppText t;
final ValueNotifier<double> progress;
final ValueNotifier<bool> installing;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
title: Row(children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(t.updateDownload,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
),
]),
content: ValueListenableBuilder<bool>(
valueListenable: installing,
builder: (_, inst, __) => ValueListenableBuilder<double>(
valueListenable: progress,
builder: (_, p, __) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(PangolinRadius.full),
child: LinearProgressIndicator(
value: inst || p <= 0 ? null : p,
minHeight: 6,
backgroundColor: c.bgSubtle,
valueColor: AlwaysStoppedAnimation<Color>(c.accent),
),
),
const SizedBox(height: 12),
Text(
inst ? t.lang.updateInstalling : t.lang.updateDownloadingPercent((p * 100).clamp(0, 100).round()),
style: PangolinText.sm.copyWith(color: c.fg2),
),
],
),
),
),
);
}
}