b702957788
ci-pangolin / Lint — shellcheck (pull_request) Successful in 9s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 56s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 33s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 12s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m32s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 22s
原「下载到 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) <noreply@anthropic.com>
362 lines
13 KiB
Dart
362 lines
13 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);
|
|
await Process.run('/bin/chmod', <String>['+x', helper]);
|
|
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(失败回滚,
|
|
/// 属主非本人则 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<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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|