Files
2026-07-14 11:11:36 +08:00

209 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/contracts/app_update.dart';
import '../../core/infra/github_releases/github_releases_client.dart';
import '../../shared/app_info.dart';
import '../../shared/utils/app_logger.dart';
import '../../shared/utils/semver.dart';
import '../../shared/utils/window_bounds.dart';
class UpdateVerificationException implements Exception {
UpdateVerificationException(this.message);
final String message;
@override
String toString() => 'UpdateVerificationException: $message';
}
class UpdateService {
static const _tag = 'Update';
final GithubReleasesClient _client;
final Dio _dio;
final FutureOr<String> Function() _readCurrentVersion;
UpdateService({
GithubReleasesClient? client,
Dio? dio,
FutureOr<String> Function()? readCurrentVersion,
}) : _readCurrentVersion = readCurrentVersion ?? getSmPlayerVersion,
_client = client ?? GithubReleasesClient(),
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
followRedirects: true,
),
);
static String? get currentPlatformKey {
if (Platform.isWindows) return 'windows-x64';
if (Platform.isMacOS) return 'macos';
if (Platform.isAndroid) return 'android-arm64';
return null;
}
Future<UpdateCheckResult> check() async {
final platformKey = currentPlatformKey;
if (platformKey == null) {
return const UpdateCheckResult(hasUpdate: false);
}
final latest = await _client.fetchLatestRelease(platformKey: platformKey);
final currentVersion = await _readCurrentVersion();
final platformEntry = latest.platformAssets[platformKey];
final selectedAsset = _selectPlatformAsset(latest);
if (platformEntry == null && selectedAsset == null) {
return const UpdateCheckResult(hasUpdate: false);
}
final effectiveRemoteVersion = (platformEntry?.version?.isNotEmpty ?? false)
? platformEntry!.version!
: latest.version;
if (!isNewerVersion(currentVersion, effectiveRemoteVersion)) {
return const UpdateCheckResult(hasUpdate: false);
}
return UpdateCheckResult(
hasUpdate: true,
info: latest.copyWith(
version: effectiveRemoteVersion,
selectedAsset: selectedAsset,
noAssetForPlatform: selectedAsset == null,
),
);
}
static Future<String> computeSha256(File file) async {
final bytes = await file.readAsBytes();
return sha256.convert(bytes).toString();
}
Future<File> download(
AppUpdateAsset asset, {
required CancelToken cancelToken,
void Function(int received, int total)? onReceiveProgress,
}) async {
final directory = Directory(
'${(await getTemporaryDirectory()).path}${Platform.pathSeparator}'
'smplayer-update',
);
if (await directory.exists()) {
try {
await directory.delete(recursive: true);
} catch (e) {
AppLogger.warn(_tag, 'clean update temp directory failed', e);
}
}
await directory.create(recursive: true);
final file = File(
'${directory.path}${Platform.pathSeparator}${_safeFileName(asset.name)}',
);
await _dio.download(
asset.downloadUrl,
file.path,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
final expected = asset.sha256;
if (expected == null || expected.isEmpty) {
throw UpdateVerificationException('更新包缺少校验信息,请前往发布页手动下载');
}
final actual = await computeSha256(file);
if (actual.toLowerCase() != expected.toLowerCase()) {
AppLogger.warn(_tag, 'sha256 mismatch expected=$expected actual=$actual');
try {
await file.delete();
} catch (_) {}
throw UpdateVerificationException('更新包校验失败,请前往发布页重试');
}
return file;
}
static const _apkInstallerChannel = MethodChannel('smplayer/apk_installer');
Future<void> launchInstaller(String path) async {
if (Platform.isWindows) {
await Process.start(path, const [], mode: ProcessStartMode.detached);
await saveWindowBounds();
await Future<void>.delayed(const Duration(milliseconds: 500));
exit(0);
}
if (Platform.isMacOS) {
await Process.start('open', [path], mode: ProcessStartMode.detached);
return;
}
if (Platform.isAndroid) {
await _apkInstallerChannel.invokeMethod<void>('installApk', {
'path': path,
});
return;
}
throw UnsupportedError('unsupported platform');
}
AppUpdateAsset? _selectPlatformAsset(AppUpdateInfo info) {
final platformKey = currentPlatformKey;
final platformAsset = platformKey == null
? null
: info.platformAssets[platformKey];
if (platformAsset != null) return platformAsset;
return _selectFromAssets(info.assets);
}
AppUpdateAsset? _selectFromAssets(List<AppUpdateAsset> assets) {
if (Platform.isWindows) {
final setupPattern = RegExp(
r'^smplayer-setup-.*-x64\.exe$',
caseSensitive: false,
);
for (final asset in assets) {
if (setupPattern.hasMatch(asset.name)) return asset;
}
for (final asset in assets) {
if (asset.name.toLowerCase().endsWith('.exe')) return asset;
}
}
if (Platform.isMacOS) {
for (final asset in assets) {
if (asset.name.toLowerCase().endsWith('.dmg')) return asset;
}
}
if (Platform.isAndroid) {
final arm64Pattern = RegExp(r'arm64|arm64-v8a', caseSensitive: false);
for (final asset in assets) {
final lowerName = asset.name.toLowerCase();
if (lowerName.endsWith('.apk') && arm64Pattern.hasMatch(lowerName)) {
return asset;
}
}
for (final asset in assets) {
if (asset.name.toLowerCase().endsWith('.apk')) return asset;
}
}
return null;
}
String _safeFileName(String name) {
return name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
}
}