Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/update_provider.dart';
import '../../shared/app_info.dart';
import '../../shared/utils/format_utils.dart';
class UpdateDialog extends ConsumerStatefulWidget {
const UpdateDialog({super.key});
@override
ConsumerState<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends ConsumerState<UpdateDialog> {
bool _installTriggered = false;
@override
Widget build(BuildContext context) {
final state = ref.watch(updateProvider);
final info = state.info;
final appVersion =
ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback;
if (state.status == UpdateStatus.readyToInstall && !_installTriggered) {
_installTriggered = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final navigator = Navigator.of(context);
await ref.read(updateProvider.notifier).install();
if (mounted && navigator.canPop()) {
navigator.pop();
}
});
}
if (info == null) {
return FDialog.adaptive(
title: const Text('检查更新'),
body: _ErrorContent(message: state.error ?? '没有可用的更新信息'),
actions: [
FButton(
onPress: () => ref.read(updateProvider.notifier).manualCheck(),
child: const Text('重试'),
),
FButton(
onPress: () => Navigator.of(context).pop(),
variant: FButtonVariant.outline,
child: const Text('关闭'),
),
],
);
}
final isDownloading = state.status == UpdateStatus.downloading;
final isReady = state.status == UpdateStatus.readyToInstall;
final hasError = state.status == UpdateStatus.error;
final noAsset = info.noAssetForPlatform;
return FDialog.adaptive(
title: Text('发现新版本 ${info.version}'),
body: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前版本 $appVersion → 最新版本 ${info.version}'),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
child: Text(
info.releaseNotes.trim().isEmpty
? '暂无更新说明'
: info.releaseNotes.trim(),
style: Theme.of(context).textTheme.bodySmall,
),
),
),
if (isDownloading) ...[
const SizedBox(height: 16),
FDeterminateProgress(
value: downloadProgressValue(
received: state.received,
total: state.total,
) ?? 0,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${formatDownloadBytes(state.received)} / ${formatDownloadBytes(state.total)}',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
formatDownloadSpeed(state.speedBytesPerSec),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
if (isReady) ...[
const SizedBox(height: 16),
const Text('安装程序已下载,正在打开。'),
],
if (hasError) ...[
const SizedBox(height: 16),
_ErrorContent(message: state.error ?? '更新失败'),
],
],
),
),
actions: [
if (isDownloading)
FButton(
onPress: () => ref.read(updateProvider.notifier).cancel(),
variant: FButtonVariant.outline,
child: const Text('取消'),
)
else ...[
FButton(
onPress: () => Navigator.of(context).pop(),
variant: FButtonVariant.outline,
child: const Text('稍后'),
),
if (hasError)
FButton(
onPress: () => ref.read(updateProvider.notifier).startDownload(),
child: const Text('重试'),
)
else if (noAsset)
FButton(
onPress: () => _openDownloadPage(info.htmlUrl),
child: const Text('前往下载页'),
)
else
FButton(
onPress: isReady
? null
: () => ref.read(updateProvider.notifier).startDownload(),
child: const Text('下载并安装'),
),
],
],
);
}
Future<void> _openDownloadPage(Uri uri) async {
if (!uri.hasScheme) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _ErrorContent extends StatelessWidget {
final String message;
const _ErrorContent({required this.message});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 18,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
}
+208
View File
@@ -0,0 +1,208 @@
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'[\\/:*?"<>|]'), '_');
}
}