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/auth/auth_state.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_info.dart';
|
import '../../core/config/app_info.dart';
|
||||||
|
import '../../core/update/app_updater.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/number_rule.dart';
|
import '../../models/number_rule.dart';
|
||||||
import '../../models/user.dart';
|
import '../../models/user.dart';
|
||||||
@@ -729,7 +730,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
value: 'v${updateInfo.latestVersion}',
|
value: 'v${updateInfo.latestVersion}',
|
||||||
valueColor: AppTheme.success,
|
valueColor: AppTheme.success,
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: () => launchUpdateUrl(updateInfo.downloadUrls),
|
onPressed: () => startInAppUpdate(context, updateInfo),
|
||||||
child: const Text('立即更新'),
|
child: const Text('立即更新'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -739,8 +740,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
value: updateInfo != null ? '已是最新' : '检查中…',
|
value: updateInfo != null ? '已是最新' : '检查中…',
|
||||||
valueColor: AppTheme.textSecondary,
|
valueColor: AppTheme.textSecondary,
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: () =>
|
onPressed: () async {
|
||||||
ref.read(updateProvider.notifier).forceCheck(),
|
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('检查更新'),
|
child: const Text('检查更新'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
|
import '../../core/update/app_updater.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -56,7 +57,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => launchUpdateUrl(info.downloadUrls),
|
onPressed: () => startInAppUpdate(context, info),
|
||||||
child: const Text('立即更新'),
|
child: const Text('立即更新'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -237,8 +238,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => launchUpdateUrl(
|
onPressed: () =>
|
||||||
updateInfo.downloadUrls),
|
startInAppUpdate(context, updateInfo),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor:
|
foregroundColor:
|
||||||
const Color(0xFFF57F17)),
|
const Color(0xFFF57F17)),
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ ArchitecturesAllowed=x64
|
|||||||
ArchitecturesInstallIn64BitMode=x64
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
UninstallDisplayName={#MyAppName}
|
UninstallDisplayName={#MyAppName}
|
||||||
UninstallDisplayIcon={app}\{#MyAppExe}
|
UninstallDisplayIcon={app}\{#MyAppExe}
|
||||||
|
; 应用内更新时本应用会先退出再启动安装包;这些选项确保能顺利覆盖。
|
||||||
|
CloseApplications=yes
|
||||||
|
RestartApplications=no
|
||||||
|
|
||||||
; 说明:未声明 [Languages],使用 Inno Setup 内置英文向导(简体中文语言文件
|
; 说明:未声明 [Languages],使用 Inno Setup 内置英文向导(简体中文语言文件
|
||||||
; ChineseSimplified.isl 默认不随 Inno Setup 安装,依赖它会导致编译找不到文件)。
|
; ChineseSimplified.isl 默认不随 Inno Setup 安装,依赖它会导致编译找不到文件)。
|
||||||
|
|||||||
Reference in New Issue
Block a user