feat(client): 应用内更新(Windows/macOS 下载安装,Web 刷新)

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-05 09:04:26 +08:00
parent 056ae914ce
commit d8c0bd74d2
6 changed files with 210 additions and 6 deletions
+85
View File
@@ -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<void> 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<void> _runWithProgress(
BuildContext context, AppUpdateInfo info, String url) async {
final progress = ValueNotifier<double>(0);
// 不可关闭的进度对话框;下载完成后 impl 会让进程退出。
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: AlertDialog(
title: Text('正在更新到 v${info.latestVersion}'),
content: ValueListenableBuilder<double>(
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<void>(
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();
}
}
@@ -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<String, String> 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<void> 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<void>.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<void>.delayed(const Duration(milliseconds: 400));
exit(0);
}
throw Exception('当前平台不支持应用内更新');
}
@@ -0,0 +1,15 @@
import 'package:web/web.dart' as web;
// Web 平台:没有"安装",更新即刷新页面拿最新资源。
bool get isWeb => true;
bool get isInAppSupported => true;
String? platformDownloadUrl(Map<String, String> urls) => urls['web'];
void reloadForUpdate() => web.window.location.reload();
Future<void> downloadAndInstall(
String url, void Function(double) onProgress) async {
reloadForUpdate();
}
@@ -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<SettingsScreen> {
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<SettingsScreen> {
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('检查更新'),
),
),
+4 -3
View File
@@ -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<AppShell> {
),
actions: [
ElevatedButton(
onPressed: () => launchUpdateUrl(info.downloadUrls),
onPressed: () => startInAppUpdate(context, info),
child: const Text('立即更新'),
),
],
@@ -237,8 +238,8 @@ class _AppShellState extends ConsumerState<AppShell> {
),
),
TextButton(
onPressed: () => launchUpdateUrl(
updateInfo.downloadUrls),
onPressed: () =>
startInAppUpdate(context, updateInfo),
style: TextButton.styleFrom(
foregroundColor:
const Color(0xFFF57F17)),
+3
View File
@@ -34,6 +34,9 @@ ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExe}
; 应用内更新时本应用会先退出再启动安装包;这些选项确保能顺利覆盖。
CloseApplications=yes
RestartApplications=no
; 说明:未声明 [Languages],使用 Inno Setup 内置英文向导(简体中文语言文件
; ChineseSimplified.isl 默认不随 Inno Setup 安装,依赖它会导致编译找不到文件)。