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
参照 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
237 lines
8.4 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|