Files
pangolin/client/lib/core/update/app_updater.dart
T
wangjia c723bdd53e
Deploy Client / build-windows (push) Successful in 2m2s
Deploy Client / build-android (push) Successful in 2m13s
Deploy Client / build-macos (push) Successful in 3m45s
Deploy Client / build-ios (push) Successful in 3m58s
Deploy Client / release-deploy (push) Successful in 2m1s
feat(client/update): 启动自动检查 + App 内下载安装(不走浏览器)
参照 jiu 补齐,但比 jiu 多做了 Android 原生安装(jiu 的 App 内下载仅 Win/mac):
- 启动自动检查:update_provider 改 AsyncNotifier,build() 延迟 3s 首查 + 每小时
  轮询;home_shell watch 拉起、listen 弹更新框;_dismissed 防反复弹(轮询重置);
  强制更新走不可关闭弹窗。设置页「检查更新」改 forceCheck()。
- App 内下载(core/update/app_updater.dart,http 流式带进度框,不再 launchUrl):
  · Android:下 APK → open_filex 拉起系统安装器(+ REQUEST_INSTALL_PACKAGES)
  · Windows:下 exe → Process.start(detached) → exit(0) 覆盖安装
  · macOS :下 zip → open 解压 + 访达显示,提示手动拖入(带系统扩展不自动 swap)
  · iOS   :降级外链(TestFlight/App Store)
  失败降级浏览器下载。新增 6 语更新进度/失败/提示文案。
- 依赖 open_filex;flutter analyze 仅 2 个既有 withOpacity info。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 17:09:29 +08:00

237 lines
8.4 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 → open 解压 + 访达显示,提示手动拖入「应用程序」
// (本 app 带系统扩展,不自动替换 bundle,避免破坏 sysext)
// - 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) {
// 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。
await Process.run('open', <String>[path]); // Archive Utility 解压
await Process.run('open', <String>['-R', path]); // 访达定位
}
}
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),
),
],
),
),
),
);
}
}