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
This commit is contained in:
@@ -23,6 +23,12 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!--
|
||||
App 内更新:下载 APK 后拉起系统安装器(open_filex 内封 FileProvider)。
|
||||
Android 8+ 首次安装会引导用户授予「允许安装未知应用」。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="穿山甲"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 各实现一份。
|
||||
|
||||
@@ -20,7 +20,7 @@ import '../widgets/update_dialog.dart';
|
||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||
/// 有更新则弹 [showUpdateDialog]。
|
||||
Future<void> _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);
|
||||
|
||||
@@ -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<AsyncValue<AppUpdateInfo?>>(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(),
|
||||
|
||||
@@ -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<UpdateChecker>((ref) => const UpdateChecker());
|
||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
Timer? _timer;
|
||||
bool _dismissed = false;
|
||||
|
||||
class UpdateChecker {
|
||||
const UpdateChecker();
|
||||
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
|
||||
bool get isDismissed => _dismissed;
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
await Future<void>.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<AppUpdateInfo?> forceCheck() async {
|
||||
_dismissed = false;
|
||||
final info = await _check();
|
||||
state = AsyncValue.data(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||
Future<AppUpdateInfo?> check() async {
|
||||
Future<AppUpdateInfo?> _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<String, dynamic>;
|
||||
|
||||
@@ -56,8 +91,8 @@ class UpdateChecker {
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? 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, AppUpdateInfo?>(UpdateNotifier.new);
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user