// 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 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(0); final installing = ValueNotifier(false); var dialogOpen = true; void closeDialog() { if (context.mounted && dialogOpen) { dialogOpen = false; Navigator.of(context, rootNavigator: true).pop(); } } // 不可关闭的进度框。 unawaited(showDialog( 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 _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 _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 _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.delayed(const Duration(milliseconds: 400)); exit(0); // 退出让安装器覆盖 } if (Platform.isMacOS) { 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)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } Future _showInfo(BuildContext context, AppText t, String msg) { final c = context.pangolin; return showDialog( 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 _showFailed(BuildContext context, AppText t, String url) { final c = context.pangolin; return showDialog( 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 progress; final ValueNotifier 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( valueListenable: installing, builder: (_, inst, __) => ValueListenableBuilder( 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(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), ), ], ), ), ), ); } }