c4eb92fc2d
4.5 harness/资源修复:UpdateNotifier 初始延迟由不可取消的 Future.delayed 改为 可取消 Timer,onDispose 一并取消——修 golden 整屏挂载时 pending-timer 泄漏 (生产也受益,provider dispose 不再悬挂 timer)。 4.2 CJK 测试字体:拷生产子集 NotoSansSC-Regular-subset.otf 进 test/fonts/ + flutter_test_config 注册(family 'Noto Sans SC'),golden 中文真渲染不出豆腐块。 顺带修 tablet_pages_golden_test 既存编译错(6 语言改动遗留):localeProvider override 返回值 AppLang → 改返回 LocaleNotifier 子类 _FixedLocale(钉死语言态)。 4.3 权威 Linux 容器(ghcr.io/cirruslabs/flutter)重生成全量 golden 基线: 34 tests All passed,32 张 PNG 更新(CJK 真字 + 暖阴影 + tablet zh/en 矩阵)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
5.3 KiB
Dart
149 lines
5.3 KiB
Dart
// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。
|
|
//
|
|
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
|
|
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
|
|
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
|
|
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
|
|
//
|
|
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:http/http.dart' as http;
|
|
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({
|
|
required this.latestVersion,
|
|
required this.buildNumber,
|
|
required this.forceUpdate,
|
|
required this.releaseNotes,
|
|
required this.downloadUrls,
|
|
required this.hasUpdate,
|
|
});
|
|
|
|
final String latestVersion;
|
|
final int buildNumber;
|
|
final bool forceUpdate;
|
|
final String releaseNotes;
|
|
final Map<String, String> downloadUrls;
|
|
final bool hasUpdate;
|
|
}
|
|
|
|
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
|
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
|
Timer? _timer;
|
|
Timer? _initialTimer;
|
|
|
|
@override
|
|
Future<AppUpdateInfo?> build() async {
|
|
// 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非
|
|
// Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose
|
|
// 时会悬挂 → widget 测试报 pending timer、生产留资源)。
|
|
ref.onDispose(() {
|
|
_initialTimer?.cancel();
|
|
_timer?.cancel();
|
|
});
|
|
final ready = Completer<void>();
|
|
_initialTimer = Timer(_kInitialDelay, ready.complete);
|
|
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
|
|
final first = await _check();
|
|
_timer = Timer.periodic(_kPollInterval, (_) async {
|
|
state = AsyncValue.data(await _check());
|
|
});
|
|
return first;
|
|
}
|
|
|
|
/// 设置页「检查更新」手动触发:立即查一次并回结果。
|
|
Future<AppUpdateInfo?> forceCheck() async {
|
|
final info = await _check();
|
|
state = AsyncValue.data(info);
|
|
return info;
|
|
}
|
|
|
|
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
|
Future<AppUpdateInfo?> _check() async {
|
|
try {
|
|
final resp = await http
|
|
.get(Uri.parse('$kApiBaseUrl/version'))
|
|
.timeout(_kCheckTimeout);
|
|
if (resp.statusCode != 200) return null;
|
|
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
|
|
final latestVersion = data['version'] as String? ?? '0.0.0';
|
|
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
|
|
final forceUpdate = data['force_update'] as bool? ?? false;
|
|
final releaseNotes = data['release_notes'] as String? ?? '';
|
|
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
|
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
|
|
|
final pkg = await PackageInfo.fromPlatform();
|
|
final hasUpdate = _isNewer(latestVersion, pkg.version);
|
|
|
|
return AppUpdateInfo(
|
|
latestVersion: latestVersion,
|
|
buildNumber: buildNumber,
|
|
forceUpdate: forceUpdate,
|
|
releaseNotes: releaseNotes,
|
|
downloadUrls: downloadUrls,
|
|
hasUpdate: hasUpdate,
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
|
|
bool _isNewer(String latest, String current) {
|
|
final l = _parse(latest);
|
|
final c = _parse(current);
|
|
for (var i = 0; i < 3; i++) {
|
|
if (l[i] > c[i]) return true;
|
|
if (l[i] < c[i]) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<int> _parse(String v) {
|
|
final parts = v.split('.').map((s) {
|
|
final m = RegExp(r'^\d+').firstMatch(s);
|
|
return m == null ? 0 : int.parse(m.group(0)!);
|
|
}).toList();
|
|
while (parts.length < 3) {
|
|
parts.add(0);
|
|
}
|
|
return parts;
|
|
}
|
|
}
|
|
|
|
final updateProvider =
|
|
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
|
|
|
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
|
|
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
|
|
/// 供 shell 顶部 banner 的显隐判断。
|
|
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
|
|
|
|
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
|
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
|
if (Platform.isMacOS) return downloadUrls['macos'];
|
|
if (Platform.isWindows) return downloadUrls['windows'];
|
|
if (Platform.isIOS) return downloadUrls['ios'];
|
|
if (Platform.isAndroid) return downloadUrls['android'];
|
|
return downloadUrls['web'];
|
|
}
|