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