From c723bdd53e189a764de6a0ee54a1a9a2165e09a9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 17:09:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(client/update):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A3=80=E6=9F=A5=20+=20App=20=E5=86=85?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=AE=89=E8=A3=85(=E4=B8=8D=E8=B5=B0?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参照 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 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- .../android/app/src/main/AndroidManifest.xml | 6 + client/lib/core/update/app_updater.dart | 236 ++++++++++++++++++ client/lib/l10n/app_text.dart | 108 ++++++++ client/lib/screens/settings_page.dart | 2 +- client/lib/shell/home_shell.dart | 20 ++ client/lib/state/update_provider.dart | 66 +++-- client/lib/widgets/update_dialog.dart | 27 +- client/pubspec.yaml | 3 +- 8 files changed, 438 insertions(+), 30 deletions(-) create mode 100644 client/lib/core/update/app_updater.dart diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index a52e295..2a81d58 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ --> + + + 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) { + // 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。 + await Process.run('open', [path]); // Archive Utility 解压 + await Process.run('open', ['-R', path]); // 访达定位 + } +} + +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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 04f7e15..7aedbc2 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -200,6 +200,114 @@ extension AppLangMisc on AppLang { return 'hace $days d'; } } + + /// 更新下载进度:「正在下载 X%」。 + String updateDownloadingPercent(int pct) { + switch (this) { + case AppLang.zh: + return '正在下载 $pct%'; + case AppLang.en: + return 'Downloading $pct%'; + case AppLang.ja: + return 'ダウンロード中 $pct%'; + case AppLang.ko: + return '다운로드 중 $pct%'; + case AppLang.ru: + return 'Загрузка $pct%'; + case AppLang.es: + return 'Descargando $pct%'; + } + } + + /// 下载完成、正在安装。 + String get updateInstalling { + switch (this) { + case AppLang.zh: + return '下载完成,正在安装…'; + case AppLang.en: + return 'Downloaded, installing…'; + case AppLang.ja: + return 'ダウンロード完了、インストール中…'; + case AppLang.ko: + return '다운로드 완료, 설치 중…'; + case AppLang.ru: + return 'Загружено, установка…'; + case AppLang.es: + return 'Descargado, instalando…'; + } + } + + /// 下载失败。 + String get updateDownloadFailed { + switch (this) { + case AppLang.zh: + return '下载失败,请重试或改用浏览器下载。'; + case AppLang.en: + return 'Download failed. Retry or download in your browser.'; + case AppLang.ja: + return 'ダウンロードに失敗しました。再試行するかブラウザでダウンロードしてください。'; + case AppLang.ko: + return '다운로드에 실패했습니다. 다시 시도하거나 브라우저로 다운로드하세요.'; + case AppLang.ru: + return 'Не удалось загрузить. Повторите или скачайте в браузере.'; + case AppLang.es: + return 'Error de descarga. Reintenta o descarga en el navegador.'; + } + } + + /// macOS:已下载,提示手动拖入应用程序。 + String get updateMacReveal { + switch (this) { + case AppLang.zh: + return '已下载并解压。请在访达中将 Pangolin 拖入「应用程序」文件夹替换旧版,然后重新打开。'; + case AppLang.en: + return 'Downloaded and extracted. In Finder, drag Pangolin into your Applications folder to replace the old version, then reopen.'; + case AppLang.ja: + return 'ダウンロードと展開が完了しました。Finder で Pangolin を「アプリケーション」フォルダにドラッグして置き換え、再度開いてください。'; + case AppLang.ko: + return '다운로드 및 압축 해제 완료. Finder에서 Pangolin을 「응용 프로그램」 폴더로 드래그해 이전 버전을 교체한 뒤 다시 여세요.'; + case AppLang.ru: + return 'Загружено и распаковано. В Finder перетащите Pangolin в папку «Программы», заменив старую версию, затем откройте заново.'; + case AppLang.es: + return 'Descargado y extraído. En Finder, arrastra Pangolin a tu carpeta de Aplicaciones para reemplazar la versión anterior y vuelve a abrir.'; + } + } + + /// 失败对话框:改用浏览器下载。 + String get updateOpenBrowser { + switch (this) { + case AppLang.zh: + return '浏览器下载'; + case AppLang.en: + return 'Open in browser'; + case AppLang.ja: + return 'ブラウザで開く'; + case AppLang.ko: + return '브라우저에서 열기'; + case AppLang.ru: + return 'Открыть в браузере'; + case AppLang.es: + return 'Abrir en el navegador'; + } + } + + /// 通用「取消」。 + String get updateCancelBtn { + switch (this) { + case AppLang.zh: + return '取消'; + case AppLang.en: + return 'Cancel'; + case AppLang.ja: + return 'キャンセル'; + case AppLang.ko: + return '취소'; + case AppLang.ru: + return 'Отмена'; + case AppLang.es: + return 'Cancelar'; + } + } } /// 全部界面文案的抽象契约。zh / en 各实现一份。 diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 9ada980..619499b 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -20,7 +20,7 @@ import '../widgets/update_dialog.dart'; /// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, /// 有更新则弹 [showUpdateDialog]。 Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async { - final info = await ref.read(updateCheckerProvider).check(); + final info = await ref.read(updateProvider.notifier).forceCheck(); if (!context.mounted) return; if (info == null) { showPangolinToast(context, t.updateCheckFailed); diff --git a/client/lib/shell/home_shell.dart b/client/lib/shell/home_shell.dart index ad4e098..a837156 100644 --- a/client/lib/shell/home_shell.dart +++ b/client/lib/shell/home_shell.dart @@ -8,6 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/responsive/form_factor.dart'; import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/update_provider.dart'; +import '../widgets/update_dialog.dart'; import 'desktop_shell.dart'; import 'mobile_shell.dart'; import 'tablet_shell.dart'; @@ -18,6 +21,23 @@ class HomeShell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; + + // 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询); + // 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免 + // rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。 + ref.listen>(updateProvider, (prev, next) { + final info = next.valueOrNull; + if (info == null || !info.hasUpdate) return; + final notifier = ref.read(updateProvider.notifier); + if (notifier.isDismissed) return; + if (!info.forceUpdate) notifier.dismiss(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + showUpdateDialog(context, ref.read(appTextProvider), info); + }); + }); + ref.watch(updateProvider); // 激活 provider(惰性 build) + final Widget body = switch (context.formFactor) { FormFactor.desktop => const DesktopShell(), FormFactor.tablet => const TabletShell(), diff --git a/client/lib/state/update_provider.dart b/client/lib/state/update_provider.dart index 148fa12..ac209e5 100644 --- a/client/lib/state/update_provider.dart +++ b/client/lib/state/update_provider.dart @@ -1,11 +1,12 @@ -// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。 +// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。 // -// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息, -// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗 -// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。 +// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性 +// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。 +// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。 +// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。 // -// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与 -// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。 +// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。 +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -15,6 +16,15 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../services/api_config.dart'; +/// 启动后首次检查的延迟(避开登录/首屏竞争)。 +const _kInitialDelay = Duration(seconds: 3); + +/// 轮询间隔。 +const _kPollInterval = Duration(hours: 1); + +/// 单次检查网络超时。 +const _kCheckTimeout = Duration(seconds: 8); + /// 一次更新检查的结果。 class AppUpdateInfo { const AppUpdateInfo({ @@ -34,18 +44,43 @@ class AppUpdateInfo { final bool hasUpdate; } -/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。 -final updateCheckerProvider = Provider((ref) => const UpdateChecker()); +/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。 +class UpdateNotifier extends AsyncNotifier { + Timer? _timer; + bool _dismissed = false; -class UpdateChecker { - const UpdateChecker(); + /// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。 + bool get isDismissed => _dismissed; + + @override + Future build() async { + ref.onDispose(() => _timer?.cancel()); + await Future.delayed(_kInitialDelay); + final first = await _check(); + _timer = Timer.periodic(_kPollInterval, (_) async { + _dismissed = false; // 新一轮允许再次提示 + state = AsyncValue.data(await _check()); + }); + return first; + } + + /// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)。 + void dismiss() => _dismissed = true; + + /// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。 + Future forceCheck() async { + _dismissed = false; + final info = await _check(); + state = AsyncValue.data(info); + return info; + } /// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。 - Future check() async { + Future _check() async { try { final resp = await http .get(Uri.parse('$kApiBaseUrl/version')) - .timeout(const Duration(seconds: 8)); + .timeout(_kCheckTimeout); if (resp.statusCode != 200) return null; final data = jsonDecode(resp.body) as Map; @@ -56,8 +91,8 @@ class UpdateChecker { final rawUrls = data['download_urls'] as Map? ?? const {}; final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? '')); - final info = await PackageInfo.fromPlatform(); - final hasUpdate = _isNewer(latestVersion, info.version); + final pkg = await PackageInfo.fromPlatform(); + final hasUpdate = _isNewer(latestVersion, pkg.version); return AppUpdateInfo( latestVersion: latestVersion, @@ -95,6 +130,9 @@ class UpdateChecker { } } +final updateProvider = + AsyncNotifierProvider(UpdateNotifier.new); + /// 按当前平台从服务端 download_urls 里取对应下载直链。 String? platformDownloadUrl(Map downloadUrls) { if (Platform.isMacOS) return downloadUrls['macos']; diff --git a/client/lib/widgets/update_dialog.dart b/client/lib/widgets/update_dialog.dart index c59b8a8..9d6a6b4 100644 --- a/client/lib/widgets/update_dialog.dart +++ b/client/lib/widgets/update_dialog.dart @@ -1,11 +1,13 @@ // update_dialog.dart — 「发现新版本」更新提示弹窗。 // -// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链 -// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭 -// (无「稍后」按钮、点遮罩/返回也关不掉)。 -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; +// 点「下载更新」走 App 内下载安装(core/update/app_updater.dart,带进度框,不再开 +// 浏览器);Android 装 APK、Windows 跑安装包、macOS 解压提示拖入、iOS 降级外链。 +// force_update=true 时不可关闭(无「稍后」按钮、点遮罩/返回也关不掉)。 +import 'dart:async'; +import 'package:flutter/material.dart'; + +import '../core/update/app_updater.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; import '../state/update_provider.dart'; @@ -63,15 +65,12 @@ class _UpdateDialog extends StatelessWidget { child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), ), TextButton( - onPressed: () async { - final url = platformDownloadUrl(info.downloadUrls); - if (url != null && url.isNotEmpty) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - if (context.mounted) Navigator.of(context).pop(); + onPressed: () { + // 先关本弹窗,用 Navigator 自身 context(关后仍挂载)启动 App 内下载 + // (它自带进度框)。 + final nav = Navigator.of(context); + nav.pop(); + unawaited(startInAppUpdate(nav.context, t, info)); }, child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), ), diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 5e8aed7..b956b5c 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -20,7 +20,8 @@ dependencies: flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化 shared_preferences: ^2.5.5 package_info_plus: ^9.0.1 - url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页) + url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / iOS 更新降级外链) + open_filex: ^4.5.0 # App 内更新:下载后拉起系统安装器(Android APK / 打开文件) device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报) uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4) launch_at_startup: ^0.5.1