369 lines
10 KiB
Dart
369 lines
10 KiB
Dart
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,
|
|
});
|
|
}
|