From d8c0bd74d2e33c9ee32647f1f96afa3f7446e57d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 5 Jun 2026 09:04:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=88Windows/macOS=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=AE=89=E8=A3=85=EF=BC=8CWeb=20=E5=88=B7=E6=96=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lib/core/update/:app_updater(入口+进度对话框) + io/web 条件实现 - Windows: Dio 下载 setup.exe(无 MOTW) -> 启动安装包 -> 退出,规避浏览器 SmartScreen 下载页与运行时拦截 - macOS: 下载 zip -> ditto 解压 -> 后台脚本等退出后替换 /Applications 内 app 并重启(权限失败回退访达高亮) - Web: 刷新页面;其它平台回退浏览器下载 - app_shell 更新 banner/强制更新弹窗、设置页「立即更新」改走应用内更新 - 设置页「检查更新」加反馈(正在检查/已是最新/发现新版本/失败) - jiu-installer.iss 加 CloseApplications=yes 便于覆盖 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/update/app_updater.dart | 85 +++++++++++++++++++ client/lib/core/update/app_updater_io.dart | 83 ++++++++++++++++++ client/lib/core/update/app_updater_web.dart | 15 ++++ .../lib/screens/settings/settings_screen.dart | 23 ++++- client/lib/screens/shell/app_shell.dart | 7 +- deploy/windows/jiu-installer.iss | 3 + 6 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 client/lib/core/update/app_updater.dart create mode 100644 client/lib/core/update/app_updater_io.dart create mode 100644 client/lib/core/update/app_updater_web.dart diff --git a/client/lib/core/update/app_updater.dart b/client/lib/core/update/app_updater.dart new file mode 100644 index 0000000..7aabc98 --- /dev/null +++ b/client/lib/core/update/app_updater.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../../providers/update_provider.dart'; +import 'app_updater_io.dart' + if (dart.library.js_interop) 'app_updater_web.dart' as impl; + +/// 应用内更新统一入口。 +/// - Web:刷新页面。 +/// - Windows/macOS:应用内下载安装包并自动安装/重启(带进度对话框)。 +/// - 其它平台 / 无对应下载地址:回退浏览器下载(launchUpdateUrl)。 +Future startInAppUpdate(BuildContext context, AppUpdateInfo info) async { + if (impl.isWeb) { + impl.reloadForUpdate(); + return; + } + + final url = impl.platformDownloadUrl(info.downloadUrls); + if (!impl.isInAppSupported || url == null || url.isEmpty) { + await launchUpdateUrl(info.downloadUrls); // 兜底:开浏览器/商店 + return; + } + + await _runWithProgress(context, info, url); +} + +Future _runWithProgress( + BuildContext context, AppUpdateInfo info, String url) async { + final progress = ValueNotifier(0); + + // 不可关闭的进度对话框;下载完成后 impl 会让进程退出。 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PopScope( + canPop: false, + child: AlertDialog( + title: Text('正在更新到 v${info.latestVersion}'), + content: ValueListenableBuilder( + valueListenable: progress, + builder: (_, p, __) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator(value: p > 0 ? p : null), + const SizedBox(height: 12), + Text(p >= 1 + ? '下载完成,正在安装,应用即将重启…' + : '正在下载… ${(p * 100).toStringAsFixed(0)}%'), + ], + ), + ), + ), + ), + ); + + try { + await impl.downloadAndInstall(url, (p) => progress.value = p); + // 桌面端到此通常已 exit(0),不会继续执行。 + } catch (e) { + if (!context.mounted) return; + Navigator.of(context, rootNavigator: true).pop(); // 关进度对话框 + if (!context.mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('更新失败'), + content: Text('$e\n\n可改用浏览器下载更新。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(ctx); + launchUpdateUrl(info.downloadUrls); + }, + child: const Text('浏览器下载'), + ), + ], + ), + ); + } finally { + progress.dispose(); + } +} diff --git a/client/lib/core/update/app_updater_io.dart b/client/lib/core/update/app_updater_io.dart new file mode 100644 index 0000000..32017d1 --- /dev/null +++ b/client/lib/core/update/app_updater_io.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; + +// 桌面/原生平台的应用内更新实现。Web 版见 app_updater_web.dart。 + +bool get isWeb => false; + +/// 仅 Windows / macOS 支持「下载并自动安装」。其它平台回退到浏览器下载。 +bool get isInAppSupported => Platform.isWindows || Platform.isMacOS; + +String? platformDownloadUrl(Map urls) { + if (Platform.isWindows) return urls['windows']; + if (Platform.isMacOS) return urls['macos']; + if (Platform.isAndroid) return urls['android']; + if (Platform.isIOS) return urls['ios']; + return urls['web']; +} + +void reloadForUpdate() {/* 桌面无意义 */} + +/// 用 Dio 下载安装包(不经浏览器,无 MOTW / quarantine),再启动安装。 +/// 成功后进程会自行退出,调用方的后续代码可能不会执行。 +Future downloadAndInstall( + String url, void Function(double) onProgress) async { + final tmp = await getTemporaryDirectory(); + final dio = Dio(); + void prog(int r, int t) { + if (t > 0) onProgress(r / t); + } + + if (Platform.isWindows) { + final exe = '${tmp.path}\\jiu-update-setup.exe'; + await dio.download(url, exe, onReceiveProgress: prog); + // 启动 Inno Setup 安装包后退出本应用,让其覆盖安装。 + await Process.start(exe, const [], mode: ProcessStartMode.detached); + await Future.delayed(const Duration(milliseconds: 400)); + exit(0); + } + + if (Platform.isMacOS) { + final zip = '${tmp.path}/jiu-update.zip'; + await dio.download(url, zip, onReceiveProgress: prog); + + final extractDir = '${tmp.path}/jiu-update'; + await Process.run('/bin/rm', ['-rf', extractDir]); + await Directory(extractDir).create(recursive: true); + // ditto 正确处理 .app 包(保留权限/符号链接)。 + final unzip = await Process.run('/usr/bin/ditto', ['-x', '-k', zip, extractDir]); + if (unzip.exitCode != 0) { + throw Exception('解压失败:${unzip.stderr}'); + } + final newApp = Directory(extractDir) + .listSync() + .map((e) => e.path) + .firstWhere((p) => p.endsWith('.app'), + orElse: () => throw Exception('更新包中未找到 .app')); + + // 当前运行的 app bundle:.../Xxx.app/Contents/MacOS/exe + final resolved = Platform.resolvedExecutable; + final idx = resolved.indexOf('.app'); + if (idx < 0) { + throw Exception('无法定位当前 app 路径(非 .app 运行)'); + } + final appPath = resolved.substring(0, idx + 4); + + // 等本进程退出后替换并重启;替换失败(权限)则在访达中高亮让用户手动拖入。 + final script = '${tmp.path}/jiu-update-swap.sh'; + await File(script).writeAsString('''#!/bin/sh +while kill -0 $pid 2>/dev/null; do sleep 0.5; done +if rm -rf "$appPath" && ditto "$newApp" "$appPath"; then + open "$appPath" +else + open -R "$newApp" +fi +'''); + await Process.start('/bin/sh', [script], mode: ProcessStartMode.detached); + await Future.delayed(const Duration(milliseconds: 400)); + exit(0); + } + + throw Exception('当前平台不支持应用内更新'); +} diff --git a/client/lib/core/update/app_updater_web.dart b/client/lib/core/update/app_updater_web.dart new file mode 100644 index 0000000..1e3a324 --- /dev/null +++ b/client/lib/core/update/app_updater_web.dart @@ -0,0 +1,15 @@ +import 'package:web/web.dart' as web; + +// Web 平台:没有"安装",更新即刷新页面拿最新资源。 + +bool get isWeb => true; +bool get isInAppSupported => true; + +String? platformDownloadUrl(Map urls) => urls['web']; + +void reloadForUpdate() => web.window.location.reload(); + +Future downloadAndInstall( + String url, void Function(double) onProgress) async { + reloadForUpdate(); +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index ed78534..c3d8e91 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -12,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_info.dart'; +import '../../core/update/app_updater.dart'; import '../../core/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; @@ -729,7 +730,7 @@ class _SettingsScreenState extends ConsumerState { value: 'v${updateInfo.latestVersion}', valueColor: AppTheme.success, trailing: TextButton( - onPressed: () => launchUpdateUrl(updateInfo.downloadUrls), + onPressed: () => startInAppUpdate(context, updateInfo), child: const Text('立即更新'), ), ) @@ -739,8 +740,24 @@ class _SettingsScreenState extends ConsumerState { value: updateInfo != null ? '已是最新' : '检查中…', valueColor: AppTheme.textSecondary, trailing: TextButton( - onPressed: () => - ref.read(updateProvider.notifier).forceCheck(), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('正在检查更新…'))); + await ref.read(updateProvider.notifier).forceCheck(); + if (!context.mounted) return; + final r = ref.read(updateProvider).valueOrNull; + messenger.hideCurrentSnackBar(); + final String msg; + if (r == null) { + msg = '检查更新失败,请检查网络'; + } else if (r.hasUpdate) { + msg = '发现新版本 v${r.latestVersion}'; + } else { + msg = '已是最新版本(v${r.latestVersion})'; + } + messenger.showSnackBar(SnackBar(content: Text(msg))); + }, child: const Text('检查更新'), ), ), diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 0016232..ceab431 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -10,6 +10,7 @@ import '../../core/theme/app_theme.dart'; import '../../providers/connectivity_provider.dart'; import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; +import '../../core/update/app_updater.dart'; class AppShell extends ConsumerStatefulWidget { final Widget child; @@ -56,7 +57,7 @@ class _AppShellState extends ConsumerState { ), actions: [ ElevatedButton( - onPressed: () => launchUpdateUrl(info.downloadUrls), + onPressed: () => startInAppUpdate(context, info), child: const Text('立即更新'), ), ], @@ -237,8 +238,8 @@ class _AppShellState extends ConsumerState { ), ), TextButton( - onPressed: () => launchUpdateUrl( - updateInfo.downloadUrls), + onPressed: () => + startInAppUpdate(context, updateInfo), style: TextButton.styleFrom( foregroundColor: const Color(0xFFF57F17)), diff --git a/deploy/windows/jiu-installer.iss b/deploy/windows/jiu-installer.iss index eb27a87..54ab065 100644 --- a/deploy/windows/jiu-installer.iss +++ b/deploy/windows/jiu-installer.iss @@ -34,6 +34,9 @@ ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 UninstallDisplayName={#MyAppName} UninstallDisplayIcon={app}\{#MyAppExe} +; 应用内更新时本应用会先退出再启动安装包;这些选项确保能顺利覆盖。 +CloseApplications=yes +RestartApplications=no ; 说明:未声明 [Languages],使用 Inno Setup 内置英文向导(简体中文语言文件 ; ChineseSimplified.isl 默认不随 Inno Setup 安装,依赖它会导致编译找不到文件)。