This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/app_update_model.dart';
|
||||
|
||||
class UpdateService {
|
||||
UpdateService._();
|
||||
|
||||
static final UpdateService instance = UpdateService._();
|
||||
|
||||
static final Uri releasesRssUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases.rss',
|
||||
);
|
||||
static final Uri releasesPageUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases',
|
||||
);
|
||||
|
||||
final Dio _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
followRedirects: true,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
bool _checking = false;
|
||||
|
||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||
if (_checking) return null;
|
||||
if (!_supportsAutoDownload) return null;
|
||||
|
||||
_checking = true;
|
||||
try {
|
||||
final current = await PackageInfo.fromPlatform();
|
||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||
if (latest == null) return null;
|
||||
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
||||
return latest;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||
return null;
|
||||
} finally {
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> downloadUpdate(
|
||||
AppUpdateInfo update, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
final url = update.downloadUrl;
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final updateDir = Directory(p.join(dir.path, 'app_updates'));
|
||||
await updateDir.create(recursive: true);
|
||||
|
||||
final fileName = update.fileName ?? _fileNameFromUrl(url);
|
||||
final file = File(p.join(updateDir.path, fileName));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
await _dio.downloadUri(
|
||||
url,
|
||||
file.path,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> openInstaller(File file) async {
|
||||
final result = await OpenFile.open(file.path);
|
||||
if (result.type != ResultType.done) {
|
||||
throw Exception(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openReleasePage(AppUpdateInfo update) async {
|
||||
final url = update.pageUrl;
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('无法打开更新页面: $url');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
|
||||
final document = XmlDocument.parse(body);
|
||||
final items = document.findAllElements('item').toList();
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
final releases =
|
||||
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
|
||||
..sort(_compareRssReleaseDesc);
|
||||
if (releases.isEmpty) return null;
|
||||
|
||||
for (final release in releases) {
|
||||
if (currentVersion != null &&
|
||||
_compareVersions(release.version, currentVersion) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final update = await _buildUpdateInfo(release);
|
||||
if (update == null) continue;
|
||||
|
||||
if (update.downloadUrl == null) continue;
|
||||
return update;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_RssRelease? _releaseFromRssItem(XmlElement item) {
|
||||
final title = _elementText(item, 'title') ?? '';
|
||||
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
|
||||
final description = _elementText(item, 'description');
|
||||
final content = _elementText(item, 'encoded');
|
||||
final enclosure = item
|
||||
.findElements('enclosure')
|
||||
.map((e) => e.getAttribute('url'))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
|
||||
final candidates = <Uri>[
|
||||
...enclosure,
|
||||
..._extractLinks(description ?? ''),
|
||||
..._extractLinks(content ?? ''),
|
||||
..._extractLinks(item.toXmlString()),
|
||||
];
|
||||
|
||||
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
|
||||
final version = _versionFromText(
|
||||
'$title $link ${description ?? ''} ${content ?? ''}',
|
||||
);
|
||||
if (version == null) return null;
|
||||
|
||||
return _RssRelease(
|
||||
version: version,
|
||||
title: title.isEmpty ? 'v$version' : title,
|
||||
description: _stripHtml(
|
||||
(content ?? '').isNotEmpty ? content : description,
|
||||
),
|
||||
pageUrl: pageUrl,
|
||||
candidates: candidates,
|
||||
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
|
||||
final candidates = <Uri>[...release.candidates];
|
||||
final downloadUrl = _pickDownloadUrl(candidates);
|
||||
Uri? resolvedDownloadUrl = downloadUrl;
|
||||
|
||||
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
|
||||
|
||||
return AppUpdateInfo(
|
||||
version: release.version,
|
||||
title: release.title,
|
||||
description: release.description,
|
||||
pageUrl: release.pageUrl,
|
||||
downloadUrl: resolvedDownloadUrl,
|
||||
fileName: resolvedDownloadUrl == null
|
||||
? null
|
||||
: _fileNameFromUrl(resolvedDownloadUrl),
|
||||
publishedAt: release.publishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
|
||||
try {
|
||||
final response = await _dio.getUri<String>(pageUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
return _pickDownloadUrl(_extractLinks(body));
|
||||
} catch (e) {
|
||||
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uri? _pickDownloadUrl(List<Uri> candidates) {
|
||||
final normalized = candidates.toSet().toList();
|
||||
final preferred = normalized
|
||||
.where(_isReleaseAttachment)
|
||||
.where(_isSupportedPackage)
|
||||
.toList();
|
||||
if (preferred.isNotEmpty) return preferred.first;
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
||||
|
||||
bool _isReleaseAttachment(Uri uri) {
|
||||
return uri.path.contains('/releases/download/');
|
||||
}
|
||||
|
||||
bool _isSupportedPackage(Uri uri) {
|
||||
final name = _fileNameFromUrl(uri).toLowerCase();
|
||||
if (Platform.isAndroid) {
|
||||
return name.endsWith('.apk');
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return name.endsWith('.exe') ||
|
||||
name.endsWith('.msix') ||
|
||||
name.endsWith('.msi') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return name.endsWith('.appimage') ||
|
||||
name.endsWith('.deb') ||
|
||||
name.endsWith('.rpm') ||
|
||||
name.endsWith('.tar.gz') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
return name.endsWith('.dmg') ||
|
||||
name.endsWith('.pkg') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Uri> _extractLinks(String html) {
|
||||
final matches = RegExp(
|
||||
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
|
||||
caseSensitive: false,
|
||||
).allMatches(html);
|
||||
return matches
|
||||
.map((m) => m.group(1) ?? m.group(0))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Uri? _absoluteUriOrNull(String value) {
|
||||
final cleaned = _decodeHtmlEntities(value.trim());
|
||||
final parsed = Uri.tryParse(cleaned);
|
||||
if (parsed == null) return null;
|
||||
if (parsed.hasScheme) return parsed;
|
||||
return releasesPageUrl.resolveUri(parsed);
|
||||
}
|
||||
|
||||
String? _elementText(XmlElement element, String name) {
|
||||
for (final child in element.children.whereType<XmlElement>()) {
|
||||
if (child.name.qualified == name || child.name.local == name) {
|
||||
return child.innerText.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _versionFromText(String text) {
|
||||
final match = RegExp(
|
||||
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
|
||||
).firstMatch(text);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
String? _stripHtml(String? value) {
|
||||
if (value == null) return null;
|
||||
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
return _decodeHtmlEntities(
|
||||
withoutTags,
|
||||
).replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
String _decodeHtmlEntities(String value) {
|
||||
return value
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('+', '+')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
DateTime? _parseRssDate(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final direct = DateTime.tryParse(value);
|
||||
if (direct != null) return direct;
|
||||
try {
|
||||
return HttpDate.parse(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
|
||||
final version = _compareVersions(b.version, a.version);
|
||||
if (version != 0) return version;
|
||||
|
||||
final left = a.publishedAt;
|
||||
final right = b.publishedAt;
|
||||
if (left == null && right == null) return 0;
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
}
|
||||
|
||||
int _compareVersions(String a, String b) {
|
||||
final left = _versionParts(a);
|
||||
final right = _versionParts(b);
|
||||
final length = left.length > right.length ? left.length : right.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final l = i < left.length ? left[i] : 0;
|
||||
final r = i < right.length ? right[i] : 0;
|
||||
if (l != r) return l.compareTo(r);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String version) {
|
||||
final core = version.split(RegExp(r'[+\-]')).first;
|
||||
return core
|
||||
.split('.')
|
||||
.map((part) => int.tryParse(part) ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileNameFromUrl(Uri uri) {
|
||||
final segment = uri.pathSegments.isEmpty
|
||||
? 'app_update'
|
||||
: uri.pathSegments.last;
|
||||
return Uri.decodeComponent(segment);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||
}
|
||||
|
||||
class _RssRelease {
|
||||
final String version;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri pageUrl;
|
||||
final List<Uri> candidates;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const _RssRelease({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.pageUrl,
|
||||
required this.candidates,
|
||||
this.description,
|
||||
this.publishedAt,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user