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:
@@ -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('检查更新'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -34,6 +34,9 @@ ArchitecturesAllowed=x64
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExe}
|
||||
; 应用内更新时本应用会先退出再启动安装包;这些选项确保能顺利覆盖。
|
||||
CloseApplications=yes
|
||||
RestartApplications=no
|
||||
|
||||
; 说明:未声明 [Languages],使用 Inno Setup 内置英文向导(简体中文语言文件
|
||||
; ChineseSimplified.isl 默认不随 Inno Setup 安装,依赖它会导致编译找不到文件)。
|
||||
|
||||
Reference in New Issue
Block a user