This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../core/exceptions/app_exception.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
|
||||
@@ -120,6 +121,11 @@ class ApiService {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
// 附加 X-Cr-Client-Id,服务端据此过滤 SSE 自身事件
|
||||
try {
|
||||
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||
options.headers['X-Cr-Client-Id'] = clientId;
|
||||
} catch (_) {}
|
||||
return handler.next(options);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:tray_manager/tray_manager.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../presentation/providers/theme_provider.dart';
|
||||
import 'sync_service.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
|
||||
/// 桌面端服务(窗口管理 + 系统托盘)
|
||||
class DesktopService with TrayListener, WindowListener {
|
||||
@@ -188,6 +190,22 @@ class DesktopService with TrayListener, WindowListener {
|
||||
try {
|
||||
AppLogger.d('DesktopService: Cleaning up before exit...');
|
||||
|
||||
// 同步引擎清理(WCF 模式下必须调用,同步释放占位符、注销 sync root)
|
||||
try {
|
||||
await SyncService.instance.stop();
|
||||
AppLogger.d('DesktopService: Sync engine stopped');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error stopping sync: $e');
|
||||
}
|
||||
|
||||
// 进程退出前同步清理(确保 WCF 资源释放,不依赖 tokio runtime)
|
||||
try {
|
||||
await ffi.syncShutdown();
|
||||
AppLogger.d('DesktopService: Sync shutdown complete');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error in sync shutdown: $e');
|
||||
}
|
||||
|
||||
// 彻底解绑
|
||||
windowManager.removeListener(this);
|
||||
trayManager.removeListener(this);
|
||||
|
||||
@@ -213,7 +213,7 @@ class FileService {
|
||||
int? pageSize,
|
||||
}) async {
|
||||
// 构造搜索 URI: cloudreve://my?name=xxx
|
||||
final cloudreveUri = FileUtils.toCloudreveUriWithQuery(uri, {'name': name});
|
||||
final cloudreveUri = '${FileUtils.toCloudreveUri(uri)}?name=$name';
|
||||
|
||||
final params = <String, dynamic>{
|
||||
'uri': cloudreveUri,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../data/models/server_model.dart';
|
||||
|
||||
@@ -149,4 +150,55 @@ class StorageService {
|
||||
Future<void> clearSearchHistory() async {
|
||||
await remove(StorageKeys.searchHistory);
|
||||
}
|
||||
|
||||
// ===== 同步配置持久化 =====
|
||||
|
||||
/// 保存同步配置
|
||||
Future<bool> setSyncConfig(Map<String, dynamic> config) async {
|
||||
try {
|
||||
final json = jsonEncode(config);
|
||||
return await setString(StorageKeys.syncConfig, json);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取同步配置
|
||||
Future<Map<String, dynamic>?> getSyncConfig() async {
|
||||
final json = await getString(StorageKeys.syncConfig);
|
||||
if (json == null || json.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存同步状态
|
||||
Future<bool> setSyncState(String state) async {
|
||||
return await setString(StorageKeys.syncState, state);
|
||||
}
|
||||
|
||||
/// 读取同步状态
|
||||
Future<String?> getSyncState() async {
|
||||
return await getString(StorageKeys.syncState);
|
||||
}
|
||||
|
||||
/// 清除同步配置和状态
|
||||
Future<void> clearSyncData() async {
|
||||
await remove(StorageKeys.syncConfig);
|
||||
await remove(StorageKeys.syncState);
|
||||
}
|
||||
|
||||
// ===== client_id 持久化 =====
|
||||
|
||||
/// 获取 client_id,首次调用自动生成并持久化
|
||||
Future<String> getOrCreateClientId() async {
|
||||
var id = await getString(StorageKeys.clientId);
|
||||
if (id == null || id.isEmpty) {
|
||||
id = const Uuid().v4();
|
||||
await setString(StorageKeys.clientId, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/sync_config_model.dart';
|
||||
import '../data/models/sync_event_model.dart';
|
||||
import '../data/models/sync_status_model.dart';
|
||||
import '../data/models/sync_task_model.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
import '../src/rust/api/ffi_types.dart' as ffi_types;
|
||||
|
||||
/// 同步服务单例 - 桥接 Flutter UI 和 Rust 同步引擎
|
||||
class SyncService {
|
||||
static final SyncService instance = SyncService._();
|
||||
SyncService._();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
/// 事件流,供 SyncProvider 订阅
|
||||
final _eventController = StreamController<SyncEventModel>.broadcast();
|
||||
Stream<SyncEventModel> get events => _eventController.stream;
|
||||
|
||||
/// 初始化同步引擎(已初始化时更新配置)
|
||||
Future<void> init(SyncConfigModel config) async {
|
||||
if (_initialized) {
|
||||
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}');
|
||||
|
||||
await ffi.initSyncEngine(config: config.toFfi());
|
||||
|
||||
_initialized = true;
|
||||
AppLogger.d('[FFI] ← initSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 执行初始全量同步
|
||||
Future<SyncSummaryModel> startInitialSync() async {
|
||||
AppLogger.d('[FFI] → startInitialSync');
|
||||
final summary = await ffi.startInitialSync();
|
||||
AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// 启动持续同步
|
||||
Future<void> startContinuousSync() async {
|
||||
AppLogger.d('[FFI] → startContinuousSync');
|
||||
await ffi.startContinuousSync();
|
||||
AppLogger.d('[FFI] ← startContinuousSync: spawned');
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stop() async {
|
||||
AppLogger.d('[FFI] → stopSync');
|
||||
await ffi.stopSync();
|
||||
AppLogger.d('[FFI] ← stopSync: ok');
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pause() async {
|
||||
AppLogger.d('[FFI] → pauseSync');
|
||||
await ffi.pauseSync();
|
||||
AppLogger.d('[FFI] ← pauseSync: ok');
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resume() async {
|
||||
AppLogger.d('[FFI] → resumeSync');
|
||||
await ffi.resumeSync();
|
||||
AppLogger.d('[FFI] ← resumeSync: ok');
|
||||
}
|
||||
|
||||
/// 强制重新同步
|
||||
Future<SyncSummaryModel> forceSync() async {
|
||||
AppLogger.d('[FFI] → forceSync');
|
||||
final summary = await ffi.forceSync();
|
||||
AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// Token 变更时推送给 Rust
|
||||
Future<void> updateTokens(String accessToken) async {
|
||||
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
|
||||
await ffi.updateTokens(accessToken: accessToken);
|
||||
AppLogger.d('[FFI] ← updateTokens: ok');
|
||||
}
|
||||
|
||||
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
|
||||
Future<void> updateConfig(SyncConfigModel config) async {
|
||||
if (!_initialized) return;
|
||||
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||
try {
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
} catch (e) {
|
||||
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 以下为轮询/查询类,用 trace 避免刷屏 ==========
|
||||
|
||||
/// 获取同步状态快照(轮询高频调用,trace 级别)
|
||||
Future<SyncStatusModel> getStatus() async {
|
||||
final status = await ffi.getSyncStatus();
|
||||
AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}');
|
||||
return SyncStatusModel.fromFfi(status);
|
||||
}
|
||||
|
||||
/// 获取活跃 Worker 数量(轮询高频调用,trace 级别)
|
||||
Future<int> getActiveWorkerCount() async {
|
||||
final count = await ffi.getActiveWorkerCount();
|
||||
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
|
||||
return count;
|
||||
}
|
||||
|
||||
/// 获取活跃的同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
|
||||
final tasks = await ffi.getActiveTasks();
|
||||
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
|
||||
return tasks.map((t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 获取最近同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
|
||||
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
|
||||
final tasks = await ffi.getRecentTasks(limit: limit);
|
||||
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
|
||||
return tasks.map((t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 获取任务详情(按需查询,trace 级别)
|
||||
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
|
||||
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
|
||||
final items = await ffi.getTaskDetail(taskId: taskId);
|
||||
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
|
||||
return items.map((i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 分页查询任务详情(trace 级别)
|
||||
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
|
||||
required String taskId,
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset');
|
||||
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi(
|
||||
taskId: taskId,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
));
|
||||
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
|
||||
return items.map((i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
// ========== 以下为低频操作,保持 debug 级别 ==========
|
||||
|
||||
/// 水合文件 (Windows CFAPi)
|
||||
Future<void> hydrateFile(String localPath) async {
|
||||
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
|
||||
await ffi.hydrateFile(localPath: localPath);
|
||||
AppLogger.d('[FFI] ← hydrateFile: ok');
|
||||
}
|
||||
|
||||
/// 检查云端相册目录 (Android)
|
||||
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
|
||||
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}');
|
||||
return {
|
||||
'dcimExists': result.dcimExists,
|
||||
'picturesExists': result.picturesExists,
|
||||
'dcimUri': result.dcimUri,
|
||||
'picturesUri': result.picturesUri,
|
||||
};
|
||||
}
|
||||
|
||||
/// 创建云端相册目录 (Android)
|
||||
Future<void> createCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
|
||||
await ffi.createCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
|
||||
}
|
||||
|
||||
/// 销毁同步引擎
|
||||
Future<void> dispose() async {
|
||||
AppLogger.d('[FFI] → disposeSyncEngine');
|
||||
await _eventController.close();
|
||||
await ffi.disposeSyncEngine();
|
||||
_initialized = false;
|
||||
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 热修改日志级别(立即生效,无需重启)
|
||||
Future<void> setLogLevel(String level) async {
|
||||
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
|
||||
await ffi.setSyncLogLevel(level: level);
|
||||
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync() async {
|
||||
AppLogger.d('[FFI] → resetSync');
|
||||
await ffi.resetSync();
|
||||
AppLogger.d('[FFI] ← resetSync: ok');
|
||||
}
|
||||
}
|
||||
@@ -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