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 Function() _readCurrentVersion; UpdateService({ GithubReleasesClient? client, Dio? dio, FutureOr 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 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 computeSha256(File file) async { final bytes = await file.readAsBytes(); return sha256.convert(bytes).toString(); } Future 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 launchInstaller(String path) async { if (Platform.isWindows) { await Process.start(path, const [], mode: ProcessStartMode.detached); await saveWindowBounds(); await Future.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('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 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'[\\/:*?"<>|]'), '_'); } }