diff --git a/.gitignore b/.gitignore index 03ade25..a0462f3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ DESIGN_OVERVIEW.md DESIGN.md refactory.md DESIGN-copy.md +todo.md +feat-2315.md +*.txt +*.json *.jsonl example.dart install.sh @@ -58,6 +62,11 @@ lib/firebase_options.dart *secret*.json *secrets*.json *.local.json +native/target +native/logs +sync_refactory.md +tools/* +*.diff # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index 8918ed3..564f5f2 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -27,4 +27,12 @@ class StorageKeys { // 搜索历史 static const String searchHistory = 'search_history'; + + // 同步相关 + static const String syncConfig = 'sync_config'; + static const String syncState = 'sync_state'; + static const String clientId = 'client_id'; + + // 日志级别 + static const String logLevel = 'app_log_level'; } diff --git a/lib/core/constants/sync_defaults.dart b/lib/core/constants/sync_defaults.dart new file mode 100644 index 0000000..d342aea --- /dev/null +++ b/lib/core/constants/sync_defaults.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +class SyncDefaults { + SyncDefaults._(); + + /// 默认同步目录 + static String defaultLocalRoot() { + if (Platform.isWindows) { + return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4'; + } else if (Platform.isLinux) { + final xdgDownload = + Platform.environment['XDG_DOWNLOAD_DIR'] ?? ("${Platform.environment['HOME'] ?? ''}/Downloads"); + return '$xdgDownload/Cloudreve4'; + } else if (Platform.isAndroid) { + return ''; // Android 使用系统相册目录 + } + return ''; + } + + static const String defaultRemoteRoot = 'cloudreve://my'; + static const String defaultSyncMode = 'full'; + static const String defaultConflictStrategy = 'keep_both'; + static const int defaultMaxConcurrentTransfers = 3; + static const int defaultBandwidthLimitKbps = 0; + static const int defaultMaxWorkers = 0; // 0 = CPU 核心数 + static const String defaultLogLevel = 'info'; +} diff --git a/lib/core/utils/app_logger.dart b/lib/core/utils/app_logger.dart index 0c06827..e37ea99 100644 --- a/lib/core/utils/app_logger.dart +++ b/lib/core/utils/app_logger.dart @@ -10,6 +10,10 @@ class AppLogger { static Logger? _logger; static File? _logFile; + /// 当前日志级别(默认 info,debug 模式下也是 info 避免刷屏) + static Level _level = Level.info; + static Level get level => _level; + /// 初始化日志,必须在 main 中 await static Future init() async { if (_logger != null) return; @@ -22,6 +26,10 @@ class AppLogger { } _logFile = File(p.join(logDir.path, 'log.txt')); + _createLogger(); + } + + static void _createLogger() { // 2. 配置多路输出:同时输出到控制台和文件 _logger = Logger( printer: PrettyPrinter( @@ -38,10 +46,16 @@ class AppLogger { file: _logFile!, ), ]), - filter: ProductionFilter(), + filter: _LevelFilter(_level), ); } + /// 运行时切换日志级别 + static void setLevel(Level level) { + _level = level; + _createLogger(); + } + // 使用 getter 确保 logger 已初始化,防止空指针 static Logger get _instance { _logger ??= Logger( @@ -67,6 +81,9 @@ class AppLogger { /// Error 级别日志 static void e(String message) => _instance.e(message); + /// Trace 级别日志(高频轮询/查询使用,仅 trace 级别可见) + static void t(String message) => _instance.t(message); + /// Debug 级别日志(支持格式化) static void df(String message, List args) => _instance.d(message, error: args); @@ -132,6 +149,17 @@ class AppLogger { } } +/// 自定义级别过滤器:低于设定级别的日志被过滤 +class _LevelFilter extends LogFilter { + final Level minLevel; + _LevelFilter(this.minLevel); + + @override + bool shouldLog(LogEvent event) { + return event.level.index >= minLevel.index; + } +} + /// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加 class CustomFileOutput extends LogOutput { final File file; diff --git a/lib/core/utils/file_utils.dart b/lib/core/utils/file_utils.dart index 850284e..ca09858 100644 --- a/lib/core/utils/file_utils.dart +++ b/lib/core/utils/file_utils.dart @@ -1,7 +1,29 @@ -/// 文件工具类 class FileUtils { - /// 将路径转换为 Cloudreve URI 格式 - /// "/" → "cloudreve://my", "/subfolder" → "cloudreve://my/subfolder" + static String safeDecodePathSegment(String value, {int maxPasses = 5}) { + var decoded = value; + + for (var i = 0; i < maxPasses; i++) { + try { + final next = Uri.decodeComponent(decoded); + if (next == decoded) break; + decoded = next; + } on FormatException { + break; + } on ArgumentError { + break; + } + } + + return decoded; + } + + static String decodePathForDisplay(String path) { + return path + .split('/') + .map((segment) => safeDecodePathSegment(segment)) + .join('/'); + } + static String toCloudreveUri(String path) { if (path.startsWith('cloudreve://')) return path; if (path == '/' || path.isEmpty) return 'cloudreve://my'; @@ -11,69 +33,18 @@ class FileUtils { static String toCloudreveUriWithQuery( String path, - Map queryParameters, + Map queryParameters, ) { - final baseUri = toCloudreveUri(path); - final filteredQuery = {}; - - for (final entry in queryParameters.entries) { - final value = entry.value; - if (value != null) { - filteredQuery[entry.key] = value.toString(); - } - } - - if (filteredQuery.isEmpty) return baseUri; - - final query = Uri(queryParameters: filteredQuery).query; - final separator = baseUri.contains('?') ? '&' : '?'; - return '$baseUri$separator$query'; + final uri = Uri.parse(toCloudreveUri(path)); + return uri.replace(queryParameters: queryParameters).toString(); } - static String toRelativePath(String path) { - const prefix = 'cloudreve://my'; - if (!path.startsWith(prefix)) { - return path.isEmpty ? '/' : path; - } - - var relative = path.substring(prefix.length); - final queryIndex = relative.indexOf('?'); - if (queryIndex != -1) { - relative = relative.substring(0, queryIndex); - } - - return relative.isEmpty ? '/' : relative; - } - - static String decodePathForDisplay(String path) { - final relativePath = toRelativePath(path); - return relativePath.split('/').map(decodePathSegment).join('/'); - } - - static String decodePathSegment(String value) { - var decoded = value; - - for (var i = 0; i < 5; i++) { - try { - final next = Uri.decodeComponent(decoded); - if (next == decoded) break; - decoded = next; - } on FormatException { - break; - } - } - - return decoded; - } - - /// 获取文件扩展名 static String getFileExtension(String fileName) { final dotIndex = fileName.lastIndexOf('.'); if (dotIndex == -1) return ''; return fileName.substring(dotIndex + 1).toLowerCase(); } - /// 判断是否为图片文件 static bool isImageFile(String fileName) { const imageExtensions = [ 'jpg', @@ -88,24 +59,20 @@ class FileUtils { return imageExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为视频文件 static bool isVideoFile(String fileName) { const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv']; return videoExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为音频文件 static bool isAudioFile(String fileName) { const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']; return audioExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为PDF文件 static bool isPdfFile(String fileName) { return getFileExtension(fileName) == 'pdf'; } - /// 判断是否为文本文件 static bool isTextFile(String fileName) { const textExtensions = [ 'txt', @@ -120,7 +87,6 @@ class FileUtils { return textExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为代码文件 static bool isCodeFile(String fileName) { const codeExtensions = [ 'js', @@ -152,13 +118,11 @@ class FileUtils { return codeExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为压缩文件 static bool isArchiveFile(String fileName) { const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']; return archiveExtensions.contains(getFileExtension(fileName)); } - /// 判断是否为文档文件 static bool isDocumentFile(String fileName) { const docExtensions = [ 'doc', @@ -174,7 +138,6 @@ class FileUtils { return docExtensions.contains(getFileExtension(fileName)); } - /// 判断是否可预览 static bool isPreviewable(String fileName) { return isImageFile(fileName) || isVideoFile(fileName) || @@ -183,7 +146,6 @@ class FileUtils { isCodeFile(fileName); } - /// 获取MIME类型 static String getMimeType(String fileName) { final ext = getFileExtension(fileName); final mimeTypes = { @@ -211,7 +173,6 @@ class FileUtils { return mimeTypes[ext] ?? 'application/octet-stream'; } - /// 获取文件图标 static String getFileIcon(String fileName) { if (isImageFile(fileName)) return 'assets/icons/image.svg'; if (isVideoFile(fileName)) return 'assets/icons/video.svg'; diff --git a/lib/data/models/app_update_model.dart b/lib/data/models/app_update_model.dart new file mode 100644 index 0000000..78d4db7 --- /dev/null +++ b/lib/data/models/app_update_model.dart @@ -0,0 +1,19 @@ +class AppUpdateInfo { + final String version; + final String title; + final String? description; + final Uri pageUrl; + final Uri? downloadUrl; + final String? fileName; + final DateTime? publishedAt; + + const AppUpdateInfo({ + required this.version, + required this.title, + required this.pageUrl, + this.description, + this.downloadUrl, + this.fileName, + this.publishedAt, + }); +} diff --git a/lib/data/models/sync_config_model.dart b/lib/data/models/sync_config_model.dart new file mode 100644 index 0000000..ad61367 --- /dev/null +++ b/lib/data/models/sync_config_model.dart @@ -0,0 +1,114 @@ +import '../../src/rust/api/ffi_types.dart' as ffi; + +class SyncConfigModel { + final String baseUrl; + final String accessToken; + final String refreshToken; + final String localRoot; + final String remoteRoot; + final String syncMode; + final String conflictStrategy; + final String wcfDeleteMode; + final int maxConcurrentTransfers; + final int bandwidthLimitKbps; + final List excludedPaths; + final int maxWorkers; + final String dataDir; + final String clientId; + final String logLevel; + + const SyncConfigModel({ + required this.baseUrl, + required this.accessToken, + required this.refreshToken, + required this.localRoot, + required this.dataDir, + required this.clientId, + this.remoteRoot = 'cloudreve://my', + this.syncMode = 'full', + this.conflictStrategy = 'keep_both', + this.wcfDeleteMode = 'wcf_delete_local_only', + this.maxConcurrentTransfers = 3, + this.bandwidthLimitKbps = 0, + this.excludedPaths = const [], + this.maxWorkers = 0, + this.logLevel = 'info', + }); + + SyncConfigModel copyWith({ + String? baseUrl, + String? accessToken, + String? refreshToken, + String? localRoot, + String? remoteRoot, + String? syncMode, + String? conflictStrategy, + String? wcfDeleteMode, + int? maxConcurrentTransfers, + int? bandwidthLimitKbps, + List? excludedPaths, + int? maxWorkers, + String? dataDir, + String? clientId, + String? logLevel, + }) { + return SyncConfigModel( + baseUrl: baseUrl ?? this.baseUrl, + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + localRoot: localRoot ?? this.localRoot, + remoteRoot: remoteRoot ?? this.remoteRoot, + syncMode: syncMode ?? this.syncMode, + conflictStrategy: conflictStrategy ?? this.conflictStrategy, + wcfDeleteMode: wcfDeleteMode ?? this.wcfDeleteMode, + maxConcurrentTransfers: + maxConcurrentTransfers ?? this.maxConcurrentTransfers, + bandwidthLimitKbps: bandwidthLimitKbps ?? this.bandwidthLimitKbps, + excludedPaths: excludedPaths ?? this.excludedPaths, + maxWorkers: maxWorkers ?? this.maxWorkers, + dataDir: dataDir ?? this.dataDir, + clientId: clientId ?? this.clientId, + logLevel: logLevel ?? this.logLevel, + ); + } + + ffi.SyncConfigFfi toFfi() { + return ffi.SyncConfigFfi( + baseUrl: baseUrl, + accessToken: accessToken, + refreshToken: refreshToken, + localRoot: localRoot, + remoteRoot: remoteRoot, + syncMode: syncMode, + conflictStrategy: conflictStrategy, + wcfDeleteMode: wcfDeleteMode, + maxConcurrentTransfers: maxConcurrentTransfers, + bandwidthLimitKbps: BigInt.from(bandwidthLimitKbps), + excludedPaths: excludedPaths, + maxWorkers: maxWorkers, + dataDir: dataDir, + clientId: clientId, + logLevel: logLevel, + ); + } + + static SyncConfigModel fromFfi(ffi.SyncConfigFfi ffi) { + return SyncConfigModel( + baseUrl: ffi.baseUrl, + accessToken: ffi.accessToken, + refreshToken: ffi.refreshToken, + localRoot: ffi.localRoot, + remoteRoot: ffi.remoteRoot, + syncMode: ffi.syncMode, + conflictStrategy: ffi.conflictStrategy, + wcfDeleteMode: ffi.wcfDeleteMode, + maxConcurrentTransfers: ffi.maxConcurrentTransfers, + bandwidthLimitKbps: ffi.bandwidthLimitKbps.toInt(), + excludedPaths: ffi.excludedPaths, + maxWorkers: ffi.maxWorkers, + dataDir: ffi.dataDir, + clientId: ffi.clientId, + logLevel: ffi.logLevel, + ); + } +} diff --git a/lib/data/models/sync_event_model.dart b/lib/data/models/sync_event_model.dart new file mode 100644 index 0000000..8391633 --- /dev/null +++ b/lib/data/models/sync_event_model.dart @@ -0,0 +1,92 @@ +import '../../src/rust/api/ffi_types.dart' as ffi; + +sealed class SyncEventModel {} + +class SyncStateChanged extends SyncEventModel { + final String newState; + SyncStateChanged(this.newState); +} + +class SyncProgress extends SyncEventModel { + final int synced; + final int total; + final String currentFile; + SyncProgress(this.synced, this.total, this.currentFile); +} + +class SyncFileUploaded extends SyncEventModel { + final String localPath; + final String remoteUri; + SyncFileUploaded(this.localPath, this.remoteUri); +} + +class SyncFileDownloaded extends SyncEventModel { + final String localPath; + final String remoteUri; + SyncFileDownloaded(this.localPath, this.remoteUri); +} + +class SyncConflictDetected extends SyncEventModel { + final String localPath; + final String conflictType; + SyncConflictDetected(this.localPath, this.conflictType); +} + +class SyncError extends SyncEventModel { + final String message; + final bool recoverable; + SyncError(this.message, this.recoverable); +} + +class SyncTokenExpired extends SyncEventModel {} + +class SyncDiskSpaceWarning extends SyncEventModel { + final int availableMb; + SyncDiskSpaceWarning(this.availableMb); +} + +class SyncInitialSyncComplete extends SyncEventModel { + final SyncSummaryModel summary; + SyncInitialSyncComplete(this.summary); +} + +class SyncSummaryModel { + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int conflicts; + final int failed; + final int skipped; + final int deletedLocal; + final int deletedRemote; + final int durationMs; + + const SyncSummaryModel({ + this.uploaded = 0, + this.downloaded = 0, + this.renamed = 0, + this.moved = 0, + this.conflicts = 0, + this.failed = 0, + this.skipped = 0, + this.deletedLocal = 0, + this.deletedRemote = 0, + this.durationMs = 0, + }); + + static SyncSummaryModel fromFfi(ffi.SyncSummaryFfi f) { + return SyncSummaryModel( + uploaded: f.uploaded, + downloaded: f.downloaded, + renamed: f.renamed, + moved: f.moved, + conflicts: f.conflicts, + failed: f.failed, + skipped: f.skipped, + deletedLocal: f.deletedLocal, + deletedRemote: f.deletedRemote, + durationMs: f.durationMs.toInt(), + ); + } +} diff --git a/lib/data/models/sync_status_model.dart b/lib/data/models/sync_status_model.dart new file mode 100644 index 0000000..79721c3 --- /dev/null +++ b/lib/data/models/sync_status_model.dart @@ -0,0 +1,48 @@ +import '../../src/rust/api/ffi_types.dart' as ffi; + +class SyncStatusModel { + final String state; + final int syncedFiles; + final int totalFiles; + final int uploadingCount; + final int downloadingCount; + final int conflictCount; + final int errorCount; + final String? lastSyncTime; + final String? errorMessage; + + const SyncStatusModel({ + this.state = 'idle', + this.syncedFiles = 0, + this.totalFiles = 0, + this.uploadingCount = 0, + this.downloadingCount = 0, + this.conflictCount = 0, + this.errorCount = 0, + this.lastSyncTime, + this.errorMessage, + }); + + bool get isIdle => state == 'idle'; + bool get isInitializing => state == 'initializing'; + bool get isInitialSync => state == 'initialSync'; + bool get isContinuous => state == 'continuous'; + bool get isPaused => state == 'paused'; + bool get isError => state == 'error'; + bool get isStopped => state == 'stopped'; + bool get isActive => isInitializing || isInitialSync || isContinuous; + + static SyncStatusModel fromFfi(ffi.SyncStatusFfi f) { + return SyncStatusModel( + state: f.state, + syncedFiles: f.syncedFiles.toInt(), + totalFiles: f.totalFiles.toInt(), + uploadingCount: f.uploadingCount, + downloadingCount: f.downloadingCount, + conflictCount: f.conflictCount, + errorCount: f.errorCount, + lastSyncTime: f.lastSyncTime, + errorMessage: f.errorMessage, + ); + } +} diff --git a/lib/data/models/sync_task_model.dart b/lib/data/models/sync_task_model.dart new file mode 100644 index 0000000..134c0cb --- /dev/null +++ b/lib/data/models/sync_task_model.dart @@ -0,0 +1,96 @@ +class SyncTaskModel { + final String id; + final String trigger; + final int totalCount; + final int completedCount; + final int failedCount; + final String status; + final String createdAt; + final String updatedAt; + final String? finishedAt; + + const SyncTaskModel({ + required this.id, + required this.trigger, + required this.totalCount, + required this.completedCount, + required this.failedCount, + required this.status, + required this.createdAt, + required this.updatedAt, + this.finishedAt, + }); + + String get shortId => id.length > 8 ? id.substring(0, 8) : id; + + String get triggerLabel => switch (trigger) { + 'initial_sync' => '初始同步', + 'continuous' => '持续同步', + 'manual' => '手动同步', + _ => trigger, + }; + + String get statusLabel => switch (status) { + 'pending' => '等待中', + 'running' => '执行中', + 'completed' => '已完成', + 'failed' => '失败', + 'cancelled' => '已取消', + _ => status, + }; + + double get progress => + totalCount > 0 ? completedCount / totalCount : 0.0; +} + +class SyncTaskItemModel { + final int id; + final String taskId; + final String relativePath; + final String actionType; + final String status; + final int fileSize; + final String? errorMessage; + final String createdAt; + final String updatedAt; + + const SyncTaskItemModel({ + required this.id, + required this.taskId, + required this.relativePath, + required this.actionType, + required this.status, + required this.fileSize, + this.errorMessage, + required this.createdAt, + required this.updatedAt, + }); + + String get actionLabel => switch (actionType) { + 'upload' => '上传', + 'download' => '下载', + 'delete_local' => '删本地', + 'delete_remote' => '删远程', + 'rename' => '重命名', + 'move' => '移动', + 'mkdir_remote' => '创建远程目录', + 'mkdir_local' => '创建本地目录', + 'conflict_resolve' => '冲突解决', + 'create_placeholder' => '创建占位符', + _ => actionType, + }; + + String get statusLabel => switch (status) { + 'pending' => '未开始', + 'running' => '进行中', + 'completed' => '已完成', + 'failed' => '失败', + 'skipped' => '跳过', + _ => status, + }; + + String get filename { + final parts = relativePath.split('/'); + return parts.isNotEmpty ? parts.last : relativePath; + } +} diff --git a/lib/main.dart b/lib/main.dart index 22f1982..c016aee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui'; import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; +import 'package:logger/logger.dart' show Level; import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,6 +12,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:oktoast/oktoast.dart'; import 'package:window_manager/window_manager.dart'; import 'config/app_config.dart'; +import 'core/constants/storage_keys.dart'; import 'presentation/providers/auth_provider.dart'; import 'presentation/providers/file_manager_provider.dart'; import 'presentation/providers/navigation_provider.dart'; @@ -19,9 +21,11 @@ import 'presentation/providers/download_manager_provider.dart'; import 'presentation/providers/user_setting_provider.dart'; import 'presentation/providers/admin_provider.dart'; import 'presentation/providers/quick_access_provider.dart'; +import 'presentation/providers/sync_provider.dart'; import 'presentation/providers/theme_provider.dart'; import 'services/upload_service.dart'; import 'services/api_service.dart'; +import 'services/storage_service.dart'; import 'services/server_service.dart'; import 'services/cache_manager_service.dart'; import 'services/avatar_cache_service.dart'; @@ -29,12 +33,42 @@ import 'core/utils/video_fullscreen.dart'; import 'services/desktop_service.dart'; import 'router/app_router.dart'; import 'presentation/widgets/toast_helper.dart'; +import 'presentation/widgets/update_prompt.dart'; +import 'src/rust/frb_generated.dart' show RustSyncApi; final RouteObserver routeObserver = RouteObserver(); +Level _parseLogLevel(String level) { + return switch (level) { + 'error' => Level.error, + 'warning' => Level.warning, + 'info' => Level.info, + 'debug' => Level.debug, + 'trace' => Level.trace, + _ => Level.info, + }; +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); + await AppLogger.init(); + final savedLevel = await StorageService.instance.getString( + StorageKeys.logLevel, + ); + if (savedLevel != null) { + final level = _parseLogLevel(savedLevel); + AppLogger.setLevel(level); + } + AppLogger.i("应用启动,日志系统就绪"); + + try { + await RustSyncApi.init(); + AppLogger.i("RustSyncApi 初始化成功"); + } catch (e) { + AppLogger.e("RustSyncApi 初始化失败: $e"); + } + // 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常 // 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略 FlutterError.onError = (details) { @@ -51,10 +85,6 @@ void main() async { return false; }; - // 初始化日志 - await AppLogger.init(); - AppLogger.i("应用启动,日志系统就绪"); - // 桌面端初始化窗口管理和系统托盘 if (Platform.isWindows || Platform.isLinux) { // 实例化 FlutterSingleInstance 获取单实例句柄 @@ -156,6 +186,7 @@ class CloudreveApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => UserSettingProvider()), ChangeNotifierProvider(create: (_) => AdminProvider()), ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()), + ChangeNotifierProvider(create: (_) => SyncProvider()), ], child: const AppView(), ); @@ -215,7 +246,7 @@ class AppView extends StatelessWidget { currentWidget = FilterQualityWidget(child: currentWidget); } // 添加全局错误处理 - return ErrorHandler(child: currentWidget); + return UpdatePrompt(child: ErrorHandler(child: currentWidget)); }, ), ); diff --git a/lib/presentation/pages/files/files_page.dart b/lib/presentation/pages/files/files_page.dart index a369282..27dd51b 100644 --- a/lib/presentation/pages/files/files_page.dart +++ b/lib/presentation/pages/files/files_page.dart @@ -179,14 +179,7 @@ class _FilesPageState extends State { if (fileManager.currentPath == '/') { return const Text('文件'); } - final segments = FileUtils.toRelativePath( - fileManager.currentPath, - ).split('/').where((s) => s.isNotEmpty).toList(); - return Text( - segments.isNotEmpty - ? FileUtils.decodePathSegment(segments.last) - : '文件', - ); + return _buildDesktopBreadcrumb(context, fileManager); } return _buildMobileBreadcrumb(context, fileManager); }, @@ -195,15 +188,76 @@ class _FilesPageState extends State { ); } + String _decodePathSegment(String segment) { + return FileUtils.safeDecodePathSegment(segment); + } + + Widget _buildDesktopBreadcrumb( + BuildContext context, + FileManagerProvider fileManager, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final pathParts = fileManager.currentPath.split('/'); + pathParts.removeWhere((part) => part.isEmpty); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => fileManager.currentPath != '/' + ? fileManager.enterFolder('/') + : null, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary), + ), + ), + for (int i = 0; i < pathParts.length; i++) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + LucideIcons.chevronRight, + size: 14, + color: theme.hintColor.withValues(alpha: 0.5), + ), + ), + InkWell( + onTap: () { + final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}'; + if (targetPath != fileManager.currentPath) { + fileManager.enterFolder(targetPath); + } + }, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Text( + _decodePathSegment(pathParts[i]), + style: TextStyle( + color: i == pathParts.length - 1 + ? colorScheme.onSurface + : colorScheme.primary, + fontWeight: i == pathParts.length - 1 + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + ], + ], + ); + } + Widget _buildMobileBreadcrumb( BuildContext context, FileManagerProvider fileManager, ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final pathParts = FileUtils.toRelativePath( - fileManager.currentPath, - ).split('/'); + final pathParts = fileManager.currentPath.split('/'); pathParts.removeWhere((part) => part.isEmpty); return SizedBox( @@ -231,7 +285,7 @@ class _FilesPageState extends State { ), _buildBreadcrumbChip( context, - label: FileUtils.decodePathSegment(pathParts[i]), + label: _decodePathSegment(pathParts[i]), icon: null, color: colorScheme.primary, isLast: i == pathParts.length - 1, diff --git a/lib/presentation/pages/overview/widgets/quick_access_grid.dart b/lib/presentation/pages/overview/widgets/quick_access_grid.dart index 7d01505..e728fc8 100644 --- a/lib/presentation/pages/overview/widgets/quick_access_grid.dart +++ b/lib/presentation/pages/overview/widgets/quick_access_grid.dart @@ -71,6 +71,7 @@ class QuickAccessGrid extends StatelessWidget { child: _QuickAccessButton( item: items[index], onTap: () => _onTap(context, items[index]), + fillHeight: fillHeight, ), ), ); @@ -150,8 +151,13 @@ class QuickAccessGrid extends StatelessWidget { class _QuickAccessButton extends StatelessWidget { final QuickAccessConfig item; final VoidCallback onTap; + final bool fillHeight; - const _QuickAccessButton({required this.item, required this.onTap}); + const _QuickAccessButton({ + required this.item, + required this.onTap, + this.fillHeight = false, + }); @override Widget build(BuildContext context) { @@ -179,24 +185,27 @@ class _QuickAccessButton extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(item.icon, color: foreground, size: 22), - const SizedBox(width: 9), - Flexible( - child: Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: foreground, - fontWeight: FontWeight.w800, - fontSize: 14, + child: SizedBox( + height: fillHeight ? double.infinity : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(item.icon, color: foreground, size: 22), + const SizedBox(width: 9), + Flexible( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontWeight: FontWeight.w800, + fontSize: 14, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/presentation/pages/profile/widgets/quick_functions_section.dart b/lib/presentation/pages/profile/widgets/quick_functions_section.dart index 6a3bc38..e180221 100644 --- a/lib/presentation/pages/profile/widgets/quick_functions_section.dart +++ b/lib/presentation/pages/profile/widgets/quick_functions_section.dart @@ -1,28 +1,67 @@ +import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart'; import 'package:cloudreve4_flutter/router/app_router.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'package:provider/provider.dart'; class _QuickFunction { final IconData icon; final String label; - final String route; + final String? route; + final void Function(BuildContext context)? onTap; const _QuickFunction({ required this.icon, required this.label, - required this.route, + this.route, + this.onTap, }); } class QuickFunctionsSection extends StatelessWidget { const QuickFunctionsSection({super.key}); - static const _functions = [ - _QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share), - _QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav), - _QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload), - _QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin), - _QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings), + static final _functions = [ + _QuickFunction( + icon: LucideIcons.share2, + label: '我的分享', + route: RouteNames.share, + ), + _QuickFunction( + icon: LucideIcons.cloud, + label: 'WebDAV', + route: RouteNames.webdav, + ), + _QuickFunction( + icon: LucideIcons.download, + label: '离线下载', + route: RouteNames.remoteDownload, + ), + _QuickFunction( + icon: LucideIcons.trash2, + label: '回收站', + route: RouteNames.recycleBin, + ), + _QuickFunction( + icon: LucideIcons.refreshCw, + label: '文件同步', + onTap: (ctx) { + final isMobilePlatform = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + if (isMobilePlatform) { + Navigator.of(ctx).pushNamed(RouteNames.syncSettings); + return; + } + ctx.read().setIndex(3); + }, + ), + _QuickFunction( + icon: LucideIcons.settings, + label: '设置', + route: RouteNames.settings, + ), ]; static const double _spacing = 12; @@ -43,9 +82,12 @@ class QuickFunctionsSection extends StatelessWidget { children: [ Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text('快捷功能', - style: theme.textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600)), + Text( + '快捷功能', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), ], ), ), @@ -59,7 +101,8 @@ class QuickFunctionsSection extends StatelessWidget { if (itemWidth < _minItemWidth) break; perRow = next; } - final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow; + final itemWidth = + (availableWidth - _spacing * (perRow - 1)) / perRow; return Wrap( spacing: _spacing, @@ -70,7 +113,13 @@ class QuickFunctionsSection extends StatelessWidget { child: _QuickFunctionCard( icon: fn.icon, label: fn.label, - onTap: () => Navigator.of(context).pushNamed(fn.route), + onTap: () { + if (fn.onTap != null) { + fn.onTap!(context); + } else if (fn.route != null) { + Navigator.of(context).pushNamed(fn.route!); + } + }, ), ); }).toList(), @@ -106,9 +155,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> { final colorScheme = theme.colorScheme; return Card( - color: _hovered - ? colorScheme.surfaceContainerHighest - : null, + color: _hovered ? colorScheme.surfaceContainerHighest : null, child: InkWell( onTap: widget.onTap, borderRadius: BorderRadius.circular(12), diff --git a/lib/presentation/pages/settings/app_settings_page.dart b/lib/presentation/pages/settings/app_settings_page.dart index a5ed785..e09c92b 100644 --- a/lib/presentation/pages/settings/app_settings_page.dart +++ b/lib/presentation/pages/settings/app_settings_page.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:open_file/open_file.dart'; +import 'package:logger/logger.dart' show Level; import 'package:provider/provider.dart'; import '../../../core/constants/storage_keys.dart'; import '../../../core/utils/app_logger.dart'; @@ -38,6 +39,7 @@ class _AppSettingsPageState extends State { String _logFilePath = ''; int? _logFileSize; String _cacheDirPath = ''; + Level _logLevel = Level.info; @override void initState() { @@ -46,6 +48,7 @@ class _AppSettingsPageState extends State { _loadWifiOnlySetting(); _loadGravatarMirrorSetting(); _loadLogInfo(); + _loadLogLevel(); } Future _loadCacheSettings() async { @@ -127,6 +130,15 @@ class _AppSettingsPageState extends State { } } + Future _loadLogLevel() async { + final saved = await StorageService.instance.getString(StorageKeys.logLevel); + if (saved != null && mounted) { + setState(() { + _logLevel = _parseLogLevel(saved); + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -295,6 +307,13 @@ class _AppSettingsPageState extends State { _buildSection( title: '日志管理', children: [ + ListTile( + leading: const Icon(Icons.tune), + title: const Text('日志级别'), + subtitle: Text(_logLevelLabel(_logLevel)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickLogLevel(), + ), ListTile( title: const Text('日志文件路径'), subtitle: Text( @@ -883,4 +902,70 @@ class _AppSettingsPageState extends State { if (mounted) ToastHelper.success('日志已清空'); } } + + String _logLevelLabel(Level level) { + return switch (level) { + Level.error => 'Error — 仅错误', + Level.warning => 'Warning — 错误 + 警告', + Level.info => 'Info — 常规信息', + Level.debug => 'Debug — 调试信息(含FFI交互)', + Level.trace => 'Trace — 全量追踪', + _ => level.name, + }; + } + + Level _parseLogLevel(String level) { + return switch (level) { + 'error' => Level.error, + 'warning' => Level.warning, + 'info' => Level.info, + 'debug' => Level.debug, + 'trace' => Level.trace, + _ => Level.info, + }; + } + + Future _pickLogLevel() async { + final levels = [ + (Level.error, 'Error — 仅错误'), + (Level.warning, 'Warning — 错误 + 警告'), + (Level.info, 'Info — 常规信息'), + (Level.debug, 'Debug — 调试信息(含FFI交互)'), + (Level.trace, 'Trace — 全量追踪'), + ]; + + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('日志级别'), + children: levels.map((e) { + final isSelected = _logLevel == e.$1; + return SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e.$1), + child: Row( + children: [ + Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + size: 20, + color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor, + ), + const SizedBox(width: 8), + Text(e.$2), + ], + ), + ); + }).toList(), + ), + ); + + if (result != null && result != _logLevel) { + setState(() => _logLevel = result); + AppLogger.setLevel(result); + await StorageService.instance.setString( + StorageKeys.logLevel, + result.name, + ); + if (mounted) ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}'); + } + } } diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index 6f53ef0..8fa22cc 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -16,6 +16,7 @@ import 'file_preferences_page.dart'; import 'app_settings_page.dart'; import 'credit_history_page.dart'; import 'quick_access_settings_page.dart'; +import '../../../router/app_router.dart'; /// 设置主页 class SettingsPage extends StatefulWidget { @@ -105,6 +106,12 @@ class _SettingsPageState extends State { _buildSection( title: '偏好', children: [ + _SettingsTile( + icon: Icons.sync_outlined, + title: '文件同步', + subtitle: '本地与云端文件自动同步', + onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings), + ), _SettingsTile( icon: Icons.apps_outlined, title: '快捷入口', diff --git a/lib/presentation/pages/shell/app_shell.dart b/lib/presentation/pages/shell/app_shell.dart index b175067..6c44590 100644 --- a/lib/presentation/pages/shell/app_shell.dart +++ b/lib/presentation/pages/shell/app_shell.dart @@ -1,21 +1,44 @@ -import 'package:animations/animations.dart'; import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart'; +import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart'; import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart'; import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../../router/app_router.dart'; import '../files/files_page.dart'; import '../overview/overview_page.dart'; +import '../sync/sync_page.dart'; import '../tasks/tasks_page.dart'; import '../profile/profile_page.dart'; +class _ShellPageSlot extends StatefulWidget { + final Widget child; + + const _ShellPageSlot({required this.child}); + + @override + State<_ShellPageSlot> createState() => _ShellPageSlotState(); +} + +class _ShellPageSlotState extends State<_ShellPageSlot> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + class AppShell extends StatefulWidget { const AppShell({super.key}); @@ -23,7 +46,52 @@ class AppShell extends StatefulWidget { State createState() => _AppShellState(); } -class _AppShellState extends State with GestureHandlerMixin { +class _AppShellState extends State + with GestureHandlerMixin, TickerProviderStateMixin { + final Set _visitedPageIndexes = {0}; + late AnimationController _syncSpinController; + + bool get _showSyncTab => + defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS; + + List get _pages => _showSyncTab + ? [ + const OverviewPage(), + const FilesPage(), + const TasksPage(), + const SyncPage(), + const ProfilePage(), + ] + : [ + const OverviewPage(), + const FilesPage(), + const TasksPage(), + const ProfilePage(), + ]; + + int _clampedIndex(int index) { + final maxIndex = _pages.length - 1; + if (index < 0) return 0; + if (index > maxIndex) return maxIndex; + return index; + } + + @override + void initState() { + super.initState(); + _syncSpinController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + } + + @override + void dispose() { + _syncSpinController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; @@ -33,12 +101,19 @@ class _AppShellState extends State with GestureHandlerMixin { canPop: false, onPopInvokedWithResult: (didPop, result) async { if (!didPop) { - final navProvider = Provider.of(context, listen: false); - final fileManager = Provider.of(context, listen: false); + final navProvider = Provider.of( + context, + listen: false, + ); + final fileManager = Provider.of( + context, + listen: false, + ); if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') { await fileManager.goBack(); - } else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) { + } else if (navProvider.currentIndex != 0 && + navProvider.currentIndex != 1) { navProvider.setIndex(0); } else { await checkExitApp(context); @@ -57,42 +132,74 @@ class _AppShellState extends State with GestureHandlerMixin { } Widget _buildPageContent(BuildContext context, int currentIndex) { - const pages = [ - OverviewPage(), - FilesPage(), - TasksPage(), - ProfilePage(), - ]; + final pages = _pages; + final visibleIndex = _clampedIndex(currentIndex); + _visitedPageIndexes.add(visibleIndex); - return PageTransitionSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation, secondaryAnimation) { - return SharedAxisTransition( - animation: animation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.horizontal, - child: child, - ); - }, - child: KeyedSubtree( - key: ValueKey(currentIndex), - child: pages[currentIndex], + return RepaintBoundary( + child: IndexedStack( + index: visibleIndex, + children: List.generate(pages.length, (index) { + if (!_visitedPageIndexes.contains(index)) { + return const SizedBox.shrink(); + } + return _ShellPageSlot(child: pages[index]); + }), ), ); } - Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) { + Widget _buildSyncIcon({required bool isSelected, required double size}) { + return Consumer( + builder: (context, sync, _) { + final hasWorkers = sync.activeWorkerCount > 0; + + if (hasWorkers && !_syncSpinController.isAnimating) { + _syncSpinController.repeat(); + } else if (!hasWorkers && _syncSpinController.isAnimating) { + _syncSpinController.stop(); + _syncSpinController.value = 0; + } + + final icon = Icon( + LucideIcons.refreshCw, + size: size, + weight: isSelected ? 700 : 400, + ); + + if (!hasWorkers) return icon; + + return ListenableBuilder( + listenable: _syncSpinController, + builder: (context, child) { + return Transform.rotate( + angle: _syncSpinController.value * 2 * 3.14159265, + child: child, + ); + }, + child: icon, + ); + }, + ); + } + + Widget _buildMobileLayout( + BuildContext context, + NavigationProvider navProvider, + ) { return Scaffold( body: _buildPageContent(context, navProvider.currentIndex), bottomNavigationBar: GlassmorphismContainer( borderRadius: 0, child: Consumer2( builder: (context, uploadManager, downloadManager, _) { - final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; + final activeCount = + uploadManager.activeTasks.length + + downloadManager.downloadingCount; return NavigationBar( height: 64, - selectedIndex: navProvider.currentIndex, + selectedIndex: _clampedIndex(navProvider.currentIndex), onDestinationSelected: (i) => navProvider.setIndex(i), destinations: [ const NavigationDestination( @@ -118,6 +225,30 @@ class _AppShellState extends State with GestureHandlerMixin { ), label: '任务', ), + if (_showSyncTab) + NavigationDestination( + icon: Consumer( + builder: (context, sync, _) { + final count = sync.activeWorkerCount; + return Badge( + isLabelVisible: count > 0, + label: Text('$count'), + child: _buildSyncIcon(isSelected: false, size: 24), + ); + }, + ), + selectedIcon: Consumer( + builder: (context, sync, _) { + final count = sync.activeWorkerCount; + return Badge( + isLabelVisible: count > 0, + label: Text('$count'), + child: _buildSyncIcon(isSelected: true, size: 24), + ); + }, + ), + label: '同步', + ), const NavigationDestination( icon: Icon(LucideIcons.user), selectedIcon: Icon(LucideIcons.user, weight: 700), @@ -131,7 +262,10 @@ class _AppShellState extends State with GestureHandlerMixin { ); } - Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) { + Widget _buildDesktopLayout( + BuildContext context, + NavigationProvider navProvider, + ) { final theme = Theme.of(context); final authProvider = context.watch(); final user = authProvider.user; @@ -141,16 +275,16 @@ class _AppShellState extends State with GestureHandlerMixin { body: Row( children: [ NavigationRail( - selectedIndex: navProvider.currentIndex, + selectedIndex: _clampedIndex(navProvider.currentIndex), onDestinationSelected: (i) => navProvider.setIndex(i), leading: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: GestureDetector( - onTap: () => navProvider.setIndex(3), + onTap: () => navProvider.setIndex(_pages.length - 1), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: navProvider.currentIndex == 3 + border: navProvider.currentIndex == _pages.length - 1 ? Border.all( color: theme.colorScheme.primary, width: 2.5, @@ -180,7 +314,9 @@ class _AppShellState extends State with GestureHandlerMixin { NavigationRailDestination( icon: Consumer2( builder: (context, uploadManager, downloadManager, _) { - final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; + final activeCount = + uploadManager.activeTasks.length + + downloadManager.downloadingCount; return Badge( isLabelVisible: activeCount > 0, label: Text('$activeCount'), @@ -191,6 +327,30 @@ class _AppShellState extends State with GestureHandlerMixin { selectedIcon: const Icon(LucideIcons.listChecks, weight: 700), label: const Text('任务'), ), + if (_showSyncTab) + NavigationRailDestination( + icon: Consumer( + builder: (context, sync, _) { + final count = sync.activeWorkerCount; + return Badge( + isLabelVisible: count > 0, + label: Text('$count'), + child: _buildSyncIcon(isSelected: false, size: 24), + ); + }, + ), + selectedIcon: Consumer( + builder: (context, sync, _) { + final count = sync.activeWorkerCount; + return Badge( + isLabelVisible: count > 0, + label: Text('$count'), + child: _buildSyncIcon(isSelected: true, size: 24), + ); + }, + ), + label: const Text('同步'), + ), const NavigationRailDestination( icon: Icon(LucideIcons.user), selectedIcon: Icon(LucideIcons.user, weight: 700), @@ -206,32 +366,38 @@ class _AppShellState extends State with GestureHandlerMixin { context, icon: LucideIcons.share2, label: '我的分享', - onTap: () => Navigator.of(context).pushNamed(RouteNames.share), + onTap: () => + Navigator.of(context).pushNamed(RouteNames.share), ), _buildSecondaryNavItem( context, icon: LucideIcons.cloud, label: 'WebDAV', - onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav), + onTap: () => + Navigator.of(context).pushNamed(RouteNames.webdav), ), _buildSecondaryNavItem( context, icon: LucideIcons.download, label: '离线下载', - onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload), + onTap: () => Navigator.of( + context, + ).pushNamed(RouteNames.remoteDownload), ), _buildSecondaryNavItem( context, icon: LucideIcons.trash2, label: '回收站', - onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin), + onTap: () => + Navigator.of(context).pushNamed(RouteNames.recycleBin), ), const Divider(indent: 12, endIndent: 12), _buildSecondaryNavItem( context, icon: LucideIcons.settings, label: '设置', - onTap: () => Navigator.of(context).pushNamed(RouteNames.settings), + onTap: () => + Navigator.of(context).pushNamed(RouteNames.settings), ), _buildSecondaryNavItem( context, @@ -245,9 +411,7 @@ class _AppShellState extends State with GestureHandlerMixin { ), ), const VerticalDivider(thickness: 1, width: 1), - Expanded( - child: _buildPageContent(context, navProvider.currentIndex), - ), + Expanded(child: _buildPageContent(context, navProvider.currentIndex)), ], ), ); @@ -278,7 +442,10 @@ class _AppShellState extends State with GestureHandlerMixin { Future _handleLogout(BuildContext context) async { final authProvider = Provider.of(context, listen: false); - final fileManager = Provider.of(context, listen: false); + final fileManager = Provider.of( + context, + listen: false, + ); final confirmed = await showDialog( context: context, @@ -302,7 +469,9 @@ class _AppShellState extends State with GestureHandlerMixin { await authProvider.logout(); fileManager.clearFiles(); if (context.mounted) { - Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false); + Navigator.of( + context, + ).pushNamedAndRemoveUntil(RouteNames.login, (route) => false); } } } diff --git a/lib/presentation/pages/splash/splash_page.dart b/lib/presentation/pages/splash/splash_page.dart index f480963..df61d01 100644 --- a/lib/presentation/pages/splash/splash_page.dart +++ b/lib/presentation/pages/splash/splash_page.dart @@ -5,6 +5,7 @@ import '../../../services/api_service.dart'; import '../../../services/server_service.dart'; import '../../../services/storage_service.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/sync_provider.dart'; /// 启动页 class SplashPage extends StatefulWidget { @@ -45,6 +46,16 @@ class _SplashPageState extends State { if (!mounted) return; if (authProvider.isAuthenticated) { + // 自动恢复同步(如果之前处于同步状态) + if (!mounted) return; + final syncProvider = Provider.of(context, listen: false); + final token = authProvider.token; + await syncProvider.autoResumeIfNeeded( + currentAccessToken: token?.accessToken, + currentRefreshToken: token?.refreshToken, + ); + + if (!mounted) return; Navigator.of(context).pushReplacementNamed(RouteNames.home); } else { Navigator.of(context).pushReplacementNamed(RouteNames.login); diff --git a/lib/presentation/pages/sync/sync_log_viewer_page.dart b/lib/presentation/pages/sync/sync_log_viewer_page.dart new file mode 100644 index 0000000..5132bd2 --- /dev/null +++ b/lib/presentation/pages/sync/sync_log_viewer_page.dart @@ -0,0 +1,131 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import '../../widgets/toast_helper.dart'; + +/// 同步引擎日志预览页面 +class SyncLogViewerPage extends StatefulWidget { + const SyncLogViewerPage({super.key}); + + @override + State createState() => _SyncLogViewerPageState(); +} + +class _SyncLogViewerPageState extends State { + String _logContent = ''; + bool _isLoading = true; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _loadLog(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _getSyncLogPath() async { + final appDir = await getApplicationSupportDirectory(); + return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt'; + } + + Future _loadLog() async { + setState(() => _isLoading = true); + try { + final path = await _getSyncLogPath(); + final file = File(path); + if (!await file.exists()) { + if (mounted) { + setState(() { + _logContent = ''; + _isLoading = false; + }); + } + return; + } + + final lines = await file.readAsLines(); + const maxLines = 1000; + String content; + if (lines.length <= maxLines) { + content = lines.join('\n'); + } else { + content = '... (仅显示最近 $maxLines 行)\n\n' + '${lines.sublist(lines.length - maxLines).join('\n')}'; + } + + if (mounted) { + setState(() { + _logContent = content; + _isLoading = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ToastHelper.error('读取同步日志失败:$e'); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('同步日志'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadLog, + tooltip: '刷新', + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _logContent.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, + size: 48, color: theme.hintColor.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text('暂无同步日志', style: TextStyle(color: theme.hintColor)), + ], + ), + ) + : Container( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(12), + child: SelectableText( + _logContent, + style: TextStyle( + fontFamily: 'SourceCodePro', + fontSize: 13, + height: 1.5, + color: isDark ? Colors.grey[300] : Colors.grey[900], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/sync/sync_page.dart b/lib/presentation/pages/sync/sync_page.dart new file mode 100644 index 0000000..7ab824c --- /dev/null +++ b/lib/presentation/pages/sync/sync_page.dart @@ -0,0 +1,604 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import '../../../data/models/sync_task_model.dart'; +import '../../providers/sync_provider.dart'; +import '../../widgets/toast_helper.dart'; +import 'sync_settings_page.dart'; + +/// 同步 Tab 页 - 展示实时同步状态、活跃任务和已完成任务 +class SyncPage extends StatefulWidget { + const SyncPage({super.key}); + + @override + State createState() => _SyncPageState(); +} + +class _SyncPageState extends State { + // 展开的任务 ID + final Set _expandedTasks = {}; + // 正在加载详情的任务 ID + final Set _loadingDetails = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadRecentTasks(); + }); + } + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('文件同步'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => _navigateToSettings(), + tooltip: '同步设置', + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + final sync = context.read(); + sync.invalidateAllTaskDetails(); + await sync.loadRecentTasks(); + }, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _buildStatusCard(sync, theme), + const SizedBox(height: 8), + _buildActiveTasksSection(sync, theme), + const SizedBox(height: 8), + _buildCompletedTasksSection(sync, theme), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + void _navigateToSettings() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SyncSettingsPage()), + ); + } + + Widget _buildStatusCard(SyncProvider sync, ThemeData theme) { + final isActive = sync.isActive; + final isPaused = sync.isPaused; + final hasError = sync.hasError; + + Color statusColor; + IconData statusIcon; + String statusText; + + if (isActive) { + statusColor = theme.colorScheme.primary; + statusIcon = Icons.sync; + statusText = _syncModeLabel(sync); + } else if (isPaused) { + statusColor = Colors.orange; + statusIcon = Icons.pause_circle_outline; + statusText = '已暂停'; + } else if (hasError) { + statusColor = theme.colorScheme.error; + statusIcon = Icons.error_outline; + statusText = '同步错误'; + } else if (sync.state == SyncState.stopped) { + statusColor = theme.disabledColor; + statusIcon = Icons.stop_circle_outlined; + statusText = '已停止'; + } else { + statusColor = theme.disabledColor; + statusIcon = Icons.cloud_off; + statusText = '未启动'; + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 5), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(statusIcon, color: statusColor, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusText, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (sync.errorMessage != null) + Text( + sync.errorMessage!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (sync.activeWorkerCount > 0) + Badge( + label: Text('${sync.activeWorkerCount}'), + child: Icon(LucideIcons.loader, color: statusColor, size: 24), + ), + ], + ), + if (isActive || isPaused) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: sync.totalFiles > 0 ? sync.progress : null, + minHeight: 6, + ), + ), + const SizedBox(height: 8), + Text( + sync.totalFiles > 0 + ? '${sync.syncedFiles} / ${sync.totalFiles} 文件' + : sync.state == SyncState.continuous + ? '持续同步中' + : '正在同步...', + style: theme.textTheme.bodySmall, + ), + ], + if (sync.lastSummary != null) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _summaryChip(theme, '上传', sync.lastSummary!.uploaded), + _summaryChip(theme, '下载', sync.lastSummary!.downloaded), + _summaryChip(theme, '冲突', sync.lastSummary!.conflicts), + _summaryChip(theme, '失败', sync.lastSummary!.failed), + _summaryChip(theme, '重命名', sync.lastSummary!.renamed), + _summaryChip(theme, '移动', sync.lastSummary!.moved), + ], + ), + ], + const SizedBox(height: 12), + Row( + children: [ + if (!sync.isActive && !sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => _navigateToSettings(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('开始同步'), + ), + ), + if (sync.isActive) + Expanded( + child: OutlinedButton.icon( + onPressed: () => sync.pause(), + icon: const Icon(Icons.pause, size: 18), + label: const Text('暂停'), + ), + ), + if (sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => sync.resume(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('恢复'), + ), + ), + if (sync.isActive || sync.isPaused) ...[ + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _stopSync(sync), + icon: const Icon(Icons.stop, size: 18), + label: const Text('停止'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _summaryChip(ThemeData theme, String label, int value) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), + Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)), + ], + ); + } + + String _syncModeLabel(SyncProvider sync) { + final mode = sync.persistedConfig?.syncMode ?? 'full'; + return switch (mode) { + 'full' => '全量同步中', + 'upload_only' => '仅上传中', + 'download_only' => '仅下载中', + 'album' => '相册同步中', + 'mirror_wcf' => '镜像同步中', + _ => '同步中', + }; + } + + Widget _buildActiveTasksSection(SyncProvider sync, ThemeData theme) { + final tasks = sync.activeTasks; + + return _buildTaskSection( + theme: theme, + title: '正在同步', + icon: LucideIcons.loader, + tasks: tasks, + emptyText: '暂无正在进行的同步任务', + ); + } + + Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) { + final tasks = sync.recentTasks + .where((t) => + (t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') && + t.totalCount > 0) + .toList(); + + return _buildTaskSection( + theme: theme, + title: '已完成', + icon: LucideIcons.checkCircle2, + tasks: tasks, + emptyText: '暂无已完成的同步任务', + ); + } + + Widget _buildTaskSection({ + required ThemeData theme, + required String title, + required IconData icon, + required List tasks, + required String emptyText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8), + child: Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + if (tasks.isEmpty) + Card( + margin: const EdgeInsets.symmetric(horizontal: 5), + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text(emptyText, style: TextStyle(color: theme.hintColor)), + ), + ), + ) + else + ...tasks.map((task) => _buildTaskCard(task, theme)), + ], + ); + } + + Widget _buildTaskCard(SyncTaskModel task, ThemeData theme) { + final isExpanded = _expandedTasks.contains(task.id); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Column( + children: [ + InkWell( + onTap: () => _toggleTaskExpand(task.id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12), + child: Row( + children: [ + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: task.id)); + ToastHelper.success('已复制任务 ID'); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 72, + child: Text( + task.shortId, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.hintColor, + ), + ), + ), + Icon(Icons.copy, size: 14, color: theme.hintColor), + ], + ), + ), + Expanded( + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _statusColor(task.status, theme).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + task.statusLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: _statusColor(task.status, theme), + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${task.completedCount}/${task.totalCount}', + style: theme.textTheme.bodySmall, + ), + if (task.failedCount > 0) ...[ + const SizedBox(width: 8), + Text( + '失败${task.failedCount}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + color: theme.hintColor, + ), + ], + ), + ), + ), + if (isExpanded) + _buildTaskDetailList(task, theme), + ], + ), + ); + } + + Widget _buildTaskDetailList(SyncTaskModel task, ThemeData theme) { + final sync = context.watch(); + final items = sync.getCachedTaskDetail(task.id); + + if (_loadingDetails.contains(task.id)) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ); + } + + if (items == null || items.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)), + ); + } + + final hasMore = sync.hasMoreTaskDetail(task.id); + + return Column( + children: [ + const Divider(height: 1, indent: 16, endIndent: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Row( + children: [ + SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), + Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), + SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), + SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), + ], + ), + ), + ...items.map((item) => _buildTaskItemRow(item, theme)), + if (hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextButton.icon( + onPressed: () => _loadMoreDetail(task.id), + icon: const Icon(Icons.expand_more, size: 16), + label: const Text('加载更多'), + ), + ), + const SizedBox(height: 4), + ], + ); + } + + Widget _buildTaskItemRow(SyncTaskItemModel item, ThemeData theme) { + final timeStr = _formatTime(item.updatedAt); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Row( + children: [ + SizedBox( + width: 70, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: _actionColor(item.actionType, theme).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + item.actionLabel, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: _actionColor(item.actionType, theme), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.filename, + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + SizedBox( + width: 55, + child: Text( + item.statusLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: _itemStatusColor(item.status, theme), + ), + ), + ), + SizedBox( + width: 110, + child: Text( + timeStr, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + + Color _statusColor(String status, ThemeData theme) { + return switch (status) { + 'running' => theme.colorScheme.primary, + 'completed' => Colors.green, + 'failed' => theme.colorScheme.error, + 'cancelled' => theme.hintColor, + _ => theme.hintColor, + }; + } + + Color _actionColor(String actionType, ThemeData theme) { + return switch (actionType) { + 'upload' => Colors.blue, + 'download' => Colors.green, + 'create_placeholder' => Colors.teal, + 'delete_local' || 'delete_remote' => theme.colorScheme.error, + 'rename' || 'move' => Colors.orange, + 'conflict_resolve' => Colors.purple, + _ => theme.colorScheme.primary, + }; + } + + Color _itemStatusColor(String status, ThemeData theme) { + return switch (status) { + 'completed' => Colors.green, + 'failed' => theme.colorScheme.error, + 'running' => theme.colorScheme.primary, + _ => theme.hintColor, + }; + } + + String _formatTime(String isoTime) { + try { + final dt = DateTime.parse(isoTime).toLocal(); + return '${dt.year}/${dt.month.toString().padLeft(2, '0')}/${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } catch (_) { + return isoTime; + } + } + + void _toggleTaskExpand(String taskId) { + final sync = context.read(); + if (_expandedTasks.contains(taskId)) { + setState(() => _expandedTasks.remove(taskId)); + sync.unwatchTaskDetail(taskId); + return; + } + + setState(() => _expandedTasks.add(taskId)); + sync.watchTaskDetail(taskId); + + if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) { + setState(() => _loadingDetails.add(taskId)); + sync.getTaskDetail(taskId).whenComplete(() { + if (mounted) { + setState(() => _loadingDetails.remove(taskId)); + } + }); + } + } + + void _loadMoreDetail(String taskId) { + final sync = context.read(); + sync.loadMoreTaskDetail(taskId); + } + + Future _stopSync(SyncProvider sync) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('停止同步'), + content: const Text('确定要停止文件同步吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + child: const Text('停止'), + ), + ], + ), + ); + + if (confirmed == true) { + await sync.stop(); + } + } +} diff --git a/lib/presentation/pages/sync/sync_settings_page.dart b/lib/presentation/pages/sync/sync_settings_page.dart new file mode 100644 index 0000000..b439026 --- /dev/null +++ b/lib/presentation/pages/sync/sync_settings_page.dart @@ -0,0 +1,1140 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:open_file/open_file.dart'; + +import '../../../core/constants/sync_defaults.dart'; +import '../../../core/utils/app_logger.dart'; +import '../../../data/models/sync_config_model.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/sync_provider.dart'; +import '../../widgets/desktop_constrained.dart'; +import '../../widgets/folder_picker.dart'; +import '../../widgets/toast_helper.dart'; +import 'sync_log_viewer_page.dart'; + +/// 同步设置页面 +class SyncSettingsPage extends StatefulWidget { + const SyncSettingsPage({super.key}); + + @override + State createState() => _SyncSettingsPageState(); +} + +class _SyncSettingsPageState extends State { + late TextEditingController _localRootController; + String _remoteRoot = SyncDefaults.defaultRemoteRoot; + String _syncMode = 'full'; + String _conflictStrategy = 'keep_both'; + String _wcfDeleteMode = 'wcf_delete_local_only'; + int _maxConcurrent = 3; + int _bandwidthLimitKbps = 0; + int _maxWorkers = SyncDefaults.defaultMaxWorkers; + String _logLevel = 'info'; + String _syncLogFilePath = ''; + int? _syncLogFileSize; + + @override + void initState() { + super.initState(); + _localRootController = TextEditingController( + text: SyncDefaults.defaultLocalRoot(), + ); + + AppLogger.i('默认同步目录: ${_localRootController.text}'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final sync = context.read(); + final config = sync.persistedConfig; + if (config != null) { + setState(() { + _localRootController.text = config.localRoot; + _remoteRoot = config.remoteRoot; + _syncMode = config.syncMode; + _conflictStrategy = config.conflictStrategy; + _wcfDeleteMode = config.wcfDeleteMode; + _maxConcurrent = config.maxConcurrentTransfers; + _bandwidthLimitKbps = config.bandwidthLimitKbps; + _maxWorkers = config.maxWorkers; + _logLevel = config.logLevel; + }); + } + _loadSyncLogInfo(); + }); + } + + @override + void dispose() { + _localRootController.dispose(); + super.dispose(); + } + + bool get _isDesktop => Platform.isWindows || Platform.isLinux; + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + final auth = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('文件同步')), + body: DesktopConstrained( + child: ListView( + children: [ + _buildSyncStatus(sync), + if (_isDesktop) ...[ + _buildSection( + title: '同步目录', + children: [ + ListTile( + leading: const Icon(Icons.folder_outlined), + title: const Text('本地同步目录'), + subtitle: Text(_localRootController.text), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickLocalFolder(), + ), + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: const Text('远程同步目录'), + subtitle: Text(_remoteRoot), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickRemoteFolder(), + ), + ], + ), + _buildSection( + title: '同步模式', + children: [ + if (sync.isActive || sync.isPaused) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + '同步运行中,无法切换模式。请先停止同步再修改。', + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), + ), + ), + RadioGroup( + groupValue: _syncMode, + onChanged: (sync.isActive || sync.isPaused) + ? (_) {} + : (v) { + if (v != null) _handleSyncModeChange(v); + }, + child: Column( + children: [ + if (Platform.isWindows) + RadioListTile( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('镜像同步'), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '推荐', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + subtitle: const Text('本地仅保留占位符,打开文件时自动下载'), + value: 'mirror_wcf', + ), + RadioListTile( + title: const Text('全量同步'), + subtitle: const Text('双向同步所有文件'), + value: 'full', + ), + RadioListTile( + title: const Text('仅上传本地到远程'), + subtitle: const Text('本地文件同步到远程,不影响远程已有文件'), + value: 'upload_only', + ), + RadioListTile( + title: const Text('仅下载远程到本地'), + subtitle: const Text('远程文件同步到本地,本地修改不影响远程'), + value: 'download_only', + ), + ], + ), + ), + ], + ), + if (_syncMode == 'full') + _buildSection( + title: '冲突处理', + children: [ + ListTile( + leading: const Icon(Icons.merge_outlined), + title: const Text('冲突解决策略'), + subtitle: (sync.isActive || sync.isPaused) + ? const Text('同步运行中,无法修改') + : Text(_conflictStrategyLabel(_conflictStrategy)), + trailing: const Icon(Icons.chevron_right), + onTap: (sync.isActive || sync.isPaused) ? null : () => _pickConflictStrategy(), + ), + ], + ), + if (_syncMode == 'mirror_wcf') + _buildSection( + title: '镜像删除模式', + children: [ + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('本地删除行为'), + subtitle: (sync.isActive || sync.isPaused) + ? const Text('同步运行中,无法修改') + : Text(_wcfDeleteModeLabel(_wcfDeleteMode)), + trailing: const Icon(Icons.chevron_right), + onTap: (sync.isActive || sync.isPaused) ? null : () => _pickWcfDeleteMode(), + ), + ], + ), + ], + if (Platform.isAndroid) + _buildSection( + title: '相册同步', + children: [ + SwitchListTile( + title: const Text('自动备份相册'), + subtitle: const Text('将手机照片自动备份到云端'), + value: _syncMode == 'album', + onChanged: (v) { + setState(() => _syncMode = v ? 'album' : 'full'); + _pushConfig(); + }, + ), + ], + ), + _buildSection( + title: '性能', + children: [ + if (_isDesktop) + ListTile( + leading: const Icon(Icons.work_outline), + title: const Text('最大并发任务数'), + subtitle: Text(_maxWorkersLabel), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickMaxWorkers(), + ), + ListTile( + leading: const Icon(Icons.sync_outlined), + title: const Text('最大并发传输数'), + subtitle: Text('$_maxConcurrent'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickConcurrency(), + ), + ListTile( + leading: const Icon(Icons.speed_outlined), + title: const Text('带宽限制'), + subtitle: Text( + _bandwidthLimitKbps > 0 + ? '${(_bandwidthLimitKbps / 1024).toStringAsFixed(1)} MB/s' + : '不限制', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickBandwidthLimit(), + ), + ], + ), + _buildSection( + title: '同步控制', + children: [ + if (!sync.isActive && !sync.isPaused) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _startSync(auth, sync), + icon: const Icon(Icons.play_arrow), + label: const Text('开始同步'), + ), + ), + ), + if (sync.isActive || sync.isPaused) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + if (sync.isActive) + Expanded( + child: OutlinedButton.icon( + onPressed: () => sync.pause(), + icon: const Icon(Icons.pause), + label: const Text('暂停'), + ), + ), + if (sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => sync.resume(), + icon: const Icon(Icons.play_arrow), + label: const Text('恢复'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _stopSync(sync), + icon: const Icon(Icons.stop), + label: const Text('停止'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ), + if (sync.isActive) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => sync.forceSync(), + icon: const Icon(Icons.refresh), + label: const Text('强制重新同步'), + ), + ), + ), + if (sync.engineInitialized) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _resetSync(sync), + icon: const Icon(Icons.delete_forever), + label: const Text('重置同步'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ), + ), + ], + ), + _buildSection( + title: '同步日志', + children: [ + ListTile( + leading: const Icon(Icons.tune), + title: const Text('日志级别'), + subtitle: Text(_logLevelLabel(_logLevel)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickLogLevel(), + ), + ListTile( + title: const Text('日志文件路径'), + subtitle: Text( + _syncLogFilePath.isNotEmpty ? _syncLogFilePath : '未初始化', + style: const TextStyle(fontSize: 11), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ListTile( + title: const Text('日志文件大小'), + subtitle: Text(_formatBytes(_syncLogFileSize)), + ), + if (!Platform.isAndroid) + ListTile( + leading: const Icon(Icons.folder_open), + title: const Text('打开日志目录'), + trailing: const Icon(Icons.chevron_right), + onTap: _openSyncLogFolder, + ), + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: const Text('导出日志'), + subtitle: const Text('导出到 Download 目录'), + trailing: const Icon(Icons.chevron_right), + onTap: _exportSyncLog, + ), + ListTile( + leading: const Icon(Icons.visibility_outlined), + title: const Text('预览日志'), + trailing: const Icon(Icons.chevron_right), + onTap: _previewSyncLog, + ), + ListTile( + leading: const Icon(Icons.delete_outline, color: Colors.red), + title: const Text('清空日志'), + trailing: const Icon(Icons.chevron_right), + onTap: _clearSyncLog, + ), + ], + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + String get _maxWorkersLabel { + if (_maxWorkers == 0) return '自动 (CPU核心数)'; + return '$_maxWorkers'; + } + + Future _handleSyncModeChange(String newMode) async { + if (newMode == 'full') { + final confirmed = await _showModeConfirmDialog( + title: '全量同步', + description: '此模式下:\n\n' + '• 本地和远程双向同步所有文件\n' + '• 本地新增、修改的文件将上传到远程\n' + '• 远程新增、修改的文件将下载到本地\n' + '• 本地删除会同步删除远程副本,反之亦然\n' + '• 本地重命名、移动会在远程同步操作,反之亦然\n' + '• 冲突时根据所选策略处理\n\n' + '适用于需要在本地和远程之间保持完全一致的场景。', + ); + if (confirmed != true) return; + } else if (newMode == 'upload_only') { + final confirmed = await _showModeConfirmDialog( + title: '仅上传本地到远程', + description: '此模式下:\n\n' + '• 本地新增、修改的文件将上传到远程\n' + '• 本地重命名、移动的文件会在远程同步操作\n' + '• 本地删除的文件不会删除远程副本\n' + '• 不会下载远程新增或修改的文件\n' + '• 不监听远程事件(不消耗 SSE 连接)\n\n' + '适用于只需要备份本地文件到云端的场景。', + ); + if (confirmed != true) return; + } else if (newMode == 'download_only') { + final confirmed = await _showModeConfirmDialog( + title: '仅下载远程到本地', + description: '此模式下:\n\n' + '• 远程新增、修改的文件将下载到本地\n' + '• 远程删除的文件将同步删除本地副本\n' + '• 远程重命名、移动会在本地同步\n' + '• 本地的所有增删改操作不会影响远程\n' + '• 本地删除的文件如果远程仍存在会重新下载\n' + '• 不监听本地文件变化\n\n' + '适用于只需要在本地保留远程副本的场景。', + ); + if (confirmed != true) return; + } else if (newMode == 'mirror_wcf') { + final confirmed = await _showModeConfirmDialog( + title: '镜像同步', + description: '此模式下(仅 Windows):\n\n' + '• 远程文件以占位符形式出现在本地\n' + '• 占位符不占用磁盘空间,但在资源管理器中可见\n' + '• 打开文件时自动从云端下载(水合)\n' + '• 本地修改会同步回远程\n' + '• 默认仅删除本地,保留远程副本以便重新水合\n' + '• 可手动将文件恢复为占位符(脱水)以释放空间\n\n' + '适用于远程文件较多但本地磁盘空间有限的场景。', + ); + if (confirmed != true) return; + } + + setState(() => _syncMode = newMode); + } + + Future _showModeConfirmDialog({ + required String title, + required String description, + }) { + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(title), + content: Text(description), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认切换'), + ), + ], + ), + ); + } + + Widget _buildSyncStatus(SyncProvider sync) { + return _buildSection( + title: '同步状态', + children: [ + ListTile( + leading: Icon( + _stateIcon(sync), + color: _stateColor(sync), + ), + title: Text(_stateLabel(sync)), + subtitle: sync.hasError && sync.errorMessage != null + ? Text( + sync.errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + ), + if (sync.isActive || sync.isPaused) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LinearProgressIndicator( + value: sync.totalFiles > 0 ? sync.progress : null, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + sync.totalFiles > 0 + ? '${sync.syncedFiles} / ${sync.totalFiles} 文件' + : sync.state == SyncState.continuous + ? '持续同步中' + : '正在同步...', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (sync.currentFile != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + sync.currentFile!, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + if (sync.lastSummary != null) ...[ + const Divider(indent: 16, endIndent: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Wrap( + spacing: 16, + children: [ + _summaryChip('上传', sync.lastSummary!.uploaded), + _summaryChip('下载', sync.lastSummary!.downloaded), + _summaryChip('冲突', sync.lastSummary!.conflicts), + _summaryChip('失败', sync.lastSummary!.failed), + _summaryChip('跳过', sync.lastSummary!.skipped), + _summaryChip('删本地', sync.lastSummary!.deletedLocal), + _summaryChip('删远程', sync.lastSummary!.deletedRemote), + ], + ), + ), + ], + ], + ); + } + + Widget _summaryChip(String label, int value) { + return Chip( + label: Text('$label: $value'), + labelStyle: Theme.of(context).textTheme.bodySmall, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + } + + IconData _stateIcon(SyncProvider sync) { + if (sync.isActive) return Icons.sync; + if (sync.isPaused) return Icons.pause_circle_outline; + if (sync.hasError) return Icons.error_outline; + if (sync.state == SyncState.stopped) return Icons.stop_circle_outlined; + return Icons.cloud_off; + } + + Color _stateColor(SyncProvider sync) { + if (sync.isActive) return Theme.of(context).colorScheme.primary; + if (sync.isPaused) return Colors.orange; + if (sync.hasError) return Theme.of(context).colorScheme.error; + return Theme.of(context).disabledColor; + } + + String _stateLabel(SyncProvider sync) { + return switch (sync.state) { + SyncState.idle => '未启动', + SyncState.initializing => '正在初始化...', + SyncState.initialSync => '初始同步中', + SyncState.continuous => '持续同步中', + SyncState.paused => '已暂停', + SyncState.error => '同步错误', + SyncState.stopped => '已停止', + }; + } + + String _conflictStrategyLabel(String strategy) { + return switch (strategy) { + 'keep_local' => '保留本地版本', + 'keep_remote' => '保留远程版本', + 'keep_both' => '保留两份(重命名本地)', + 'newest_wins' => '最新修改优先', + 'largest_wins' => '最大文件优先', + 'manual' => '手动处理', + _ => strategy, + }; + } + + String _wcfDeleteModeLabel(String mode) { + return switch (mode) { + 'wcf_delete_local_only' => '仅删除本地(可重新水合)', + 'wcf_delete_sync_remote' => '同步删除远程', + _ => mode, + }; + } + + Widget _buildSection({ + required String title, + required List children, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Column(children: children), + ), + ], + ), + ); + } + + Future _pickLocalFolder() async { + final result = await FilePicker.platform.getDirectoryPath( + dialogTitle: '选择本地同步目录', + ); + if (result != null) { + setState(() => _localRootController.text = result); + } + } + + Future _pickRemoteFolder() async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('选择远程同步目录'), + content: SizedBox( + width: 400, + height: 500, + child: FolderPicker( + currentPath: _remoteRoot, + onFolderSelected: (path) => Navigator.pop(ctx, path), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ], + ), + ); + if (result != null && result.isNotEmpty) { + setState(() { + _remoteRoot = result == '/' + ? SyncDefaults.defaultRemoteRoot + : '${SyncDefaults.defaultRemoteRoot}${result.startsWith('/') ? result : '/$result'}'; + }); + } + } + + Future _pickConflictStrategy() async { + final strategies = [ + ('keep_both', '保留两份(重命名本地)'), + ('keep_local', '保留本地版本'), + ('keep_remote', '保留远程版本'), + ('newest_wins', '最新修改优先'), + ('largest_wins', '最大文件优先'), + ('manual', '手动处理'), + ]; + + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('冲突解决策略'), + children: strategies + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e.$1), + child: Row( + children: [ + Icon( + e.$1 == _conflictStrategy + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + ), + const SizedBox(width: 8), + Text(e.$2), + ], + ), + ), + ) + .toList(), + ), + ); + + if (result != null) { + setState(() => _conflictStrategy = result); + _pushConfig(); + } + } + + Future _pickWcfDeleteMode() async { + final modes = [ + ('wcf_delete_local_only', '仅删除本地(可重新水合)'), + ('wcf_delete_sync_remote', '同步删除远程'), + ]; + + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('镜像删除模式'), + children: modes + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e.$1), + child: Row( + children: [ + Icon( + e.$1 == _wcfDeleteMode + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + ), + const SizedBox(width: 8), + Text(e.$2), + ], + ), + ), + ) + .toList(), + ), + ); + + if (result != null) { + setState(() => _wcfDeleteMode = result); + _pushConfig(); + } + } + + Future _pickConcurrency() async { + final values = [1, 2, 3, 5, 8]; + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('最大并发传输数'), + children: values + .map( + (v) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, v), + child: Row( + children: [ + Icon( + v == _maxConcurrent + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + ), + const SizedBox(width: 8), + Text('$v'), + ], + ), + ), + ) + .toList(), + ), + ); + if (result != null) { + setState(() => _maxConcurrent = result); + _pushConfig(); + } + } + + Future _pickMaxWorkers() async { + final controller = TextEditingController(text: _maxWorkers.toString()); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('最大并发任务数'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + hintText: '0 = 自动 (CPU核心数)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + '0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效', + style: Theme.of(ctx).textTheme.bodySmall?.copyWith( + color: Theme.of(ctx).hintColor, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + final val = int.tryParse(controller.text) ?? 0; + Navigator.pop(ctx, val.clamp(0, 999)); + }, + child: const Text('确定'), + ), + ], + ), + ); + if (result != null) { + setState(() => _maxWorkers = result); + _pushConfig(); + } + } + + Future _pickBandwidthLimit() async { + final options = [ + (0, '不限制'), + (1024, '1 MB/s'), + (2048, '2 MB/s'), + (5120, '5 MB/s'), + (10240, '10 MB/s'), + ]; + + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('带宽限制'), + children: options + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e.$1), + child: Row( + children: [ + Icon( + e.$1 == _bandwidthLimitKbps + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + ), + const SizedBox(width: 8), + Text(e.$2), + ], + ), + ), + ) + .toList(), + ), + ); + if (result != null) { + setState(() => _bandwidthLimitKbps = result); + _pushConfig(); + } + } + + void _pushConfig() { + final sync = context.read(); + + final config = sync.persistedConfig; + if (config == null) return; + + final updated = config.copyWith( + syncMode: _syncMode, + conflictStrategy: _conflictStrategy, + wcfDeleteMode: _wcfDeleteMode, + maxConcurrentTransfers: _maxConcurrent, + bandwidthLimitKbps: _bandwidthLimitKbps, + maxWorkers: _maxWorkers, + logLevel: _logLevel, + ); + sync.updateConfig(updated); + } + + Future _startSync(AuthProvider auth, SyncProvider sync) async { + final server = auth.currentServer; + final token = auth.token; + if (server == null || token == null) { + ToastHelper.failure('请先登录'); + return; + } + + final appSupportDir = await getApplicationSupportDirectory(); + + final config = SyncConfigModel( + baseUrl: server.baseUrl, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + localRoot: _localRootController.text, + remoteRoot: _remoteRoot, + syncMode: _syncMode, + conflictStrategy: _conflictStrategy, + wcfDeleteMode: _wcfDeleteMode, + maxConcurrentTransfers: _maxConcurrent, + bandwidthLimitKbps: _bandwidthLimitKbps, + maxWorkers: _maxWorkers, + dataDir: appSupportDir.path, + clientId: '', + logLevel: _logLevel, + ); + + await sync.startSync(config); + } + + Future _stopSync(SyncProvider sync) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('停止同步'), + content: const Text('确定要停止文件同步吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + child: const Text('停止'), + ), + ], + ), + ); + + if (confirmed == true) { + await sync.stop(); + } + } + + Future _resetSync(SyncProvider sync) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('重置同步'), + content: const Text( + '此操作将:\n\n' + '• 停止当前同步任务\n' + '• 清空同步数据库(任务记录、文件映射)\n' + '• 删除本地同步目录中的所有文件(不影响远程)\n\n' + '重置后需重新点击"开始同步"。此操作不可恢复。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + child: const Text('重置'), + ), + ], + ), + ); + + if (confirmed == true) { + await sync.resetSync(); + if (mounted) ToastHelper.success('同步已重置'); + } + } + + // ===== 同步日志 ===== + + Future _getSyncLogPath() async { + final appDir = await getApplicationSupportDirectory(); + return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt'; + } + + Future _loadSyncLogInfo() async { + final path = await _getSyncLogPath(); + final file = File(path); + int? size; + if (await file.exists()) { + size = await file.length(); + } + if (mounted) { + setState(() { + _syncLogFilePath = path; + _syncLogFileSize = size; + }); + } + } + + String _formatBytes(int? bytes) { + if (bytes == null) return '未知'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + String _logLevelLabel(String level) { + return switch (level) { + 'error' => 'Error — 仅错误', + 'warn' => 'Warn — 错误 + 警告', + 'info' => 'Info — 常规信息', + 'debug' => 'Debug — 调试信息', + 'trace' => 'Trace — 全量追踪', + _ => level, + }; + } + + Future _pickLogLevel() async { + final levels = [ + ('error', 'Error — 仅错误'), + ('warn', 'Warn — 错误 + 警告'), + ('info', 'Info — 常规信息'), + ('debug', 'Debug — 调试信息'), + ('trace', 'Trace — 全量追踪'), + ]; + + final result = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('日志级别'), + children: levels + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e.$1), + child: Row( + children: [ + Icon( + e.$1 == _logLevel + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + ), + const SizedBox(width: 8), + Text(e.$2), + ], + ), + ), + ) + .toList(), + ), + ); + + if (result != null && result != _logLevel) { + setState(() => _logLevel = result); + if (!mounted) return; + // 热修改:立即通知 Rust 引擎 + final sync = context.read(); + await sync.setLogLevel(result); + + if (mounted) { + ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}'); + } + } + } + + Future _openSyncLogFolder() async { + try { + if (_syncLogFilePath.isEmpty) { + ToastHelper.error('日志文件路径未获取'); + return; + } + final dir = File(_syncLogFilePath).parent.path; + final result = await OpenFile.open(dir); + if (result.type != ResultType.done) { + if (mounted) ToastHelper.error('无法打开目录:${result.message}'); + } + } catch (e) { + if (mounted) ToastHelper.error('打开目录失败:$e'); + } + } + + Future _exportSyncLog() async { + try { + final srcPath = await _getSyncLogPath(); + final srcFile = File(srcPath); + if (!await srcFile.exists()) { + if (mounted) ToastHelper.error('日志文件不存在'); + return; + } + final downloadDir = await getDownloadsDirectory(); + if (downloadDir == null) { + if (mounted) ToastHelper.error('无法获取下载目录'); + return; + } + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19); + final destPath = '${downloadDir.path}${Platform.pathSeparator}sync_core_log_$timestamp.txt'; + await srcFile.copy(destPath); + if (mounted) ToastHelper.success('日志已导出到:$destPath'); + } catch (e) { + if (mounted) ToastHelper.error('导出日志失败:$e'); + } + } + + Future _previewSyncLog() async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SyncLogViewerPage(), + ), + ); + } + + Future _clearSyncLog() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('清空同步日志'), + content: const Text('确定要清空同步引擎日志文件内容吗?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), + child: const Text('清空'), + ), + ], + ), + ); + + if (confirmed == true) { + final path = await _getSyncLogPath(); + final file = File(path); + if (await file.exists()) { + await file.writeAsString(''); + } + await _loadSyncLogInfo(); + if (mounted) ToastHelper.success('同步日志已清空'); + } + } +} diff --git a/lib/presentation/providers/sync_provider.dart b/lib/presentation/providers/sync_provider.dart new file mode 100644 index 0000000..d602410 --- /dev/null +++ b/lib/presentation/providers/sync_provider.dart @@ -0,0 +1,611 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +import '../../../core/utils/app_logger.dart'; +import '../../../data/models/sync_config_model.dart'; +import '../../../data/models/sync_event_model.dart'; +import '../../../data/models/sync_task_model.dart'; +import '../../../services/storage_service.dart'; +import '../../../services/sync_service.dart'; + +enum SyncState { + idle, + initializing, + initialSync, + continuous, + paused, + error, + stopped, +} + +class SyncProvider extends ChangeNotifier { + + SyncState _state = SyncState.idle; + String? _errorMessage; + SyncSummaryModel? _lastSummary; + int _syncedFiles = 0; + int _totalFiles = 0; + int _conflictCount = 0; + int _uploadingCount = 0; + int _downloadingCount = 0; + String? _currentFile; + StreamSubscription? _eventSub; + Timer? _pollTimer; + int _pollIntervalSeconds = 1; // 动态调节:活跃1s,空闲3s + + // 任务列表 + List _activeTasks = []; + List _recentTasks = []; + int _activeWorkerCount = 0; + // 任务详情缓存: taskId -> items + final Map> _taskDetailCache = {}; + // 任务详情是否有更多: taskId -> hasMore + final Map _taskDetailHasMore = {}; + // 需要实时刷新详情的任务ID(UI 展开中的任务) + final Set _watchedTaskIds = {}; + bool _recentTasksLoaded = false; + + // 持久化的同步配置 + SyncConfigModel? _persistedConfig; + + SyncState get state => _state; + String? get errorMessage => _errorMessage; + SyncSummaryModel? get lastSummary => _lastSummary; + int get syncedFiles => _syncedFiles; + int get totalFiles => _totalFiles; + int get conflictCount => _conflictCount; + int get uploadingCount => _uploadingCount; + int get downloadingCount => _downloadingCount; + String? get currentFile => _currentFile; + SyncConfigModel? get persistedConfig => _persistedConfig; + List get activeTasks => _activeTasks; + List get recentTasks => _recentTasks; + int get activeWorkerCount => _activeWorkerCount; + + bool get isActive => + _state == SyncState.initializing || + _state == SyncState.initialSync || + _state == SyncState.continuous; + + bool get isPaused => _state == SyncState.paused; + + bool get hasError => _state == SyncState.error; + + /// Rust 同步引擎是否已初始化(点过"开始同步"后为 true) + bool _engineInitialized = false; + bool get engineInitialized => _engineInitialized; + + double get progress => + _totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0; + + /// 从持久化存储恢复同步配置和状态 + Future restoreFromStorage() async { + final configMap = await StorageService.instance.getSyncConfig(); + if (configMap != null) { + try { + _persistedConfig = SyncConfigModel( + baseUrl: configMap['baseUrl'] as String? ?? '', + accessToken: configMap['accessToken'] as String? ?? '', + refreshToken: configMap['refreshToken'] as String? ?? '', + localRoot: configMap['localRoot'] as String? ?? '', + remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my', + syncMode: configMap['syncMode'] as String? ?? 'full', + conflictStrategy: configMap['conflictStrategy'] as String? ?? 'keep_both', + wcfDeleteMode: configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only', + maxConcurrentTransfers: configMap['maxConcurrentTransfers'] as int? ?? 3, + bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0, + maxWorkers: configMap['maxWorkers'] as int? ?? 0, + dataDir: configMap['dataDir'] as String? ?? '', + clientId: configMap['clientId'] as String? ?? '', + logLevel: configMap['logLevel'] as String? ?? 'info', + ); + AppLogger.i('恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}'); + } catch (e) { + AppLogger.e('恢复同步配置失败: $e'); + } + } + + final savedState = await StorageService.instance.getSyncState(); + if (savedState != null && savedState != 'idle' && savedState != 'stopped') { + AppLogger.i('恢复同步状态: $savedState'); + } + } + + /// 保存同步配置到持久化存储 + Future _persistConfig(SyncConfigModel config) async { + _persistedConfig = config; + await StorageService.instance.setSyncConfig({ + 'baseUrl': config.baseUrl, + 'accessToken': config.accessToken, + 'refreshToken': config.refreshToken, + 'localRoot': config.localRoot, + 'remoteRoot': config.remoteRoot, + 'syncMode': config.syncMode, + 'conflictStrategy': config.conflictStrategy, + 'wcfDeleteMode': config.wcfDeleteMode, + 'maxConcurrentTransfers': config.maxConcurrentTransfers, + 'bandwidthLimitKbps': config.bandwidthLimitKbps, + 'maxWorkers': config.maxWorkers, + 'dataDir': config.dataDir, + 'clientId': config.clientId, + 'logLevel': config.logLevel, + }); + } + + /// 持久化同步状态 + Future _persistState(SyncState state) async { + final stateStr = switch (state) { + SyncState.idle => 'idle', + SyncState.initializing => 'initializing', + SyncState.initialSync => 'initialSync', + SyncState.continuous => 'continuous', + SyncState.paused => 'paused', + SyncState.error => 'error', + SyncState.stopped => 'stopped', + }; + await StorageService.instance.setSyncState(stateStr); + } + + /// 初始化并启动同步 + Future startSync(SyncConfigModel config) async { + _state = SyncState.initializing; + _errorMessage = null; + _syncedFiles = 0; + _totalFiles = 0; + _currentFile = null; + notifyListeners(); + + // 确保 clientId 已注入(Dart 生成、持久化、全层共享) + final clientId = await StorageService.instance.getOrCreateClientId(); + final configWithClientId = config.copyWith(clientId: clientId); + + // 持久化配置和状态 + await _persistConfig(configWithClientId); + await _persistState(SyncState.initializing); + + try { + await SyncService.instance.init(configWithClientId); + _engineInitialized = true; + _subscribeEvents(); + + _state = SyncState.initialSync; + await _persistState(SyncState.initialSync); + notifyListeners(); + + // 启动状态轮询 + _startPolling(); + + // 启动初始同步(后台运行) + SyncService.instance.startInitialSync().then((summary) async { + _lastSummary = summary; + _state = SyncState.continuous; + await _persistState(SyncState.continuous); + AppLogger.i('初始同步完成'); + notifyListeners(); + + // 自动启动持续同步 + SyncService.instance.startContinuousSync(); + }).catchError((e) async { + _state = SyncState.error; + _errorMessage = e.toString(); + await _persistState(SyncState.error); + AppLogger.e('初始同步失败: $e'); + notifyListeners(); + }); + } catch (e) { + _state = SyncState.error; + _errorMessage = e.toString(); + await _persistState(SyncState.error); + AppLogger.e('同步启动失败: $e'); + notifyListeners(); + } + } + + /// 自动恢复同步(启动时调用,如果之前处于同步状态则恢复) + Future autoResumeIfNeeded({ + String? currentAccessToken, + String? currentRefreshToken, + }) async { + if (_persistedConfig == null) { + await restoreFromStorage(); + } + if (_persistedConfig == null) return; + + final savedState = await StorageService.instance.getSyncState(); + if (savedState == null || savedState == 'idle' || savedState == 'stopped' || savedState == 'error') { + return; + } + + AppLogger.i('自动恢复同步,上次状态: $savedState'); + + var config = _persistedConfig!; + if (currentAccessToken != null && currentRefreshToken != null) { + config = config.copyWith( + accessToken: currentAccessToken, + refreshToken: currentRefreshToken, + ); + } + + await startSync(config); + } + + /// 更新配置(持久化 + 推送到 Rust 引擎) + Future updateConfig(SyncConfigModel config) async { + await _persistConfig(config); + + // 引擎已初始化时(无论是否运行中),推送配置到 Rust + try { + await SyncService.instance.updateConfig(config); + AppLogger.i('同步配置已更新到引擎: 模式=${config.syncMode}'); + } catch (e) { + AppLogger.e('更新配置到引擎失败: $e'); + } + } + + /// 热修改日志级别(立即生效,无需重启引擎) + Future setLogLevel(String level) async { + // 持久化 + if (_persistedConfig != null) { + final updated = _persistedConfig!.copyWith(logLevel: level); + await _persistConfig(updated); + } + + // 引擎已初始化时立即通知 Rust(不限制仅活跃状态) + try { + await SyncService.instance.setLogLevel(level); + AppLogger.i('日志级别已切换为: $level'); + } catch (e) { + AppLogger.e('切换日志级别失败: $e'); + } + } + + /// 轮询 Rust 引擎状态(动态间隔:活跃1s,空闲3s) + void _startPolling() { + _pollTimer?.cancel(); + _pollIntervalSeconds = isActive || isPaused ? 1 : 3; + _pollTimer = Timer.periodic( + Duration(seconds: _pollIntervalSeconds), + (_) => _pollStatus(), + ); + } + + void _adjustPollInterval() { + final target = isActive || isPaused ? 1 : 3; + if (target != _pollIntervalSeconds) { + _pollIntervalSeconds = target; + _startPolling(); + } + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + int _pollErrorCount = 0; + + Future _pollStatus() async { + try { + final status = await SyncService.instance.getStatus(); + _syncedFiles = status.syncedFiles; + _totalFiles = status.totalFiles; + _uploadingCount = status.uploadingCount; + _downloadingCount = status.downloadingCount; + _conflictCount = status.conflictCount; + _pollErrorCount = 0; + + final rustState = _parseState(status.state); + if (rustState != _state && rustState != SyncState.idle) { + _state = rustState; + await _persistState(rustState); + } + if (status.errorMessage != null && status.errorMessage!.isNotEmpty) { + _errorMessage = status.errorMessage; + } + + // 轮询活跃任务 + 刷新已完成任务 + try { + final newActiveWorkerCount = await SyncService.instance.getActiveWorkerCount(); + final newActiveTasks = await SyncService.instance.getActiveTasksTyped(); + + bool changed = newActiveWorkerCount != _activeWorkerCount; + _activeWorkerCount = newActiveWorkerCount; + + // 活跃任务列表有变化时才更新 + if (!_taskListEqual(newActiveTasks, _activeTasks)) { + _activeTasks = newActiveTasks; + changed = true; + // 清除不再活跃的任务的详情缓存 + _taskDetailCache.removeWhere( + (key, _) => !newActiveTasks.any((t) => t.id == key), + ); + } + + // 每次 polling 都刷新已完成任务 + await _refreshRecentTasks(); + + // 刷新展开中的任务详情(实时更新 item 状态) + await _refreshWatchedTaskDetails(); + + if (changed) { + notifyListeners(); + } else { + notifyListeners(); + } + } catch (_) {} + + notifyListeners(); + _adjustPollInterval(); + } catch (_) { + // 引擎未初始化等连续错误,停止轮询避免刷屏 + _pollErrorCount++; + if (_pollErrorCount >= 3) { + _stopPolling(); + } + } + } + + /// 首次加载已完成任务(如果轮询未运行则启动慢速轮询) + Future loadRecentTasks() async { + if (_recentTasksLoaded) return; + await _refreshRecentTasks(); + if (_pollTimer == null) { + _startPolling(); + } + } + + Future _refreshRecentTasks() async { + try { + final tasks = await SyncService.instance.getRecentTasksTyped(limit: 50); + if (!_taskListEqual(tasks, _recentTasks)) { + _recentTasks = tasks; + } + _recentTasksLoaded = true; + } catch (_) {} + } + + bool _taskListEqual(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i].id != b[i].id || + a[i].completedCount != b[i].completedCount || + a[i].failedCount != b[i].failedCount || + a[i].status != b[i].status) { + return false; + } + } + return true; + } + + /// 刷新展开中的任务详情(轮询时调用,只刷新 watched 的任务) + Future _refreshWatchedTaskDetails() async { + if (_watchedTaskIds.isEmpty) return; + for (final taskId in _watchedTaskIds.toList()) { + try { + // 重新加载前 20 条(和当前缓存量一致) + final cachedCount = _taskDetailCache[taskId]?.length ?? 20; + final newItems = await SyncService.instance.queryTaskItemsTyped( + taskId: taskId, + limit: cachedCount.clamp(20, 100), + offset: 0, + ); + final oldItems = _taskDetailCache[taskId]; + if (oldItems != null && !_itemListEqual(oldItems, newItems)) { + _taskDetailCache[taskId] = newItems; + notifyListeners(); + } else if (oldItems == null) { + _taskDetailCache[taskId] = newItems; + notifyListeners(); + } + } catch (_) {} + } + } + + bool _itemListEqual(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i].id != b[i].id || a[i].status != b[i].status) { + return false; + } + } + return true; + } + + void _subscribeEvents() { + _eventSub = SyncService.instance.events.listen((event) async { + switch (event) { + case SyncStateChanged(:final newState): + _state = _parseState(newState); + await _persistState(_state); + case SyncProgress(:final synced, :final total, :final currentFile): + _syncedFiles = synced; + _totalFiles = total; + _currentFile = currentFile; + case SyncFileUploaded(): + _syncedFiles++; + case SyncFileDownloaded(): + _syncedFiles++; + case SyncConflictDetected(): + _conflictCount++; + case SyncError(:final message, :final recoverable): + if (!recoverable) { + _state = SyncState.error; + _errorMessage = message; + await _persistState(SyncState.error); + } + case SyncTokenExpired(): + _handleTokenExpired(); + case SyncInitialSyncComplete(:final summary): + _lastSummary = summary; + _state = SyncState.continuous; + await _persistState(SyncState.continuous); + SyncService.instance.startContinuousSync(); + case SyncDiskSpaceWarning(): + AppLogger.w('磁盘空间不足'); + } + notifyListeners(); + }); + } + + SyncState _parseState(String state) { + return switch (state) { + 'idle' => SyncState.idle, + 'initializing' => SyncState.initializing, + 'initialSync' => SyncState.initialSync, + 'continuous' => SyncState.continuous, + 'paused' => SyncState.paused, + 'error' => SyncState.error, + 'stopped' => SyncState.stopped, + _ => SyncState.idle, + }; + } + + void _handleTokenExpired() { + AppLogger.w('Token 过期,需要刷新'); + } + + /// 获取任务详情(分页加载,带缓存) + Future> getTaskDetail(String taskId) async { + if (_taskDetailCache.containsKey(taskId)) { + return _taskDetailCache[taskId]!; + } + final items = await SyncService.instance.queryTaskItemsTyped( + taskId: taskId, + limit: 20, + offset: 0, + ); + _taskDetailCache[taskId] = items; + _taskDetailHasMore[taskId] = items.length >= 20; + notifyListeners(); + return items; + } + + /// 加载更多任务详情 + Future> loadMoreTaskDetail(String taskId) async { + final current = _taskDetailCache[taskId] ?? []; + final offset = current.length; + final newItems = await SyncService.instance.queryTaskItemsTyped( + taskId: taskId, + limit: 20, + offset: offset, + ); + final merged = [...current, ...newItems]; + _taskDetailCache[taskId] = merged; + _taskDetailHasMore[taskId] = newItems.length >= 20; + notifyListeners(); + return merged; + } + + /// 指定任务是否还有更多详情可加载 + bool hasMoreTaskDetail(String taskId) { + return _taskDetailHasMore[taskId] ?? true; + } + + /// 标记任务详情需要实时刷新(UI 展开时调用) + void watchTaskDetail(String taskId) { + _watchedTaskIds.add(taskId); + } + + /// 取消实时刷新(UI 收起时调用) + void unwatchTaskDetail(String taskId) { + _watchedTaskIds.remove(taskId); + } + + /// 读取缓存的任务详情(同步,无则返回 null) + List? getCachedTaskDetail(String taskId) { + return _taskDetailCache[taskId]; + } + + /// 清除任务详情缓存,下次获取时重新请求 + void invalidateTaskDetail(String taskId) { + _taskDetailCache.remove(taskId); + _taskDetailHasMore.remove(taskId); + } + + /// 清除所有任务详情缓存 + void invalidateAllTaskDetails() { + _taskDetailCache.clear(); + _taskDetailHasMore.clear(); + _recentTasksLoaded = false; + } + + /// 暂停同步 + Future pause() async { + await SyncService.instance.pause(); + _state = SyncState.paused; + await _persistState(SyncState.paused); + notifyListeners(); + } + + /// 恢复同步 + Future resume() async { + await SyncService.instance.resume(); + _state = SyncState.continuous; + await _persistState(SyncState.continuous); + notifyListeners(); + } + + /// 停止同步 + Future stop() async { + await SyncService.instance.stop(); + _state = SyncState.stopped; + await _persistState(SyncState.stopped); + notifyListeners(); + _adjustPollInterval(); + } + + /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 + Future resetSync() async { + // 引擎未初始化时仅清空本地状态 + try { + await SyncService.instance.resetSync(); + } catch (_) {} + _state = SyncState.idle; + _errorMessage = null; + _syncedFiles = 0; + _totalFiles = 0; + _conflictCount = 0; + _uploadingCount = 0; + _downloadingCount = 0; + _currentFile = null; + _lastSummary = null; + _activeTasks = []; + _recentTasks = []; + _activeWorkerCount = 0; + _taskDetailCache.clear(); + _recentTasksLoaded = false; + _engineInitialized = false; + await _persistState(SyncState.idle); + notifyListeners(); + } + + /// 强制重新同步 + Future forceSync() async { + _state = SyncState.initialSync; + _syncedFiles = 0; + _totalFiles = 0; + await _persistState(SyncState.initialSync); + notifyListeners(); + + try { + final summary = await SyncService.instance.forceSync(); + _lastSummary = summary; + _state = SyncState.continuous; + await _persistState(SyncState.continuous); + // 重新启动持续同步 + SyncService.instance.startContinuousSync(); + } catch (e) { + _state = SyncState.error; + _errorMessage = e.toString(); + await _persistState(SyncState.error); + } + notifyListeners(); + } + + @override + void dispose() { + _stopPolling(); + _eventSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/presentation/widgets/file_breadcrumb.dart b/lib/presentation/widgets/file_breadcrumb.dart index ffae5da..67edc18 100644 --- a/lib/presentation/widgets/file_breadcrumb.dart +++ b/lib/presentation/widgets/file_breadcrumb.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; + import '../../core/utils/file_utils.dart'; /// 面包屑导航组件 @@ -15,7 +16,7 @@ class FileBreadcrumb extends StatelessWidget { @override Widget build(BuildContext context) { - final pathParts = FileUtils.toRelativePath(currentPath).split('/'); + final pathParts = currentPath.split('/'); pathParts.removeWhere((part) => part.isEmpty); final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -55,7 +56,7 @@ class FileBreadcrumb extends StatelessWidget { ), _buildBreadcrumbItem( context, - name: FileUtils.decodePathSegment(pathParts[i]), + name: _decodePathSegment(pathParts[i]), path: '/${pathParts.sublist(0, i + 1).join('/')}', icon: null, primaryColor: colorScheme.primary, @@ -69,6 +70,10 @@ class FileBreadcrumb extends StatelessWidget { ); } + String _decodePathSegment(String value) { + return FileUtils.safeDecodePathSegment(value); + } + Widget _buildBreadcrumbItem( BuildContext context, { required String name, diff --git a/lib/presentation/widgets/file_info_dialog.dart b/lib/presentation/widgets/file_info_dialog.dart index fab1c46..0e18768 100644 --- a/lib/presentation/widgets/file_info_dialog.dart +++ b/lib/presentation/widgets/file_info_dialog.dart @@ -210,7 +210,7 @@ class _FileInfoPanelState extends State { _buildInfoRow( LucideIcons.folderOpen, '位置', - FileUtils.decodePathForDisplay(file.relativePath), + FileUtils.safeDecodePathSegment(file.relativePath), ), if (file.isFile) _buildInfoRow( diff --git a/lib/presentation/widgets/folder_picker.dart b/lib/presentation/widgets/folder_picker.dart index 284c4f8..38c53ad 100644 --- a/lib/presentation/widgets/folder_picker.dart +++ b/lib/presentation/widgets/folder_picker.dart @@ -2,7 +2,6 @@ import 'package:cloudreve4_flutter/data/models/file_model.dart'; import 'package:cloudreve4_flutter/services/file_service.dart'; import 'package:flutter/material.dart'; import '../../core/utils/app_logger.dart'; -import '../../core/utils/file_utils.dart'; /// 文件夹选择器 class FolderPicker extends StatefulWidget { @@ -62,7 +61,7 @@ class _FolderPickerState extends State { void _enterFolder(FileModel folder) { setState(() { - _currentPath = folder.relativePath; + _currentPath = folder.path; }); _loadFolders(); } @@ -81,10 +80,7 @@ class _FolderPickerState extends State { child: ConstrainedBox( constraints: BoxConstraints( maxHeight: widget.maxVisibleItems != null - ? (widget.maxVisibleItems! * 56.0 + 40.0).clamp( - 80.0, - maxHeight, - ) + ? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight) : maxHeight, ), child: _buildListContent(context, primaryColor), @@ -112,10 +108,7 @@ class _FolderPickerState extends State { children: [ Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), - Text( - '此文件夹为空', - style: TextStyle(color: Colors.grey.shade600, fontSize: 14), - ), + Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)), ], ), ); @@ -153,13 +146,7 @@ class _FolderPickerState extends State { ), const SizedBox(width: 12), Expanded( - child: Text( - folder.name, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), + child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)), ), Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20), ], @@ -169,7 +156,7 @@ class _FolderPickerState extends State { } Widget _buildBreadcrumb(BuildContext context, Color primaryColor) { - final pathParts = FileUtils.toRelativePath(_currentPath).split('/'); + final pathParts = _currentPath.split('/'); pathParts.removeWhere((part) => part.isEmpty); return Column( @@ -193,8 +180,8 @@ class _FolderPickerState extends State { ), ...pathParts.asMap().entries.expand((entry) { final index = entry.key; - final path = - '/${pathParts.sublist(0, index + 1).join('/')}'; + final part = entry.value; + final path = '/${pathParts.sublist(0, index + 1).join('/')}'; return [ Padding( @@ -207,7 +194,7 @@ class _FolderPickerState extends State { ), _buildBreadcrumbItem( context, - name: FileUtils.decodePathSegment(entry.value), + name: part, path: path, isLast: index == pathParts.length - 1, primaryColor: primaryColor, @@ -221,14 +208,13 @@ class _FolderPickerState extends State { const SizedBox(width: 12), FilledButton.tonal( onPressed: () { - final relative = FileUtils.toRelativePath(_currentPath); + final relative = _currentPath.startsWith('cloudreve://my') + ? _currentPath.replaceFirst('cloudreve://my', '') + : _currentPath; widget.onFolderSelected(relative.isEmpty ? '/' : relative); }, style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), child: const Text('选择'), ), diff --git a/lib/presentation/widgets/search_dialog.dart b/lib/presentation/widgets/search_dialog.dart index 9ee3bb9..1e0ed08 100644 --- a/lib/presentation/widgets/search_dialog.dart +++ b/lib/presentation/widgets/search_dialog.dart @@ -436,7 +436,7 @@ class _SearchDialogState extends State { ), const SizedBox(height: 2), Text( - FileUtils.decodePathForDisplay(file.relativePath), + FileUtils.safeDecodePathSegment(file.relativePath), style: TextStyle(fontSize: 12, color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/presentation/widgets/update_prompt.dart b/lib/presentation/widgets/update_prompt.dart new file mode 100644 index 0000000..cad14ac --- /dev/null +++ b/lib/presentation/widgets/update_prompt.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../data/models/app_update_model.dart'; +import '../../services/update_service.dart'; +import 'toast_helper.dart'; + +class UpdatePrompt extends StatefulWidget { + final Widget child; + + const UpdatePrompt({super.key, required this.child}); + + @override + State createState() => _UpdatePromptState(); +} + +class _UpdatePromptState extends State { + static bool _checkedThisRun = false; + bool _dialogVisible = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdate()); + } + + Future _checkUpdate() async { + if (_checkedThisRun || _dialogVisible || !mounted) return; + _checkedThisRun = true; + + final update = await UpdateService.instance.checkForUpdate(); + if (update == null || !mounted) return; + + _dialogVisible = true; + try { + await _showUpdateDialog(update); + } finally { + _dialogVisible = false; + } + } + + Future _showUpdateDialog(AppUpdateInfo update) async { + var downloading = false; + var progress = 0.0; + String? status; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + final canDownload = + update.downloadUrl != null && + (Platform.isAndroid || Platform.isWindows); + + Future startUpdate() async { + if (downloading || !canDownload) return; + + setDialogState(() { + downloading = true; + progress = 0; + status = '正在下载更新...'; + }); + + try { + final file = await UpdateService.instance.downloadUpdate( + update, + onProgress: (received, total) { + if (!mounted || total <= 0) return; + setDialogState(() { + progress = received / total; + }); + }, + ); + if (file == null) return; + + setDialogState(() { + progress = 1; + status = '下载完成,正在打开安装包...'; + }); + await UpdateService.instance.openInstaller(file); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + } catch (e) { + setDialogState(() { + downloading = false; + status = '下载或打开安装包失败'; + }); + ToastHelper.failure('更新失败: $e'); + } + } + + return AlertDialog( + title: Text('发现新版本 ${update.version}'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(update.title), + if ((update.description ?? '').isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + update.description!, + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ], + if (downloading) ...[ + const SizedBox(height: 16), + LinearProgressIndicator( + value: progress == 0 ? null : progress, + ), + const SizedBox(height: 8), + Text(status ?? ''), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: downloading + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('稍后'), + ), + FilledButton( + onPressed: downloading ? null : startUpdate, + child: const Text('立即更新'), + ), + ], + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index f14f652..60fac1a 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart'; import '../presentation/pages/webdav/webdav_page.dart'; import '../presentation/pages/remote_download/remote_download_page.dart'; import '../presentation/pages/settings/settings_page.dart'; +import '../presentation/pages/sync/sync_settings_page.dart'; import '../presentation/pages/preview/image_preview_page.dart'; import '../presentation/pages/preview/pdf_preview_page.dart'; import '../presentation/pages/preview/video_preview_page.dart'; @@ -35,6 +36,7 @@ class RouteNames { static const String documentPreview = '/document-preview'; static const String markdownPreview = '/markdown-preview'; static const String categoryFiles = '/category-files'; + static const String syncSettings = '/sync-settings'; } /// 应用路由 @@ -218,6 +220,12 @@ class AppRouter { ), ); + case RouteNames.syncSettings: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SyncSettingsPage(), + ); + default: return MaterialPageRoute( settings: settings, diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 28836eb..4e41280 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -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); }, ); diff --git a/lib/services/desktop_service.dart b/lib/services/desktop_service.dart index cb601b1..82988cb 100644 --- a/lib/services/desktop_service.dart +++ b/lib/services/desktop_service.dart @@ -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); diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index acf6adc..c445979 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -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 = { 'uri': cloudreveUri, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index e9c2e74..f7e79f9 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 clearSearchHistory() async { await remove(StorageKeys.searchHistory); } + + // ===== 同步配置持久化 ===== + + /// 保存同步配置 + Future setSyncConfig(Map config) async { + try { + final json = jsonEncode(config); + return await setString(StorageKeys.syncConfig, json); + } catch (_) { + return false; + } + } + + /// 读取同步配置 + Future?> getSyncConfig() async { + final json = await getString(StorageKeys.syncConfig); + if (json == null || json.isEmpty) return null; + try { + return jsonDecode(json) as Map; + } catch (_) { + return null; + } + } + + /// 保存同步状态 + Future setSyncState(String state) async { + return await setString(StorageKeys.syncState, state); + } + + /// 读取同步状态 + Future getSyncState() async { + return await getString(StorageKeys.syncState); + } + + /// 清除同步配置和状态 + Future clearSyncData() async { + await remove(StorageKeys.syncConfig); + await remove(StorageKeys.syncState); + } + + // ===== client_id 持久化 ===== + + /// 获取 client_id,首次调用自动生成并持久化 + Future getOrCreateClientId() async { + var id = await getString(StorageKeys.clientId); + if (id == null || id.isEmpty) { + id = const Uuid().v4(); + await setString(StorageKeys.clientId, id); + } + return id; + } } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..d646adb --- /dev/null +++ b/lib/services/sync_service.dart @@ -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.broadcast(); + Stream get events => _eventController.stream; + + /// 初始化同步引擎(已初始化时更新配置) + Future 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 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 startContinuousSync() async { + AppLogger.d('[FFI] → startContinuousSync'); + await ffi.startContinuousSync(); + AppLogger.d('[FFI] ← startContinuousSync: spawned'); + } + + /// 停止同步 + Future stop() async { + AppLogger.d('[FFI] → stopSync'); + await ffi.stopSync(); + AppLogger.d('[FFI] ← stopSync: ok'); + } + + /// 暂停同步 + Future pause() async { + AppLogger.d('[FFI] → pauseSync'); + await ffi.pauseSync(); + AppLogger.d('[FFI] ← pauseSync: ok'); + } + + /// 恢复同步 + Future resume() async { + AppLogger.d('[FFI] → resumeSync'); + await ffi.resumeSync(); + AppLogger.d('[FFI] ← resumeSync: ok'); + } + + /// 强制重新同步 + Future 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 updateTokens(String accessToken) async { + AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}'); + await ffi.updateTokens(accessToken: accessToken); + AppLogger.d('[FFI] ← updateTokens: ok'); + } + + /// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略) + Future 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 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 getActiveWorkerCount() async { + final count = await ffi.getActiveWorkerCount(); + AppLogger.t('[FFI] ← getActiveWorkerCount: $count'); + return count; + } + + /// 获取活跃的同步任务列表(轮询高频调用,trace 级别) + Future> 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> 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> 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> 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 hydrateFile(String localPath) async { + AppLogger.d('[FFI] → hydrateFile: path=$localPath'); + await ffi.hydrateFile(localPath: localPath); + AppLogger.d('[FFI] ← hydrateFile: ok'); + } + + /// 检查云端相册目录 (Android) + Future> 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 createCloudAlbumDirs(String baseUri) async { + AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri'); + await ffi.createCloudAlbumDirs(baseUri: baseUri); + AppLogger.d('[FFI] ← createCloudAlbumDirs: ok'); + } + + /// 销毁同步引擎 + Future dispose() async { + AppLogger.d('[FFI] → disposeSyncEngine'); + await _eventController.close(); + await ffi.disposeSyncEngine(); + _initialized = false; + AppLogger.d('[FFI] ← disposeSyncEngine: ok'); + } + + /// 热修改日志级别(立即生效,无需重启) + Future setLogLevel(String level) async { + AppLogger.d('[FFI] → setSyncLogLevel: level=$level'); + await ffi.setSyncLogLevel(level: level); + AppLogger.d('[FFI] ← setSyncLogLevel: ok'); + } + + /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 + Future resetSync() async { + AppLogger.d('[FFI] → resetSync'); + await ffi.resetSync(); + AppLogger.d('[FFI] ← resetSync: ok'); + } +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..ab0d03b --- /dev/null +++ b/lib/services/update_service.dart @@ -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 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 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 openInstaller(File file) async { + final result = await OpenFile.open(file.path); + if (result.type != ResultType.done) { + throw Exception(result.message); + } + } + + Future openReleasePage(AppUpdateInfo update) async { + final url = update.pageUrl; + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('无法打开更新页面: $url'); + } + } + + Future _fetchLatestRelease({String? currentVersion}) async { + final response = await _dio.getUri(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() + .map(_absoluteUriOrNull) + .whereType() + .toList(); + + final candidates = [ + ...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 _buildUpdateInfo(_RssRelease release) async { + final candidates = [...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 _downloadUrlFromReleasePage(Uri pageUrl) async { + try { + final response = await _dio.getUri(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 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 _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() + .map(_absoluteUriOrNull) + .whereType() + .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()) { + 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 _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 debugFetchLatestRelease() => _fetchLatestRelease(); +} + +class _RssRelease { + final String version; + final String title; + final String? description; + final Uri pageUrl; + final List candidates; + final DateTime? publishedAt; + + const _RssRelease({ + required this.version, + required this.title, + required this.pageUrl, + required this.candidates, + this.description, + this.publishedAt, + }); +} diff --git a/lib/src/rust/api/ffi.dart b/lib/src/rust/api/ffi.dart new file mode 100644 index 0000000..31202e9 --- /dev/null +++ b/lib/src/rust/api/ffi.dart @@ -0,0 +1,114 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'ffi_types.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `album_result_to_ffi`, `apply_log_level`, `config_from_ffi`, `config_to_ffi`, `error_to_ffi`, `get_engine`, `status_to_ffi`, `summary_to_ffi`, `task_item_to_ffi`, `task_to_ffi` + +/// 初始化同步引擎 +Future initSyncEngine({required SyncConfigFfi config}) => + RustSyncApi.instance.api.crateApiFfiInitSyncEngine(config: config); + +/// 销毁同步引擎 +Future disposeSyncEngine() => + RustSyncApi.instance.api.crateApiFfiDisposeSyncEngine(); + +/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放) +/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用 +Future syncShutdown() => + RustSyncApi.instance.api.crateApiFfiSyncShutdown(); + +/// 执行初始全量同步 +Future startInitialSync() => + RustSyncApi.instance.api.crateApiFfiStartInitialSync(); + +/// 启动持续同步(后台运行,立即返回) +Future startContinuousSync() => + RustSyncApi.instance.api.crateApiFfiStartContinuousSync(); + +/// 停止同步 +Future stopSync() => RustSyncApi.instance.api.crateApiFfiStopSync(); + +/// 暂停同步 +Future pauseSync() => RustSyncApi.instance.api.crateApiFfiPauseSync(); + +/// 恢复同步 +Future resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync(); + +/// 强制同步(重新扫描全量差异) +Future forceSync() => + RustSyncApi.instance.api.crateApiFfiForceSync(); + +/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 +Future resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync(); + +/// 获取同步状态快照 +Future getSyncStatus() => + RustSyncApi.instance.api.crateApiFfiGetSyncStatus(); + +/// 获取活跃 Worker 数量 +Future getActiveWorkerCount() => + RustSyncApi.instance.api.crateApiFfiGetActiveWorkerCount(); + +/// 获取同步配置 +Future getSyncConfig() => + RustSyncApi.instance.api.crateApiFfiGetSyncConfig(); + +/// 更新同步配置 +Future updateSyncConfig({required SyncConfigFfi config}) => + RustSyncApi.instance.api.crateApiFfiUpdateSyncConfig(config: config); + +/// Dart 推送新 Token 给 Rust +Future updateTokens({required String accessToken}) => + RustSyncApi.instance.api.crateApiFfiUpdateTokens(accessToken: accessToken); + +/// 水合文件(Windows 按需下载) +Future hydrateFile({required String localPath}) => + RustSyncApi.instance.api.crateApiFfiHydrateFile(localPath: localPath); + +/// 同步相册到云端 +Future syncAlbumToCloud({ + required List albumPaths, + required String remoteDcimUri, +}) => RustSyncApi.instance.api.crateApiFfiSyncAlbumToCloud( + albumPaths: albumPaths, + remoteDcimUri: remoteDcimUri, +); + +/// 检查云端是否存在 DCIM/Pictures 目录 +Future checkCloudAlbumDirs({ + required String baseUri, +}) => RustSyncApi.instance.api.crateApiFfiCheckCloudAlbumDirs(baseUri: baseUri); + +/// 在云端创建 DCIM/Pictures 目录 +Future createCloudAlbumDirs({required String baseUri}) => + RustSyncApi.instance.api.crateApiFfiCreateCloudAlbumDirs(baseUri: baseUri); + +/// 注册 Rust→Dart 事件推送通道 +Stream registerSyncEventSink() => + RustSyncApi.instance.api.crateApiFfiRegisterSyncEventSink(); + +/// 运行时热修改日志级别(立即生效,无需重启) +Future setSyncLogLevel({required String level}) => + RustSyncApi.instance.api.crateApiFfiSetSyncLogLevel(level: level); + +/// 获取活跃的同步任务列表 +Future> getActiveTasks() => + RustSyncApi.instance.api.crateApiFfiGetActiveTasks(); + +/// 获取最近同步任务列表 +Future> getRecentTasks({required int limit}) => + RustSyncApi.instance.api.crateApiFfiGetRecentTasks(limit: limit); + +/// 获取任务详情(任务项列表) +Future> getTaskDetail({required String taskId}) => + RustSyncApi.instance.api.crateApiFfiGetTaskDetail(taskId: taskId); + +/// 多维度查询任务项 +Future> queryTaskItems({ + required TaskItemFilterFfi filter, +}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter); diff --git a/lib/src/rust/api/ffi_types.dart b/lib/src/rust/api/ffi_types.dart new file mode 100644 index 0000000..2fc0861 --- /dev/null +++ b/lib/src/rust/api/ffi_types.dart @@ -0,0 +1,450 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; +part 'ffi_types.freezed.dart'; + +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt` + +/// Android: 云端相册目录检查结果 +class CloudAlbumCheckResultFfi { + final bool dcimExists; + final bool picturesExists; + final String? dcimUri; + final String? picturesUri; + + const CloudAlbumCheckResultFfi({ + required this.dcimExists, + required this.picturesExists, + this.dcimUri, + this.picturesUri, + }); + + @override + int get hashCode => + dcimExists.hashCode ^ + picturesExists.hashCode ^ + dcimUri.hashCode ^ + picturesUri.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CloudAlbumCheckResultFfi && + runtimeType == other.runtimeType && + dcimExists == other.dcimExists && + picturesExists == other.picturesExists && + dcimUri == other.dcimUri && + picturesUri == other.picturesUri; +} + +/// 同步配置 +class SyncConfigFfi { + final String baseUrl; + final String accessToken; + final String refreshToken; + final String localRoot; + final String remoteRoot; + final String syncMode; + final String conflictStrategy; + final String wcfDeleteMode; + final int maxConcurrentTransfers; + final BigInt bandwidthLimitKbps; + final List excludedPaths; + final int maxWorkers; + final String dataDir; + final String clientId; + final String logLevel; + + const SyncConfigFfi({ + required this.baseUrl, + required this.accessToken, + required this.refreshToken, + required this.localRoot, + required this.remoteRoot, + required this.syncMode, + required this.conflictStrategy, + required this.wcfDeleteMode, + required this.maxConcurrentTransfers, + required this.bandwidthLimitKbps, + required this.excludedPaths, + required this.maxWorkers, + required this.dataDir, + required this.clientId, + required this.logLevel, + }); + + @override + int get hashCode => + baseUrl.hashCode ^ + accessToken.hashCode ^ + refreshToken.hashCode ^ + localRoot.hashCode ^ + remoteRoot.hashCode ^ + syncMode.hashCode ^ + conflictStrategy.hashCode ^ + wcfDeleteMode.hashCode ^ + maxConcurrentTransfers.hashCode ^ + bandwidthLimitKbps.hashCode ^ + excludedPaths.hashCode ^ + maxWorkers.hashCode ^ + dataDir.hashCode ^ + clientId.hashCode ^ + logLevel.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncConfigFfi && + runtimeType == other.runtimeType && + baseUrl == other.baseUrl && + accessToken == other.accessToken && + refreshToken == other.refreshToken && + localRoot == other.localRoot && + remoteRoot == other.remoteRoot && + syncMode == other.syncMode && + conflictStrategy == other.conflictStrategy && + wcfDeleteMode == other.wcfDeleteMode && + maxConcurrentTransfers == other.maxConcurrentTransfers && + bandwidthLimitKbps == other.bandwidthLimitKbps && + excludedPaths == other.excludedPaths && + maxWorkers == other.maxWorkers && + dataDir == other.dataDir && + clientId == other.clientId && + logLevel == other.logLevel; +} + +@freezed +sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException { + const SyncErrorFfi._(); + + const factory SyncErrorFfi.notInitialized() = SyncErrorFfi_NotInitialized; + const factory SyncErrorFfi.networkError({required String message}) = + SyncErrorFfi_NetworkError; + const factory SyncErrorFfi.diskFull({ + required BigInt needed, + required BigInt available, + }) = SyncErrorFfi_DiskFull; + const factory SyncErrorFfi.authError({required String message}) = + SyncErrorFfi_AuthError; + const factory SyncErrorFfi.conflictError({required int count}) = + SyncErrorFfi_ConflictError; + const factory SyncErrorFfi.internalError({required String message}) = + SyncErrorFfi_InternalError; +} + +@freezed +sealed class SyncEventFfi with _$SyncEventFfi { + const SyncEventFfi._(); + + const factory SyncEventFfi.stateChanged({required String newState}) = + SyncEventFfi_StateChanged; + const factory SyncEventFfi.progress({ + required BigInt synced, + required BigInt total, + required String currentFile, + }) = SyncEventFfi_Progress; + const factory SyncEventFfi.fileUploaded({ + required String localPath, + required String remoteUri, + }) = SyncEventFfi_FileUploaded; + const factory SyncEventFfi.fileDownloaded({ + required String localPath, + required String remoteUri, + }) = SyncEventFfi_FileDownloaded; + const factory SyncEventFfi.conflictDetected({ + required String localPath, + required String conflictType, + }) = SyncEventFfi_ConflictDetected; + const factory SyncEventFfi.error({ + required String message, + required bool recoverable, + }) = SyncEventFfi_Error; + const factory SyncEventFfi.tokenExpired() = SyncEventFfi_TokenExpired; + const factory SyncEventFfi.diskSpaceWarning({required BigInt availableMb}) = + SyncEventFfi_DiskSpaceWarning; + const factory SyncEventFfi.initialSyncComplete({ + required SyncSummaryFfi summary, + }) = SyncEventFfi_InitialSyncComplete; + const factory SyncEventFfi.workerStarted({ + required String taskId, + required String trigger, + required int uploadCount, + required int downloadCount, + }) = SyncEventFfi_WorkerStarted; + const factory SyncEventFfi.workerCompleted({ + required String taskId, + required int uploaded, + required int downloaded, + required int renamed, + required int moved, + required int failed, + required BigInt durationMs, + }) = SyncEventFfi_WorkerCompleted; + const factory SyncEventFfi.workerFailed({ + required String taskId, + required String message, + }) = SyncEventFfi_WorkerFailed; + const factory SyncEventFfi.taskItemUpdated({ + required String taskId, + required String relativePath, + required String action, + required String status, + }) = SyncEventFfi_TaskItemUpdated; +} + +/// 同步状态快照 +class SyncStatusFfi { + final String state; + final BigInt syncedFiles; + final BigInt totalFiles; + final int uploadingCount; + final int downloadingCount; + final int conflictCount; + final int errorCount; + final String? lastSyncTime; + final String? errorMessage; + + const SyncStatusFfi({ + required this.state, + required this.syncedFiles, + required this.totalFiles, + required this.uploadingCount, + required this.downloadingCount, + required this.conflictCount, + required this.errorCount, + this.lastSyncTime, + this.errorMessage, + }); + + @override + int get hashCode => + state.hashCode ^ + syncedFiles.hashCode ^ + totalFiles.hashCode ^ + uploadingCount.hashCode ^ + downloadingCount.hashCode ^ + conflictCount.hashCode ^ + errorCount.hashCode ^ + lastSyncTime.hashCode ^ + errorMessage.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncStatusFfi && + runtimeType == other.runtimeType && + state == other.state && + syncedFiles == other.syncedFiles && + totalFiles == other.totalFiles && + uploadingCount == other.uploadingCount && + downloadingCount == other.downloadingCount && + conflictCount == other.conflictCount && + errorCount == other.errorCount && + lastSyncTime == other.lastSyncTime && + errorMessage == other.errorMessage; +} + +/// 初始同步摘要 +class SyncSummaryFfi { + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int conflicts; + final int failed; + final int skipped; + final int deletedLocal; + final int deletedRemote; + final BigInt durationMs; + + const SyncSummaryFfi({ + required this.uploaded, + required this.downloaded, + required this.renamed, + required this.moved, + required this.conflicts, + required this.failed, + required this.skipped, + required this.deletedLocal, + required this.deletedRemote, + required this.durationMs, + }); + + @override + int get hashCode => + uploaded.hashCode ^ + downloaded.hashCode ^ + renamed.hashCode ^ + moved.hashCode ^ + conflicts.hashCode ^ + failed.hashCode ^ + skipped.hashCode ^ + deletedLocal.hashCode ^ + deletedRemote.hashCode ^ + durationMs.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncSummaryFfi && + runtimeType == other.runtimeType && + uploaded == other.uploaded && + downloaded == other.downloaded && + renamed == other.renamed && + moved == other.moved && + conflicts == other.conflicts && + failed == other.failed && + skipped == other.skipped && + deletedLocal == other.deletedLocal && + deletedRemote == other.deletedRemote && + durationMs == other.durationMs; +} + +/// 同步任务摘要(FFI) +class SyncTaskFfi { + final String id; + final String trigger; + final int totalCount; + final int completedCount; + final int failedCount; + final String status; + final String createdAt; + final String updatedAt; + final String? finishedAt; + + const SyncTaskFfi({ + required this.id, + required this.trigger, + required this.totalCount, + required this.completedCount, + required this.failedCount, + required this.status, + required this.createdAt, + required this.updatedAt, + this.finishedAt, + }); + + @override + int get hashCode => + id.hashCode ^ + trigger.hashCode ^ + totalCount.hashCode ^ + completedCount.hashCode ^ + failedCount.hashCode ^ + status.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + finishedAt.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncTaskFfi && + runtimeType == other.runtimeType && + id == other.id && + trigger == other.trigger && + totalCount == other.totalCount && + completedCount == other.completedCount && + failedCount == other.failedCount && + status == other.status && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + finishedAt == other.finishedAt; +} + +/// 同步任务项(FFI) +class SyncTaskItemFfi { + final PlatformInt64 id; + final String taskId; + final String relativePath; + final String actionType; + final String status; + final BigInt fileSize; + final String? errorMessage; + final String createdAt; + final String updatedAt; + + const SyncTaskItemFfi({ + required this.id, + required this.taskId, + required this.relativePath, + required this.actionType, + required this.status, + required this.fileSize, + this.errorMessage, + required this.createdAt, + required this.updatedAt, + }); + + @override + int get hashCode => + id.hashCode ^ + taskId.hashCode ^ + relativePath.hashCode ^ + actionType.hashCode ^ + status.hashCode ^ + fileSize.hashCode ^ + errorMessage.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncTaskItemFfi && + runtimeType == other.runtimeType && + id == other.id && + taskId == other.taskId && + relativePath == other.relativePath && + actionType == other.actionType && + status == other.status && + fileSize == other.fileSize && + errorMessage == other.errorMessage && + createdAt == other.createdAt && + updatedAt == other.updatedAt; +} + +/// 任务项查询过滤器(FFI) +class TaskItemFilterFfi { + final String? taskId; + final String? relativePathContains; + final String? actionType; + final String? status; + final int limit; + final int offset; + + const TaskItemFilterFfi({ + this.taskId, + this.relativePathContains, + this.actionType, + this.status, + required this.limit, + required this.offset, + }); + + @override + int get hashCode => + taskId.hashCode ^ + relativePathContains.hashCode ^ + actionType.hashCode ^ + status.hashCode ^ + limit.hashCode ^ + offset.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TaskItemFilterFfi && + runtimeType == other.runtimeType && + taskId == other.taskId && + relativePathContains == other.relativePathContains && + actionType == other.actionType && + status == other.status && + limit == other.limit && + offset == other.offset; +} diff --git a/lib/src/rust/api/ffi_types.freezed.dart b/lib/src/rust/api/ffi_types.freezed.dart new file mode 100644 index 0000000..26ddbd5 --- /dev/null +++ b/lib/src/rust/api/ffi_types.freezed.dart @@ -0,0 +1,1650 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'ffi_types.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$SyncErrorFfi { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'SyncErrorFfi()'; +} + + +} + +/// @nodoc +class $SyncErrorFfiCopyWith<$Res> { +$SyncErrorFfiCopyWith(SyncErrorFfi _, $Res Function(SyncErrorFfi) __); +} + + +/// Adds pattern-matching-related methods to [SyncErrorFfi]. +extension SyncErrorFfiPatterns on SyncErrorFfi { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( SyncErrorFfi_NotInitialized value)? notInitialized,TResult Function( SyncErrorFfi_NetworkError value)? networkError,TResult Function( SyncErrorFfi_DiskFull value)? diskFull,TResult Function( SyncErrorFfi_AuthError value)? authError,TResult Function( SyncErrorFfi_ConflictError value)? conflictError,TResult Function( SyncErrorFfi_InternalError value)? internalError,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized() when notInitialized != null: +return notInitialized(_that);case SyncErrorFfi_NetworkError() when networkError != null: +return networkError(_that);case SyncErrorFfi_DiskFull() when diskFull != null: +return diskFull(_that);case SyncErrorFfi_AuthError() when authError != null: +return authError(_that);case SyncErrorFfi_ConflictError() when conflictError != null: +return conflictError(_that);case SyncErrorFfi_InternalError() when internalError != null: +return internalError(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( SyncErrorFfi_NotInitialized value) notInitialized,required TResult Function( SyncErrorFfi_NetworkError value) networkError,required TResult Function( SyncErrorFfi_DiskFull value) diskFull,required TResult Function( SyncErrorFfi_AuthError value) authError,required TResult Function( SyncErrorFfi_ConflictError value) conflictError,required TResult Function( SyncErrorFfi_InternalError value) internalError,}){ +final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized(): +return notInitialized(_that);case SyncErrorFfi_NetworkError(): +return networkError(_that);case SyncErrorFfi_DiskFull(): +return diskFull(_that);case SyncErrorFfi_AuthError(): +return authError(_that);case SyncErrorFfi_ConflictError(): +return conflictError(_that);case SyncErrorFfi_InternalError(): +return internalError(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( SyncErrorFfi_NotInitialized value)? notInitialized,TResult? Function( SyncErrorFfi_NetworkError value)? networkError,TResult? Function( SyncErrorFfi_DiskFull value)? diskFull,TResult? Function( SyncErrorFfi_AuthError value)? authError,TResult? Function( SyncErrorFfi_ConflictError value)? conflictError,TResult? Function( SyncErrorFfi_InternalError value)? internalError,}){ +final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized() when notInitialized != null: +return notInitialized(_that);case SyncErrorFfi_NetworkError() when networkError != null: +return networkError(_that);case SyncErrorFfi_DiskFull() when diskFull != null: +return diskFull(_that);case SyncErrorFfi_AuthError() when authError != null: +return authError(_that);case SyncErrorFfi_ConflictError() when conflictError != null: +return conflictError(_that);case SyncErrorFfi_InternalError() when internalError != null: +return internalError(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? notInitialized,TResult Function( String message)? networkError,TResult Function( BigInt needed, BigInt available)? diskFull,TResult Function( String message)? authError,TResult Function( int count)? conflictError,TResult Function( String message)? internalError,required TResult orElse(),}) {final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized() when notInitialized != null: +return notInitialized();case SyncErrorFfi_NetworkError() when networkError != null: +return networkError(_that.message);case SyncErrorFfi_DiskFull() when diskFull != null: +return diskFull(_that.needed,_that.available);case SyncErrorFfi_AuthError() when authError != null: +return authError(_that.message);case SyncErrorFfi_ConflictError() when conflictError != null: +return conflictError(_that.count);case SyncErrorFfi_InternalError() when internalError != null: +return internalError(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() notInitialized,required TResult Function( String message) networkError,required TResult Function( BigInt needed, BigInt available) diskFull,required TResult Function( String message) authError,required TResult Function( int count) conflictError,required TResult Function( String message) internalError,}) {final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized(): +return notInitialized();case SyncErrorFfi_NetworkError(): +return networkError(_that.message);case SyncErrorFfi_DiskFull(): +return diskFull(_that.needed,_that.available);case SyncErrorFfi_AuthError(): +return authError(_that.message);case SyncErrorFfi_ConflictError(): +return conflictError(_that.count);case SyncErrorFfi_InternalError(): +return internalError(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? notInitialized,TResult? Function( String message)? networkError,TResult? Function( BigInt needed, BigInt available)? diskFull,TResult? Function( String message)? authError,TResult? Function( int count)? conflictError,TResult? Function( String message)? internalError,}) {final _that = this; +switch (_that) { +case SyncErrorFfi_NotInitialized() when notInitialized != null: +return notInitialized();case SyncErrorFfi_NetworkError() when networkError != null: +return networkError(_that.message);case SyncErrorFfi_DiskFull() when diskFull != null: +return diskFull(_that.needed,_that.available);case SyncErrorFfi_AuthError() when authError != null: +return authError(_that.message);case SyncErrorFfi_ConflictError() when conflictError != null: +return conflictError(_that.count);case SyncErrorFfi_InternalError() when internalError != null: +return internalError(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class SyncErrorFfi_NotInitialized extends SyncErrorFfi { + const SyncErrorFfi_NotInitialized(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_NotInitialized); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'SyncErrorFfi.notInitialized()'; +} + + +} + + + + +/// @nodoc + + +class SyncErrorFfi_NetworkError extends SyncErrorFfi { + const SyncErrorFfi_NetworkError({required this.message}): super._(); + + + final String message; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncErrorFfi_NetworkErrorCopyWith get copyWith => _$SyncErrorFfi_NetworkErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_NetworkError&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'SyncErrorFfi.networkError(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncErrorFfi_NetworkErrorCopyWith<$Res> implements $SyncErrorFfiCopyWith<$Res> { + factory $SyncErrorFfi_NetworkErrorCopyWith(SyncErrorFfi_NetworkError value, $Res Function(SyncErrorFfi_NetworkError) _then) = _$SyncErrorFfi_NetworkErrorCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$SyncErrorFfi_NetworkErrorCopyWithImpl<$Res> + implements $SyncErrorFfi_NetworkErrorCopyWith<$Res> { + _$SyncErrorFfi_NetworkErrorCopyWithImpl(this._self, this._then); + + final SyncErrorFfi_NetworkError _self; + final $Res Function(SyncErrorFfi_NetworkError) _then; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(SyncErrorFfi_NetworkError( +message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncErrorFfi_DiskFull extends SyncErrorFfi { + const SyncErrorFfi_DiskFull({required this.needed, required this.available}): super._(); + + + final BigInt needed; + final BigInt available; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncErrorFfi_DiskFullCopyWith get copyWith => _$SyncErrorFfi_DiskFullCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_DiskFull&&(identical(other.needed, needed) || other.needed == needed)&&(identical(other.available, available) || other.available == available)); +} + + +@override +int get hashCode => Object.hash(runtimeType,needed,available); + +@override +String toString() { + return 'SyncErrorFfi.diskFull(needed: $needed, available: $available)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncErrorFfi_DiskFullCopyWith<$Res> implements $SyncErrorFfiCopyWith<$Res> { + factory $SyncErrorFfi_DiskFullCopyWith(SyncErrorFfi_DiskFull value, $Res Function(SyncErrorFfi_DiskFull) _then) = _$SyncErrorFfi_DiskFullCopyWithImpl; +@useResult +$Res call({ + BigInt needed, BigInt available +}); + + + + +} +/// @nodoc +class _$SyncErrorFfi_DiskFullCopyWithImpl<$Res> + implements $SyncErrorFfi_DiskFullCopyWith<$Res> { + _$SyncErrorFfi_DiskFullCopyWithImpl(this._self, this._then); + + final SyncErrorFfi_DiskFull _self; + final $Res Function(SyncErrorFfi_DiskFull) _then; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? needed = null,Object? available = null,}) { + return _then(SyncErrorFfi_DiskFull( +needed: null == needed ? _self.needed : needed // ignore: cast_nullable_to_non_nullable +as BigInt,available: null == available ? _self.available : available // ignore: cast_nullable_to_non_nullable +as BigInt, + )); +} + + +} + +/// @nodoc + + +class SyncErrorFfi_AuthError extends SyncErrorFfi { + const SyncErrorFfi_AuthError({required this.message}): super._(); + + + final String message; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncErrorFfi_AuthErrorCopyWith get copyWith => _$SyncErrorFfi_AuthErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_AuthError&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'SyncErrorFfi.authError(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncErrorFfi_AuthErrorCopyWith<$Res> implements $SyncErrorFfiCopyWith<$Res> { + factory $SyncErrorFfi_AuthErrorCopyWith(SyncErrorFfi_AuthError value, $Res Function(SyncErrorFfi_AuthError) _then) = _$SyncErrorFfi_AuthErrorCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$SyncErrorFfi_AuthErrorCopyWithImpl<$Res> + implements $SyncErrorFfi_AuthErrorCopyWith<$Res> { + _$SyncErrorFfi_AuthErrorCopyWithImpl(this._self, this._then); + + final SyncErrorFfi_AuthError _self; + final $Res Function(SyncErrorFfi_AuthError) _then; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(SyncErrorFfi_AuthError( +message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncErrorFfi_ConflictError extends SyncErrorFfi { + const SyncErrorFfi_ConflictError({required this.count}): super._(); + + + final int count; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncErrorFfi_ConflictErrorCopyWith get copyWith => _$SyncErrorFfi_ConflictErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_ConflictError&&(identical(other.count, count) || other.count == count)); +} + + +@override +int get hashCode => Object.hash(runtimeType,count); + +@override +String toString() { + return 'SyncErrorFfi.conflictError(count: $count)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncErrorFfi_ConflictErrorCopyWith<$Res> implements $SyncErrorFfiCopyWith<$Res> { + factory $SyncErrorFfi_ConflictErrorCopyWith(SyncErrorFfi_ConflictError value, $Res Function(SyncErrorFfi_ConflictError) _then) = _$SyncErrorFfi_ConflictErrorCopyWithImpl; +@useResult +$Res call({ + int count +}); + + + + +} +/// @nodoc +class _$SyncErrorFfi_ConflictErrorCopyWithImpl<$Res> + implements $SyncErrorFfi_ConflictErrorCopyWith<$Res> { + _$SyncErrorFfi_ConflictErrorCopyWithImpl(this._self, this._then); + + final SyncErrorFfi_ConflictError _self; + final $Res Function(SyncErrorFfi_ConflictError) _then; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? count = null,}) { + return _then(SyncErrorFfi_ConflictError( +count: null == count ? _self.count : count // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class SyncErrorFfi_InternalError extends SyncErrorFfi { + const SyncErrorFfi_InternalError({required this.message}): super._(); + + + final String message; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncErrorFfi_InternalErrorCopyWith get copyWith => _$SyncErrorFfi_InternalErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncErrorFfi_InternalError&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'SyncErrorFfi.internalError(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncErrorFfi_InternalErrorCopyWith<$Res> implements $SyncErrorFfiCopyWith<$Res> { + factory $SyncErrorFfi_InternalErrorCopyWith(SyncErrorFfi_InternalError value, $Res Function(SyncErrorFfi_InternalError) _then) = _$SyncErrorFfi_InternalErrorCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$SyncErrorFfi_InternalErrorCopyWithImpl<$Res> + implements $SyncErrorFfi_InternalErrorCopyWith<$Res> { + _$SyncErrorFfi_InternalErrorCopyWithImpl(this._self, this._then); + + final SyncErrorFfi_InternalError _self; + final $Res Function(SyncErrorFfi_InternalError) _then; + +/// Create a copy of SyncErrorFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(SyncErrorFfi_InternalError( +message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$SyncEventFfi { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'SyncEventFfi()'; +} + + +} + +/// @nodoc +class $SyncEventFfiCopyWith<$Res> { +$SyncEventFfiCopyWith(SyncEventFfi _, $Res Function(SyncEventFfi) __); +} + + +/// Adds pattern-matching-related methods to [SyncEventFfi]. +extension SyncEventFfiPatterns on SyncEventFfi { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( SyncEventFfi_StateChanged value)? stateChanged,TResult Function( SyncEventFfi_Progress value)? progress,TResult Function( SyncEventFfi_FileUploaded value)? fileUploaded,TResult Function( SyncEventFfi_FileDownloaded value)? fileDownloaded,TResult Function( SyncEventFfi_ConflictDetected value)? conflictDetected,TResult Function( SyncEventFfi_Error value)? error,TResult Function( SyncEventFfi_TokenExpired value)? tokenExpired,TResult Function( SyncEventFfi_DiskSpaceWarning value)? diskSpaceWarning,TResult Function( SyncEventFfi_InitialSyncComplete value)? initialSyncComplete,TResult Function( SyncEventFfi_WorkerStarted value)? workerStarted,TResult Function( SyncEventFfi_WorkerCompleted value)? workerCompleted,TResult Function( SyncEventFfi_WorkerFailed value)? workerFailed,TResult Function( SyncEventFfi_TaskItemUpdated value)? taskItemUpdated,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged() when stateChanged != null: +return stateChanged(_that);case SyncEventFfi_Progress() when progress != null: +return progress(_that);case SyncEventFfi_FileUploaded() when fileUploaded != null: +return fileUploaded(_that);case SyncEventFfi_FileDownloaded() when fileDownloaded != null: +return fileDownloaded(_that);case SyncEventFfi_ConflictDetected() when conflictDetected != null: +return conflictDetected(_that);case SyncEventFfi_Error() when error != null: +return error(_that);case SyncEventFfi_TokenExpired() when tokenExpired != null: +return tokenExpired(_that);case SyncEventFfi_DiskSpaceWarning() when diskSpaceWarning != null: +return diskSpaceWarning(_that);case SyncEventFfi_InitialSyncComplete() when initialSyncComplete != null: +return initialSyncComplete(_that);case SyncEventFfi_WorkerStarted() when workerStarted != null: +return workerStarted(_that);case SyncEventFfi_WorkerCompleted() when workerCompleted != null: +return workerCompleted(_that);case SyncEventFfi_WorkerFailed() when workerFailed != null: +return workerFailed(_that);case SyncEventFfi_TaskItemUpdated() when taskItemUpdated != null: +return taskItemUpdated(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( SyncEventFfi_StateChanged value) stateChanged,required TResult Function( SyncEventFfi_Progress value) progress,required TResult Function( SyncEventFfi_FileUploaded value) fileUploaded,required TResult Function( SyncEventFfi_FileDownloaded value) fileDownloaded,required TResult Function( SyncEventFfi_ConflictDetected value) conflictDetected,required TResult Function( SyncEventFfi_Error value) error,required TResult Function( SyncEventFfi_TokenExpired value) tokenExpired,required TResult Function( SyncEventFfi_DiskSpaceWarning value) diskSpaceWarning,required TResult Function( SyncEventFfi_InitialSyncComplete value) initialSyncComplete,required TResult Function( SyncEventFfi_WorkerStarted value) workerStarted,required TResult Function( SyncEventFfi_WorkerCompleted value) workerCompleted,required TResult Function( SyncEventFfi_WorkerFailed value) workerFailed,required TResult Function( SyncEventFfi_TaskItemUpdated value) taskItemUpdated,}){ +final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged(): +return stateChanged(_that);case SyncEventFfi_Progress(): +return progress(_that);case SyncEventFfi_FileUploaded(): +return fileUploaded(_that);case SyncEventFfi_FileDownloaded(): +return fileDownloaded(_that);case SyncEventFfi_ConflictDetected(): +return conflictDetected(_that);case SyncEventFfi_Error(): +return error(_that);case SyncEventFfi_TokenExpired(): +return tokenExpired(_that);case SyncEventFfi_DiskSpaceWarning(): +return diskSpaceWarning(_that);case SyncEventFfi_InitialSyncComplete(): +return initialSyncComplete(_that);case SyncEventFfi_WorkerStarted(): +return workerStarted(_that);case SyncEventFfi_WorkerCompleted(): +return workerCompleted(_that);case SyncEventFfi_WorkerFailed(): +return workerFailed(_that);case SyncEventFfi_TaskItemUpdated(): +return taskItemUpdated(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( SyncEventFfi_StateChanged value)? stateChanged,TResult? Function( SyncEventFfi_Progress value)? progress,TResult? Function( SyncEventFfi_FileUploaded value)? fileUploaded,TResult? Function( SyncEventFfi_FileDownloaded value)? fileDownloaded,TResult? Function( SyncEventFfi_ConflictDetected value)? conflictDetected,TResult? Function( SyncEventFfi_Error value)? error,TResult? Function( SyncEventFfi_TokenExpired value)? tokenExpired,TResult? Function( SyncEventFfi_DiskSpaceWarning value)? diskSpaceWarning,TResult? Function( SyncEventFfi_InitialSyncComplete value)? initialSyncComplete,TResult? Function( SyncEventFfi_WorkerStarted value)? workerStarted,TResult? Function( SyncEventFfi_WorkerCompleted value)? workerCompleted,TResult? Function( SyncEventFfi_WorkerFailed value)? workerFailed,TResult? Function( SyncEventFfi_TaskItemUpdated value)? taskItemUpdated,}){ +final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged() when stateChanged != null: +return stateChanged(_that);case SyncEventFfi_Progress() when progress != null: +return progress(_that);case SyncEventFfi_FileUploaded() when fileUploaded != null: +return fileUploaded(_that);case SyncEventFfi_FileDownloaded() when fileDownloaded != null: +return fileDownloaded(_that);case SyncEventFfi_ConflictDetected() when conflictDetected != null: +return conflictDetected(_that);case SyncEventFfi_Error() when error != null: +return error(_that);case SyncEventFfi_TokenExpired() when tokenExpired != null: +return tokenExpired(_that);case SyncEventFfi_DiskSpaceWarning() when diskSpaceWarning != null: +return diskSpaceWarning(_that);case SyncEventFfi_InitialSyncComplete() when initialSyncComplete != null: +return initialSyncComplete(_that);case SyncEventFfi_WorkerStarted() when workerStarted != null: +return workerStarted(_that);case SyncEventFfi_WorkerCompleted() when workerCompleted != null: +return workerCompleted(_that);case SyncEventFfi_WorkerFailed() when workerFailed != null: +return workerFailed(_that);case SyncEventFfi_TaskItemUpdated() when taskItemUpdated != null: +return taskItemUpdated(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String newState)? stateChanged,TResult Function( BigInt synced, BigInt total, String currentFile)? progress,TResult Function( String localPath, String remoteUri)? fileUploaded,TResult Function( String localPath, String remoteUri)? fileDownloaded,TResult Function( String localPath, String conflictType)? conflictDetected,TResult Function( String message, bool recoverable)? error,TResult Function()? tokenExpired,TResult Function( BigInt availableMb)? diskSpaceWarning,TResult Function( SyncSummaryFfi summary)? initialSyncComplete,TResult Function( String taskId, String trigger, int uploadCount, int downloadCount)? workerStarted,TResult Function( String taskId, int uploaded, int downloaded, int renamed, int moved, int failed, BigInt durationMs)? workerCompleted,TResult Function( String taskId, String message)? workerFailed,TResult Function( String taskId, String relativePath, String action, String status)? taskItemUpdated,required TResult orElse(),}) {final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged() when stateChanged != null: +return stateChanged(_that.newState);case SyncEventFfi_Progress() when progress != null: +return progress(_that.synced,_that.total,_that.currentFile);case SyncEventFfi_FileUploaded() when fileUploaded != null: +return fileUploaded(_that.localPath,_that.remoteUri);case SyncEventFfi_FileDownloaded() when fileDownloaded != null: +return fileDownloaded(_that.localPath,_that.remoteUri);case SyncEventFfi_ConflictDetected() when conflictDetected != null: +return conflictDetected(_that.localPath,_that.conflictType);case SyncEventFfi_Error() when error != null: +return error(_that.message,_that.recoverable);case SyncEventFfi_TokenExpired() when tokenExpired != null: +return tokenExpired();case SyncEventFfi_DiskSpaceWarning() when diskSpaceWarning != null: +return diskSpaceWarning(_that.availableMb);case SyncEventFfi_InitialSyncComplete() when initialSyncComplete != null: +return initialSyncComplete(_that.summary);case SyncEventFfi_WorkerStarted() when workerStarted != null: +return workerStarted(_that.taskId,_that.trigger,_that.uploadCount,_that.downloadCount);case SyncEventFfi_WorkerCompleted() when workerCompleted != null: +return workerCompleted(_that.taskId,_that.uploaded,_that.downloaded,_that.renamed,_that.moved,_that.failed,_that.durationMs);case SyncEventFfi_WorkerFailed() when workerFailed != null: +return workerFailed(_that.taskId,_that.message);case SyncEventFfi_TaskItemUpdated() when taskItemUpdated != null: +return taskItemUpdated(_that.taskId,_that.relativePath,_that.action,_that.status);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String newState) stateChanged,required TResult Function( BigInt synced, BigInt total, String currentFile) progress,required TResult Function( String localPath, String remoteUri) fileUploaded,required TResult Function( String localPath, String remoteUri) fileDownloaded,required TResult Function( String localPath, String conflictType) conflictDetected,required TResult Function( String message, bool recoverable) error,required TResult Function() tokenExpired,required TResult Function( BigInt availableMb) diskSpaceWarning,required TResult Function( SyncSummaryFfi summary) initialSyncComplete,required TResult Function( String taskId, String trigger, int uploadCount, int downloadCount) workerStarted,required TResult Function( String taskId, int uploaded, int downloaded, int renamed, int moved, int failed, BigInt durationMs) workerCompleted,required TResult Function( String taskId, String message) workerFailed,required TResult Function( String taskId, String relativePath, String action, String status) taskItemUpdated,}) {final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged(): +return stateChanged(_that.newState);case SyncEventFfi_Progress(): +return progress(_that.synced,_that.total,_that.currentFile);case SyncEventFfi_FileUploaded(): +return fileUploaded(_that.localPath,_that.remoteUri);case SyncEventFfi_FileDownloaded(): +return fileDownloaded(_that.localPath,_that.remoteUri);case SyncEventFfi_ConflictDetected(): +return conflictDetected(_that.localPath,_that.conflictType);case SyncEventFfi_Error(): +return error(_that.message,_that.recoverable);case SyncEventFfi_TokenExpired(): +return tokenExpired();case SyncEventFfi_DiskSpaceWarning(): +return diskSpaceWarning(_that.availableMb);case SyncEventFfi_InitialSyncComplete(): +return initialSyncComplete(_that.summary);case SyncEventFfi_WorkerStarted(): +return workerStarted(_that.taskId,_that.trigger,_that.uploadCount,_that.downloadCount);case SyncEventFfi_WorkerCompleted(): +return workerCompleted(_that.taskId,_that.uploaded,_that.downloaded,_that.renamed,_that.moved,_that.failed,_that.durationMs);case SyncEventFfi_WorkerFailed(): +return workerFailed(_that.taskId,_that.message);case SyncEventFfi_TaskItemUpdated(): +return taskItemUpdated(_that.taskId,_that.relativePath,_that.action,_that.status);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String newState)? stateChanged,TResult? Function( BigInt synced, BigInt total, String currentFile)? progress,TResult? Function( String localPath, String remoteUri)? fileUploaded,TResult? Function( String localPath, String remoteUri)? fileDownloaded,TResult? Function( String localPath, String conflictType)? conflictDetected,TResult? Function( String message, bool recoverable)? error,TResult? Function()? tokenExpired,TResult? Function( BigInt availableMb)? diskSpaceWarning,TResult? Function( SyncSummaryFfi summary)? initialSyncComplete,TResult? Function( String taskId, String trigger, int uploadCount, int downloadCount)? workerStarted,TResult? Function( String taskId, int uploaded, int downloaded, int renamed, int moved, int failed, BigInt durationMs)? workerCompleted,TResult? Function( String taskId, String message)? workerFailed,TResult? Function( String taskId, String relativePath, String action, String status)? taskItemUpdated,}) {final _that = this; +switch (_that) { +case SyncEventFfi_StateChanged() when stateChanged != null: +return stateChanged(_that.newState);case SyncEventFfi_Progress() when progress != null: +return progress(_that.synced,_that.total,_that.currentFile);case SyncEventFfi_FileUploaded() when fileUploaded != null: +return fileUploaded(_that.localPath,_that.remoteUri);case SyncEventFfi_FileDownloaded() when fileDownloaded != null: +return fileDownloaded(_that.localPath,_that.remoteUri);case SyncEventFfi_ConflictDetected() when conflictDetected != null: +return conflictDetected(_that.localPath,_that.conflictType);case SyncEventFfi_Error() when error != null: +return error(_that.message,_that.recoverable);case SyncEventFfi_TokenExpired() when tokenExpired != null: +return tokenExpired();case SyncEventFfi_DiskSpaceWarning() when diskSpaceWarning != null: +return diskSpaceWarning(_that.availableMb);case SyncEventFfi_InitialSyncComplete() when initialSyncComplete != null: +return initialSyncComplete(_that.summary);case SyncEventFfi_WorkerStarted() when workerStarted != null: +return workerStarted(_that.taskId,_that.trigger,_that.uploadCount,_that.downloadCount);case SyncEventFfi_WorkerCompleted() when workerCompleted != null: +return workerCompleted(_that.taskId,_that.uploaded,_that.downloaded,_that.renamed,_that.moved,_that.failed,_that.durationMs);case SyncEventFfi_WorkerFailed() when workerFailed != null: +return workerFailed(_that.taskId,_that.message);case SyncEventFfi_TaskItemUpdated() when taskItemUpdated != null: +return taskItemUpdated(_that.taskId,_that.relativePath,_that.action,_that.status);case _: + return null; + +} +} + +} + +/// @nodoc + + +class SyncEventFfi_StateChanged extends SyncEventFfi { + const SyncEventFfi_StateChanged({required this.newState}): super._(); + + + final String newState; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_StateChangedCopyWith get copyWith => _$SyncEventFfi_StateChangedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_StateChanged&&(identical(other.newState, newState) || other.newState == newState)); +} + + +@override +int get hashCode => Object.hash(runtimeType,newState); + +@override +String toString() { + return 'SyncEventFfi.stateChanged(newState: $newState)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_StateChangedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_StateChangedCopyWith(SyncEventFfi_StateChanged value, $Res Function(SyncEventFfi_StateChanged) _then) = _$SyncEventFfi_StateChangedCopyWithImpl; +@useResult +$Res call({ + String newState +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_StateChangedCopyWithImpl<$Res> + implements $SyncEventFfi_StateChangedCopyWith<$Res> { + _$SyncEventFfi_StateChangedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_StateChanged _self; + final $Res Function(SyncEventFfi_StateChanged) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? newState = null,}) { + return _then(SyncEventFfi_StateChanged( +newState: null == newState ? _self.newState : newState // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_Progress extends SyncEventFfi { + const SyncEventFfi_Progress({required this.synced, required this.total, required this.currentFile}): super._(); + + + final BigInt synced; + final BigInt total; + final String currentFile; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_ProgressCopyWith get copyWith => _$SyncEventFfi_ProgressCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_Progress&&(identical(other.synced, synced) || other.synced == synced)&&(identical(other.total, total) || other.total == total)&&(identical(other.currentFile, currentFile) || other.currentFile == currentFile)); +} + + +@override +int get hashCode => Object.hash(runtimeType,synced,total,currentFile); + +@override +String toString() { + return 'SyncEventFfi.progress(synced: $synced, total: $total, currentFile: $currentFile)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_ProgressCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_ProgressCopyWith(SyncEventFfi_Progress value, $Res Function(SyncEventFfi_Progress) _then) = _$SyncEventFfi_ProgressCopyWithImpl; +@useResult +$Res call({ + BigInt synced, BigInt total, String currentFile +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_ProgressCopyWithImpl<$Res> + implements $SyncEventFfi_ProgressCopyWith<$Res> { + _$SyncEventFfi_ProgressCopyWithImpl(this._self, this._then); + + final SyncEventFfi_Progress _self; + final $Res Function(SyncEventFfi_Progress) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? synced = null,Object? total = null,Object? currentFile = null,}) { + return _then(SyncEventFfi_Progress( +synced: null == synced ? _self.synced : synced // ignore: cast_nullable_to_non_nullable +as BigInt,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable +as BigInt,currentFile: null == currentFile ? _self.currentFile : currentFile // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_FileUploaded extends SyncEventFfi { + const SyncEventFfi_FileUploaded({required this.localPath, required this.remoteUri}): super._(); + + + final String localPath; + final String remoteUri; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_FileUploadedCopyWith get copyWith => _$SyncEventFfi_FileUploadedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_FileUploaded&&(identical(other.localPath, localPath) || other.localPath == localPath)&&(identical(other.remoteUri, remoteUri) || other.remoteUri == remoteUri)); +} + + +@override +int get hashCode => Object.hash(runtimeType,localPath,remoteUri); + +@override +String toString() { + return 'SyncEventFfi.fileUploaded(localPath: $localPath, remoteUri: $remoteUri)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_FileUploadedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_FileUploadedCopyWith(SyncEventFfi_FileUploaded value, $Res Function(SyncEventFfi_FileUploaded) _then) = _$SyncEventFfi_FileUploadedCopyWithImpl; +@useResult +$Res call({ + String localPath, String remoteUri +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_FileUploadedCopyWithImpl<$Res> + implements $SyncEventFfi_FileUploadedCopyWith<$Res> { + _$SyncEventFfi_FileUploadedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_FileUploaded _self; + final $Res Function(SyncEventFfi_FileUploaded) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? localPath = null,Object? remoteUri = null,}) { + return _then(SyncEventFfi_FileUploaded( +localPath: null == localPath ? _self.localPath : localPath // ignore: cast_nullable_to_non_nullable +as String,remoteUri: null == remoteUri ? _self.remoteUri : remoteUri // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_FileDownloaded extends SyncEventFfi { + const SyncEventFfi_FileDownloaded({required this.localPath, required this.remoteUri}): super._(); + + + final String localPath; + final String remoteUri; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_FileDownloadedCopyWith get copyWith => _$SyncEventFfi_FileDownloadedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_FileDownloaded&&(identical(other.localPath, localPath) || other.localPath == localPath)&&(identical(other.remoteUri, remoteUri) || other.remoteUri == remoteUri)); +} + + +@override +int get hashCode => Object.hash(runtimeType,localPath,remoteUri); + +@override +String toString() { + return 'SyncEventFfi.fileDownloaded(localPath: $localPath, remoteUri: $remoteUri)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_FileDownloadedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_FileDownloadedCopyWith(SyncEventFfi_FileDownloaded value, $Res Function(SyncEventFfi_FileDownloaded) _then) = _$SyncEventFfi_FileDownloadedCopyWithImpl; +@useResult +$Res call({ + String localPath, String remoteUri +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_FileDownloadedCopyWithImpl<$Res> + implements $SyncEventFfi_FileDownloadedCopyWith<$Res> { + _$SyncEventFfi_FileDownloadedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_FileDownloaded _self; + final $Res Function(SyncEventFfi_FileDownloaded) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? localPath = null,Object? remoteUri = null,}) { + return _then(SyncEventFfi_FileDownloaded( +localPath: null == localPath ? _self.localPath : localPath // ignore: cast_nullable_to_non_nullable +as String,remoteUri: null == remoteUri ? _self.remoteUri : remoteUri // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_ConflictDetected extends SyncEventFfi { + const SyncEventFfi_ConflictDetected({required this.localPath, required this.conflictType}): super._(); + + + final String localPath; + final String conflictType; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_ConflictDetectedCopyWith get copyWith => _$SyncEventFfi_ConflictDetectedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_ConflictDetected&&(identical(other.localPath, localPath) || other.localPath == localPath)&&(identical(other.conflictType, conflictType) || other.conflictType == conflictType)); +} + + +@override +int get hashCode => Object.hash(runtimeType,localPath,conflictType); + +@override +String toString() { + return 'SyncEventFfi.conflictDetected(localPath: $localPath, conflictType: $conflictType)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_ConflictDetectedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_ConflictDetectedCopyWith(SyncEventFfi_ConflictDetected value, $Res Function(SyncEventFfi_ConflictDetected) _then) = _$SyncEventFfi_ConflictDetectedCopyWithImpl; +@useResult +$Res call({ + String localPath, String conflictType +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_ConflictDetectedCopyWithImpl<$Res> + implements $SyncEventFfi_ConflictDetectedCopyWith<$Res> { + _$SyncEventFfi_ConflictDetectedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_ConflictDetected _self; + final $Res Function(SyncEventFfi_ConflictDetected) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? localPath = null,Object? conflictType = null,}) { + return _then(SyncEventFfi_ConflictDetected( +localPath: null == localPath ? _self.localPath : localPath // ignore: cast_nullable_to_non_nullable +as String,conflictType: null == conflictType ? _self.conflictType : conflictType // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_Error extends SyncEventFfi { + const SyncEventFfi_Error({required this.message, required this.recoverable}): super._(); + + + final String message; + final bool recoverable; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_ErrorCopyWith get copyWith => _$SyncEventFfi_ErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_Error&&(identical(other.message, message) || other.message == message)&&(identical(other.recoverable, recoverable) || other.recoverable == recoverable)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message,recoverable); + +@override +String toString() { + return 'SyncEventFfi.error(message: $message, recoverable: $recoverable)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_ErrorCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_ErrorCopyWith(SyncEventFfi_Error value, $Res Function(SyncEventFfi_Error) _then) = _$SyncEventFfi_ErrorCopyWithImpl; +@useResult +$Res call({ + String message, bool recoverable +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_ErrorCopyWithImpl<$Res> + implements $SyncEventFfi_ErrorCopyWith<$Res> { + _$SyncEventFfi_ErrorCopyWithImpl(this._self, this._then); + + final SyncEventFfi_Error _self; + final $Res Function(SyncEventFfi_Error) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,Object? recoverable = null,}) { + return _then(SyncEventFfi_Error( +message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,recoverable: null == recoverable ? _self.recoverable : recoverable // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_TokenExpired extends SyncEventFfi { + const SyncEventFfi_TokenExpired(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_TokenExpired); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'SyncEventFfi.tokenExpired()'; +} + + +} + + + + +/// @nodoc + + +class SyncEventFfi_DiskSpaceWarning extends SyncEventFfi { + const SyncEventFfi_DiskSpaceWarning({required this.availableMb}): super._(); + + + final BigInt availableMb; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_DiskSpaceWarningCopyWith get copyWith => _$SyncEventFfi_DiskSpaceWarningCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_DiskSpaceWarning&&(identical(other.availableMb, availableMb) || other.availableMb == availableMb)); +} + + +@override +int get hashCode => Object.hash(runtimeType,availableMb); + +@override +String toString() { + return 'SyncEventFfi.diskSpaceWarning(availableMb: $availableMb)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_DiskSpaceWarningCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_DiskSpaceWarningCopyWith(SyncEventFfi_DiskSpaceWarning value, $Res Function(SyncEventFfi_DiskSpaceWarning) _then) = _$SyncEventFfi_DiskSpaceWarningCopyWithImpl; +@useResult +$Res call({ + BigInt availableMb +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_DiskSpaceWarningCopyWithImpl<$Res> + implements $SyncEventFfi_DiskSpaceWarningCopyWith<$Res> { + _$SyncEventFfi_DiskSpaceWarningCopyWithImpl(this._self, this._then); + + final SyncEventFfi_DiskSpaceWarning _self; + final $Res Function(SyncEventFfi_DiskSpaceWarning) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? availableMb = null,}) { + return _then(SyncEventFfi_DiskSpaceWarning( +availableMb: null == availableMb ? _self.availableMb : availableMb // ignore: cast_nullable_to_non_nullable +as BigInt, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_InitialSyncComplete extends SyncEventFfi { + const SyncEventFfi_InitialSyncComplete({required this.summary}): super._(); + + + final SyncSummaryFfi summary; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_InitialSyncCompleteCopyWith get copyWith => _$SyncEventFfi_InitialSyncCompleteCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_InitialSyncComplete&&(identical(other.summary, summary) || other.summary == summary)); +} + + +@override +int get hashCode => Object.hash(runtimeType,summary); + +@override +String toString() { + return 'SyncEventFfi.initialSyncComplete(summary: $summary)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_InitialSyncCompleteCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_InitialSyncCompleteCopyWith(SyncEventFfi_InitialSyncComplete value, $Res Function(SyncEventFfi_InitialSyncComplete) _then) = _$SyncEventFfi_InitialSyncCompleteCopyWithImpl; +@useResult +$Res call({ + SyncSummaryFfi summary +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_InitialSyncCompleteCopyWithImpl<$Res> + implements $SyncEventFfi_InitialSyncCompleteCopyWith<$Res> { + _$SyncEventFfi_InitialSyncCompleteCopyWithImpl(this._self, this._then); + + final SyncEventFfi_InitialSyncComplete _self; + final $Res Function(SyncEventFfi_InitialSyncComplete) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? summary = null,}) { + return _then(SyncEventFfi_InitialSyncComplete( +summary: null == summary ? _self.summary : summary // ignore: cast_nullable_to_non_nullable +as SyncSummaryFfi, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_WorkerStarted extends SyncEventFfi { + const SyncEventFfi_WorkerStarted({required this.taskId, required this.trigger, required this.uploadCount, required this.downloadCount}): super._(); + + + final String taskId; + final String trigger; + final int uploadCount; + final int downloadCount; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_WorkerStartedCopyWith get copyWith => _$SyncEventFfi_WorkerStartedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_WorkerStarted&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.trigger, trigger) || other.trigger == trigger)&&(identical(other.uploadCount, uploadCount) || other.uploadCount == uploadCount)&&(identical(other.downloadCount, downloadCount) || other.downloadCount == downloadCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,taskId,trigger,uploadCount,downloadCount); + +@override +String toString() { + return 'SyncEventFfi.workerStarted(taskId: $taskId, trigger: $trigger, uploadCount: $uploadCount, downloadCount: $downloadCount)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_WorkerStartedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_WorkerStartedCopyWith(SyncEventFfi_WorkerStarted value, $Res Function(SyncEventFfi_WorkerStarted) _then) = _$SyncEventFfi_WorkerStartedCopyWithImpl; +@useResult +$Res call({ + String taskId, String trigger, int uploadCount, int downloadCount +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_WorkerStartedCopyWithImpl<$Res> + implements $SyncEventFfi_WorkerStartedCopyWith<$Res> { + _$SyncEventFfi_WorkerStartedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_WorkerStarted _self; + final $Res Function(SyncEventFfi_WorkerStarted) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? taskId = null,Object? trigger = null,Object? uploadCount = null,Object? downloadCount = null,}) { + return _then(SyncEventFfi_WorkerStarted( +taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as String,trigger: null == trigger ? _self.trigger : trigger // ignore: cast_nullable_to_non_nullable +as String,uploadCount: null == uploadCount ? _self.uploadCount : uploadCount // ignore: cast_nullable_to_non_nullable +as int,downloadCount: null == downloadCount ? _self.downloadCount : downloadCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_WorkerCompleted extends SyncEventFfi { + const SyncEventFfi_WorkerCompleted({required this.taskId, required this.uploaded, required this.downloaded, required this.renamed, required this.moved, required this.failed, required this.durationMs}): super._(); + + + final String taskId; + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int failed; + final BigInt durationMs; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_WorkerCompletedCopyWith get copyWith => _$SyncEventFfi_WorkerCompletedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_WorkerCompleted&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.uploaded, uploaded) || other.uploaded == uploaded)&&(identical(other.downloaded, downloaded) || other.downloaded == downloaded)&&(identical(other.renamed, renamed) || other.renamed == renamed)&&(identical(other.moved, moved) || other.moved == moved)&&(identical(other.failed, failed) || other.failed == failed)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs)); +} + + +@override +int get hashCode => Object.hash(runtimeType,taskId,uploaded,downloaded,renamed,moved,failed,durationMs); + +@override +String toString() { + return 'SyncEventFfi.workerCompleted(taskId: $taskId, uploaded: $uploaded, downloaded: $downloaded, renamed: $renamed, moved: $moved, failed: $failed, durationMs: $durationMs)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_WorkerCompletedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_WorkerCompletedCopyWith(SyncEventFfi_WorkerCompleted value, $Res Function(SyncEventFfi_WorkerCompleted) _then) = _$SyncEventFfi_WorkerCompletedCopyWithImpl; +@useResult +$Res call({ + String taskId, int uploaded, int downloaded, int renamed, int moved, int failed, BigInt durationMs +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_WorkerCompletedCopyWithImpl<$Res> + implements $SyncEventFfi_WorkerCompletedCopyWith<$Res> { + _$SyncEventFfi_WorkerCompletedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_WorkerCompleted _self; + final $Res Function(SyncEventFfi_WorkerCompleted) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? taskId = null,Object? uploaded = null,Object? downloaded = null,Object? renamed = null,Object? moved = null,Object? failed = null,Object? durationMs = null,}) { + return _then(SyncEventFfi_WorkerCompleted( +taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as String,uploaded: null == uploaded ? _self.uploaded : uploaded // ignore: cast_nullable_to_non_nullable +as int,downloaded: null == downloaded ? _self.downloaded : downloaded // ignore: cast_nullable_to_non_nullable +as int,renamed: null == renamed ? _self.renamed : renamed // ignore: cast_nullable_to_non_nullable +as int,moved: null == moved ? _self.moved : moved // ignore: cast_nullable_to_non_nullable +as int,failed: null == failed ? _self.failed : failed // ignore: cast_nullable_to_non_nullable +as int,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable +as BigInt, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_WorkerFailed extends SyncEventFfi { + const SyncEventFfi_WorkerFailed({required this.taskId, required this.message}): super._(); + + + final String taskId; + final String message; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_WorkerFailedCopyWith get copyWith => _$SyncEventFfi_WorkerFailedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_WorkerFailed&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,taskId,message); + +@override +String toString() { + return 'SyncEventFfi.workerFailed(taskId: $taskId, message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_WorkerFailedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_WorkerFailedCopyWith(SyncEventFfi_WorkerFailed value, $Res Function(SyncEventFfi_WorkerFailed) _then) = _$SyncEventFfi_WorkerFailedCopyWithImpl; +@useResult +$Res call({ + String taskId, String message +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_WorkerFailedCopyWithImpl<$Res> + implements $SyncEventFfi_WorkerFailedCopyWith<$Res> { + _$SyncEventFfi_WorkerFailedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_WorkerFailed _self; + final $Res Function(SyncEventFfi_WorkerFailed) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? taskId = null,Object? message = null,}) { + return _then(SyncEventFfi_WorkerFailed( +taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SyncEventFfi_TaskItemUpdated extends SyncEventFfi { + const SyncEventFfi_TaskItemUpdated({required this.taskId, required this.relativePath, required this.action, required this.status}): super._(); + + + final String taskId; + final String relativePath; + final String action; + final String status; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SyncEventFfi_TaskItemUpdatedCopyWith get copyWith => _$SyncEventFfi_TaskItemUpdatedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SyncEventFfi_TaskItemUpdated&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.relativePath, relativePath) || other.relativePath == relativePath)&&(identical(other.action, action) || other.action == action)&&(identical(other.status, status) || other.status == status)); +} + + +@override +int get hashCode => Object.hash(runtimeType,taskId,relativePath,action,status); + +@override +String toString() { + return 'SyncEventFfi.taskItemUpdated(taskId: $taskId, relativePath: $relativePath, action: $action, status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class $SyncEventFfi_TaskItemUpdatedCopyWith<$Res> implements $SyncEventFfiCopyWith<$Res> { + factory $SyncEventFfi_TaskItemUpdatedCopyWith(SyncEventFfi_TaskItemUpdated value, $Res Function(SyncEventFfi_TaskItemUpdated) _then) = _$SyncEventFfi_TaskItemUpdatedCopyWithImpl; +@useResult +$Res call({ + String taskId, String relativePath, String action, String status +}); + + + + +} +/// @nodoc +class _$SyncEventFfi_TaskItemUpdatedCopyWithImpl<$Res> + implements $SyncEventFfi_TaskItemUpdatedCopyWith<$Res> { + _$SyncEventFfi_TaskItemUpdatedCopyWithImpl(this._self, this._then); + + final SyncEventFfi_TaskItemUpdated _self; + final $Res Function(SyncEventFfi_TaskItemUpdated) _then; + +/// Create a copy of SyncEventFfi +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? taskId = null,Object? relativePath = null,Object? action = null,Object? status = null,}) { + return _then(SyncEventFfi_TaskItemUpdated( +taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as String,relativePath: null == relativePath ? _self.relativePath : relativePath // ignore: cast_nullable_to_non_nullable +as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable +as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..fb0e4bd --- /dev/null +++ b/lib/src/rust/frb_generated.dart @@ -0,0 +1,2062 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/ffi.dart'; +import 'api/ffi_types.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustSyncApi + extends + BaseEntrypoint { + @internal + static final instance = RustSyncApi._(); + + RustSyncApi._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustSyncApiApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({required RustSyncApiApi api}) { + instance.initMockImpl(api: api); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor + get apiImplConstructor => RustSyncApiApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustSyncApiWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.12.0'; + + @override + int get rustContentHash => 264517153; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'sync_core', + ioDirectory: 'native/sync-core/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustSyncApiApi extends BaseApi { + Future crateApiFfiCheckCloudAlbumDirs({ + required String baseUri, + }); + + Future crateApiFfiCreateCloudAlbumDirs({required String baseUri}); + + Future crateApiFfiDisposeSyncEngine(); + + Future crateApiFfiForceSync(); + + Future> crateApiFfiGetActiveTasks(); + + Future crateApiFfiGetActiveWorkerCount(); + + Future> crateApiFfiGetRecentTasks({required int limit}); + + Future crateApiFfiGetSyncConfig(); + + Future crateApiFfiGetSyncStatus(); + + Future> crateApiFfiGetTaskDetail({ + required String taskId, + }); + + Future crateApiFfiHydrateFile({required String localPath}); + + Future crateApiFfiInitSyncEngine({required SyncConfigFfi config}); + + Future crateApiFfiPauseSync(); + + Future> crateApiFfiQueryTaskItems({ + required TaskItemFilterFfi filter, + }); + + Stream crateApiFfiRegisterSyncEventSink(); + + Future crateApiFfiResetSync(); + + Future crateApiFfiResumeSync(); + + Future crateApiFfiSetSyncLogLevel({required String level}); + + Future crateApiFfiStartContinuousSync(); + + Future crateApiFfiStartInitialSync(); + + Future crateApiFfiStopSync(); + + Future crateApiFfiSyncAlbumToCloud({ + required List albumPaths, + required String remoteDcimUri, + }); + + Future crateApiFfiSyncShutdown(); + + Future crateApiFfiUpdateSyncConfig({required SyncConfigFfi config}); + + Future crateApiFfiUpdateTokens({required String accessToken}); +} + +class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform + implements RustSyncApiApi { + RustSyncApiApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiFfiCheckCloudAlbumDirs({ + required String baseUri, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(baseUri, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 1, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_cloud_album_check_result_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiCheckCloudAlbumDirsConstMeta, + argValues: [baseUri], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiCheckCloudAlbumDirsConstMeta => + const TaskConstMeta( + debugName: "check_cloud_album_dirs", + argNames: ["baseUri"], + ); + + @override + Future crateApiFfiCreateCloudAlbumDirs({required String baseUri}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(baseUri, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 2, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiCreateCloudAlbumDirsConstMeta, + argValues: [baseUri], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiCreateCloudAlbumDirsConstMeta => + const TaskConstMeta( + debugName: "create_cloud_album_dirs", + argNames: ["baseUri"], + ); + + @override + Future crateApiFfiDisposeSyncEngine() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiDisposeSyncEngineConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiDisposeSyncEngineConstMeta => + const TaskConstMeta(debugName: "dispose_sync_engine", argNames: []); + + @override + Future crateApiFfiForceSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 4, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_sync_summary_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiForceSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiForceSyncConstMeta => + const TaskConstMeta(debugName: "force_sync", argNames: []); + + @override + Future> crateApiFfiGetActiveTasks() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_sync_task_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetActiveTasksConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetActiveTasksConstMeta => + const TaskConstMeta(debugName: "get_active_tasks", argNames: []); + + @override + Future crateApiFfiGetActiveWorkerCount() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 6, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetActiveWorkerCountConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetActiveWorkerCountConstMeta => + const TaskConstMeta(debugName: "get_active_worker_count", argNames: []); + + @override + Future> crateApiFfiGetRecentTasks({required int limit}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(limit, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 7, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_sync_task_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetRecentTasksConstMeta, + argValues: [limit], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetRecentTasksConstMeta => + const TaskConstMeta(debugName: "get_recent_tasks", argNames: ["limit"]); + + @override + Future crateApiFfiGetSyncConfig() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 8, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_sync_config_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetSyncConfigConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetSyncConfigConstMeta => + const TaskConstMeta(debugName: "get_sync_config", argNames: []); + + @override + Future crateApiFfiGetSyncStatus() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 9, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_sync_status_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetSyncStatusConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetSyncStatusConstMeta => + const TaskConstMeta(debugName: "get_sync_status", argNames: []); + + @override + Future> crateApiFfiGetTaskDetail({ + required String taskId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(taskId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_sync_task_item_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetTaskDetailConstMeta, + argValues: [taskId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetTaskDetailConstMeta => + const TaskConstMeta(debugName: "get_task_detail", argNames: ["taskId"]); + + @override + Future crateApiFfiHydrateFile({required String localPath}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(localPath, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 11, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiHydrateFileConstMeta, + argValues: [localPath], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiHydrateFileConstMeta => + const TaskConstMeta(debugName: "hydrate_file", argNames: ["localPath"]); + + @override + Future crateApiFfiInitSyncEngine({required SyncConfigFfi config}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_sync_config_ffi(config, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiInitSyncEngineConstMeta, + argValues: [config], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiInitSyncEngineConstMeta => + const TaskConstMeta(debugName: "init_sync_engine", argNames: ["config"]); + + @override + Future crateApiFfiPauseSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 13, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiPauseSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiPauseSyncConstMeta => + const TaskConstMeta(debugName: "pause_sync", argNames: []); + + @override + Future> crateApiFfiQueryTaskItems({ + required TaskItemFilterFfi filter, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_task_item_filter_ffi(filter, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 14, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_sync_task_item_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiQueryTaskItemsConstMeta, + argValues: [filter], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiQueryTaskItemsConstMeta => + const TaskConstMeta(debugName: "query_task_items", argNames: ["filter"]); + + @override + Stream crateApiFfiRegisterSyncEventSink() { + final sink = RustStreamSink(); + unawaited( + handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_StreamSink_sync_event_ffi_Sse(sink, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 15, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiRegisterSyncEventSinkConstMeta, + argValues: [sink], + apiImpl: this, + ), + ), + ); + return sink.stream; + } + + TaskConstMeta get kCrateApiFfiRegisterSyncEventSinkConstMeta => + const TaskConstMeta( + debugName: "register_sync_event_sink", + argNames: ["sink"], + ); + + @override + Future crateApiFfiResetSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 16, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiResetSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiResetSyncConstMeta => + const TaskConstMeta(debugName: "reset_sync", argNames: []); + + @override + Future crateApiFfiResumeSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 17, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiResumeSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiResumeSyncConstMeta => + const TaskConstMeta(debugName: "resume_sync", argNames: []); + + @override + Future crateApiFfiSetSyncLogLevel({required String level}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(level, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 18, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiSetSyncLogLevelConstMeta, + argValues: [level], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiSetSyncLogLevelConstMeta => + const TaskConstMeta(debugName: "set_sync_log_level", argNames: ["level"]); + + @override + Future crateApiFfiStartContinuousSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 19, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiStartContinuousSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiStartContinuousSyncConstMeta => + const TaskConstMeta(debugName: "start_continuous_sync", argNames: []); + + @override + Future crateApiFfiStartInitialSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 20, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_sync_summary_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiStartInitialSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiStartInitialSyncConstMeta => + const TaskConstMeta(debugName: "start_initial_sync", argNames: []); + + @override + Future crateApiFfiStopSync() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 21, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiStopSyncConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiStopSyncConstMeta => + const TaskConstMeta(debugName: "stop_sync", argNames: []); + + @override + Future crateApiFfiSyncAlbumToCloud({ + required List albumPaths, + required String remoteDcimUri, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_String(albumPaths, serializer); + sse_encode_String(remoteDcimUri, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 22, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiSyncAlbumToCloudConstMeta, + argValues: [albumPaths, remoteDcimUri], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiSyncAlbumToCloudConstMeta => + const TaskConstMeta( + debugName: "sync_album_to_cloud", + argNames: ["albumPaths", "remoteDcimUri"], + ); + + @override + Future crateApiFfiSyncShutdown() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 23, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiSyncShutdownConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiSyncShutdownConstMeta => + const TaskConstMeta(debugName: "sync_shutdown", argNames: []); + + @override + Future crateApiFfiUpdateSyncConfig({required SyncConfigFfi config}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_sync_config_ffi(config, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 24, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiUpdateSyncConfigConstMeta, + argValues: [config], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiUpdateSyncConfigConstMeta => + const TaskConstMeta( + debugName: "update_sync_config", + argNames: ["config"], + ); + + @override + Future crateApiFfiUpdateTokens({required String accessToken}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(accessToken, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 25, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiUpdateTokensConstMeta, + argValues: [accessToken], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiUpdateTokensConstMeta => const TaskConstMeta( + debugName: "update_tokens", + argNames: ["accessToken"], + ); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + RustStreamSink dco_decode_StreamSink_sync_event_ffi_Sse( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_sync_config_ffi(raw); + } + + @protected + SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_sync_summary_ffi(raw); + } + + @protected + TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_task_item_filter_ffi(raw); + } + + @protected + CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return CloudAlbumCheckResultFfi( + dcimExists: dco_decode_bool(arr[0]), + picturesExists: dco_decode_bool(arr[1]), + dcimUri: dco_decode_opt_String(arr[2]), + picturesUri: dco_decode_opt_String(arr[3]), + ); + } + + @protected + PlatformInt64 dco_decode_i_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeI64(raw); + } + + @protected + List dco_decode_list_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_String).toList(); + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + List dco_decode_list_sync_task_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_sync_task_ffi).toList(); + } + + @protected + List dco_decode_list_sync_task_item_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_sync_task_item_ffi).toList(); + } + + @protected + String? dco_decode_opt_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_String(raw); + } + + @protected + SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 15) + throw Exception('unexpected arr length: expect 15 but see ${arr.length}'); + return SyncConfigFfi( + baseUrl: dco_decode_String(arr[0]), + accessToken: dco_decode_String(arr[1]), + refreshToken: dco_decode_String(arr[2]), + localRoot: dco_decode_String(arr[3]), + remoteRoot: dco_decode_String(arr[4]), + syncMode: dco_decode_String(arr[5]), + conflictStrategy: dco_decode_String(arr[6]), + wcfDeleteMode: dco_decode_String(arr[7]), + maxConcurrentTransfers: dco_decode_u_32(arr[8]), + bandwidthLimitKbps: dco_decode_u_64(arr[9]), + excludedPaths: dco_decode_list_String(arr[10]), + maxWorkers: dco_decode_u_32(arr[11]), + dataDir: dco_decode_String(arr[12]), + clientId: dco_decode_String(arr[13]), + logLevel: dco_decode_String(arr[14]), + ); + } + + @protected + SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return SyncErrorFfi_NotInitialized(); + case 1: + return SyncErrorFfi_NetworkError(message: dco_decode_String(raw[1])); + case 2: + return SyncErrorFfi_DiskFull( + needed: dco_decode_u_64(raw[1]), + available: dco_decode_u_64(raw[2]), + ); + case 3: + return SyncErrorFfi_AuthError(message: dco_decode_String(raw[1])); + case 4: + return SyncErrorFfi_ConflictError(count: dco_decode_u_32(raw[1])); + case 5: + return SyncErrorFfi_InternalError(message: dco_decode_String(raw[1])); + default: + throw Exception("unreachable"); + } + } + + @protected + SyncEventFfi dco_decode_sync_event_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return SyncEventFfi_StateChanged(newState: dco_decode_String(raw[1])); + case 1: + return SyncEventFfi_Progress( + synced: dco_decode_u_64(raw[1]), + total: dco_decode_u_64(raw[2]), + currentFile: dco_decode_String(raw[3]), + ); + case 2: + return SyncEventFfi_FileUploaded( + localPath: dco_decode_String(raw[1]), + remoteUri: dco_decode_String(raw[2]), + ); + case 3: + return SyncEventFfi_FileDownloaded( + localPath: dco_decode_String(raw[1]), + remoteUri: dco_decode_String(raw[2]), + ); + case 4: + return SyncEventFfi_ConflictDetected( + localPath: dco_decode_String(raw[1]), + conflictType: dco_decode_String(raw[2]), + ); + case 5: + return SyncEventFfi_Error( + message: dco_decode_String(raw[1]), + recoverable: dco_decode_bool(raw[2]), + ); + case 6: + return SyncEventFfi_TokenExpired(); + case 7: + return SyncEventFfi_DiskSpaceWarning( + availableMb: dco_decode_u_64(raw[1]), + ); + case 8: + return SyncEventFfi_InitialSyncComplete( + summary: dco_decode_box_autoadd_sync_summary_ffi(raw[1]), + ); + case 9: + return SyncEventFfi_WorkerStarted( + taskId: dco_decode_String(raw[1]), + trigger: dco_decode_String(raw[2]), + uploadCount: dco_decode_u_32(raw[3]), + downloadCount: dco_decode_u_32(raw[4]), + ); + case 10: + return SyncEventFfi_WorkerCompleted( + taskId: dco_decode_String(raw[1]), + uploaded: dco_decode_u_32(raw[2]), + downloaded: dco_decode_u_32(raw[3]), + renamed: dco_decode_u_32(raw[4]), + moved: dco_decode_u_32(raw[5]), + failed: dco_decode_u_32(raw[6]), + durationMs: dco_decode_u_64(raw[7]), + ); + case 11: + return SyncEventFfi_WorkerFailed( + taskId: dco_decode_String(raw[1]), + message: dco_decode_String(raw[2]), + ); + case 12: + return SyncEventFfi_TaskItemUpdated( + taskId: dco_decode_String(raw[1]), + relativePath: dco_decode_String(raw[2]), + action: dco_decode_String(raw[3]), + status: dco_decode_String(raw[4]), + ); + default: + throw Exception("unreachable"); + } + } + + @protected + SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return SyncStatusFfi( + state: dco_decode_String(arr[0]), + syncedFiles: dco_decode_u_64(arr[1]), + totalFiles: dco_decode_u_64(arr[2]), + uploadingCount: dco_decode_u_32(arr[3]), + downloadingCount: dco_decode_u_32(arr[4]), + conflictCount: dco_decode_u_32(arr[5]), + errorCount: dco_decode_u_32(arr[6]), + lastSyncTime: dco_decode_opt_String(arr[7]), + errorMessage: dco_decode_opt_String(arr[8]), + ); + } + + @protected + SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 10) + throw Exception('unexpected arr length: expect 10 but see ${arr.length}'); + return SyncSummaryFfi( + uploaded: dco_decode_u_32(arr[0]), + downloaded: dco_decode_u_32(arr[1]), + renamed: dco_decode_u_32(arr[2]), + moved: dco_decode_u_32(arr[3]), + conflicts: dco_decode_u_32(arr[4]), + failed: dco_decode_u_32(arr[5]), + skipped: dco_decode_u_32(arr[6]), + deletedLocal: dco_decode_u_32(arr[7]), + deletedRemote: dco_decode_u_32(arr[8]), + durationMs: dco_decode_u_64(arr[9]), + ); + } + + @protected + SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return SyncTaskFfi( + id: dco_decode_String(arr[0]), + trigger: dco_decode_String(arr[1]), + totalCount: dco_decode_u_32(arr[2]), + completedCount: dco_decode_u_32(arr[3]), + failedCount: dco_decode_u_32(arr[4]), + status: dco_decode_String(arr[5]), + createdAt: dco_decode_String(arr[6]), + updatedAt: dco_decode_String(arr[7]), + finishedAt: dco_decode_opt_String(arr[8]), + ); + } + + @protected + SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return SyncTaskItemFfi( + id: dco_decode_i_64(arr[0]), + taskId: dco_decode_String(arr[1]), + relativePath: dco_decode_String(arr[2]), + actionType: dco_decode_String(arr[3]), + status: dco_decode_String(arr[4]), + fileSize: dco_decode_u_64(arr[5]), + errorMessage: dco_decode_opt_String(arr[6]), + createdAt: dco_decode_String(arr[7]), + updatedAt: dco_decode_String(arr[8]), + ); + } + + @protected + TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 6) + throw Exception('unexpected arr length: expect 6 but see ${arr.length}'); + return TaskItemFilterFfi( + taskId: dco_decode_opt_String(arr[0]), + relativePathContains: dco_decode_opt_String(arr[1]), + actionType: dco_decode_opt_String(arr[2]), + status: dco_decode_opt_String(arr[3]), + limit: dco_decode_u_32(arr[4]), + offset: dco_decode_u_32(arr[5]), + ); + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + BigInt dco_decode_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + RustStreamSink sse_decode_StreamSink_sync_event_ffi_Sse( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_sync_config_ffi(deserializer)); + } + + @protected + SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_sync_summary_ffi(deserializer)); + } + + @protected + TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_task_item_filter_ffi(deserializer)); + } + + @protected + CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_dcimExists = sse_decode_bool(deserializer); + var var_picturesExists = sse_decode_bool(deserializer); + var var_dcimUri = sse_decode_opt_String(deserializer); + var var_picturesUri = sse_decode_opt_String(deserializer); + return CloudAlbumCheckResultFfi( + dcimExists: var_dcimExists, + picturesExists: var_picturesExists, + dcimUri: var_dcimUri, + picturesUri: var_picturesUri, + ); + } + + @protected + PlatformInt64 sse_decode_i_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getPlatformInt64(); + } + + @protected + List sse_decode_list_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_String(deserializer)); + } + return ans_; + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + List sse_decode_list_sync_task_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_sync_task_ffi(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_sync_task_item_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_sync_task_item_ffi(deserializer)); + } + return ans_; + } + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_String(deserializer)); + } else { + return null; + } + } + + @protected + SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_baseUrl = sse_decode_String(deserializer); + var var_accessToken = sse_decode_String(deserializer); + var var_refreshToken = sse_decode_String(deserializer); + var var_localRoot = sse_decode_String(deserializer); + var var_remoteRoot = sse_decode_String(deserializer); + var var_syncMode = sse_decode_String(deserializer); + var var_conflictStrategy = sse_decode_String(deserializer); + var var_wcfDeleteMode = sse_decode_String(deserializer); + var var_maxConcurrentTransfers = sse_decode_u_32(deserializer); + var var_bandwidthLimitKbps = sse_decode_u_64(deserializer); + var var_excludedPaths = sse_decode_list_String(deserializer); + var var_maxWorkers = sse_decode_u_32(deserializer); + var var_dataDir = sse_decode_String(deserializer); + var var_clientId = sse_decode_String(deserializer); + var var_logLevel = sse_decode_String(deserializer); + return SyncConfigFfi( + baseUrl: var_baseUrl, + accessToken: var_accessToken, + refreshToken: var_refreshToken, + localRoot: var_localRoot, + remoteRoot: var_remoteRoot, + syncMode: var_syncMode, + conflictStrategy: var_conflictStrategy, + wcfDeleteMode: var_wcfDeleteMode, + maxConcurrentTransfers: var_maxConcurrentTransfers, + bandwidthLimitKbps: var_bandwidthLimitKbps, + excludedPaths: var_excludedPaths, + maxWorkers: var_maxWorkers, + dataDir: var_dataDir, + clientId: var_clientId, + logLevel: var_logLevel, + ); + } + + @protected + SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return SyncErrorFfi_NotInitialized(); + case 1: + var var_message = sse_decode_String(deserializer); + return SyncErrorFfi_NetworkError(message: var_message); + case 2: + var var_needed = sse_decode_u_64(deserializer); + var var_available = sse_decode_u_64(deserializer); + return SyncErrorFfi_DiskFull( + needed: var_needed, + available: var_available, + ); + case 3: + var var_message = sse_decode_String(deserializer); + return SyncErrorFfi_AuthError(message: var_message); + case 4: + var var_count = sse_decode_u_32(deserializer); + return SyncErrorFfi_ConflictError(count: var_count); + case 5: + var var_message = sse_decode_String(deserializer); + return SyncErrorFfi_InternalError(message: var_message); + default: + throw UnimplementedError(''); + } + } + + @protected + SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + var var_newState = sse_decode_String(deserializer); + return SyncEventFfi_StateChanged(newState: var_newState); + case 1: + var var_synced = sse_decode_u_64(deserializer); + var var_total = sse_decode_u_64(deserializer); + var var_currentFile = sse_decode_String(deserializer); + return SyncEventFfi_Progress( + synced: var_synced, + total: var_total, + currentFile: var_currentFile, + ); + case 2: + var var_localPath = sse_decode_String(deserializer); + var var_remoteUri = sse_decode_String(deserializer); + return SyncEventFfi_FileUploaded( + localPath: var_localPath, + remoteUri: var_remoteUri, + ); + case 3: + var var_localPath = sse_decode_String(deserializer); + var var_remoteUri = sse_decode_String(deserializer); + return SyncEventFfi_FileDownloaded( + localPath: var_localPath, + remoteUri: var_remoteUri, + ); + case 4: + var var_localPath = sse_decode_String(deserializer); + var var_conflictType = sse_decode_String(deserializer); + return SyncEventFfi_ConflictDetected( + localPath: var_localPath, + conflictType: var_conflictType, + ); + case 5: + var var_message = sse_decode_String(deserializer); + var var_recoverable = sse_decode_bool(deserializer); + return SyncEventFfi_Error( + message: var_message, + recoverable: var_recoverable, + ); + case 6: + return SyncEventFfi_TokenExpired(); + case 7: + var var_availableMb = sse_decode_u_64(deserializer); + return SyncEventFfi_DiskSpaceWarning(availableMb: var_availableMb); + case 8: + var var_summary = sse_decode_box_autoadd_sync_summary_ffi(deserializer); + return SyncEventFfi_InitialSyncComplete(summary: var_summary); + case 9: + var var_taskId = sse_decode_String(deserializer); + var var_trigger = sse_decode_String(deserializer); + var var_uploadCount = sse_decode_u_32(deserializer); + var var_downloadCount = sse_decode_u_32(deserializer); + return SyncEventFfi_WorkerStarted( + taskId: var_taskId, + trigger: var_trigger, + uploadCount: var_uploadCount, + downloadCount: var_downloadCount, + ); + case 10: + var var_taskId = sse_decode_String(deserializer); + var var_uploaded = sse_decode_u_32(deserializer); + var var_downloaded = sse_decode_u_32(deserializer); + var var_renamed = sse_decode_u_32(deserializer); + var var_moved = sse_decode_u_32(deserializer); + var var_failed = sse_decode_u_32(deserializer); + var var_durationMs = sse_decode_u_64(deserializer); + return SyncEventFfi_WorkerCompleted( + taskId: var_taskId, + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + failed: var_failed, + durationMs: var_durationMs, + ); + case 11: + var var_taskId = sse_decode_String(deserializer); + var var_message = sse_decode_String(deserializer); + return SyncEventFfi_WorkerFailed( + taskId: var_taskId, + message: var_message, + ); + case 12: + var var_taskId = sse_decode_String(deserializer); + var var_relativePath = sse_decode_String(deserializer); + var var_action = sse_decode_String(deserializer); + var var_status = sse_decode_String(deserializer); + return SyncEventFfi_TaskItemUpdated( + taskId: var_taskId, + relativePath: var_relativePath, + action: var_action, + status: var_status, + ); + default: + throw UnimplementedError(''); + } + } + + @protected + SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_state = sse_decode_String(deserializer); + var var_syncedFiles = sse_decode_u_64(deserializer); + var var_totalFiles = sse_decode_u_64(deserializer); + var var_uploadingCount = sse_decode_u_32(deserializer); + var var_downloadingCount = sse_decode_u_32(deserializer); + var var_conflictCount = sse_decode_u_32(deserializer); + var var_errorCount = sse_decode_u_32(deserializer); + var var_lastSyncTime = sse_decode_opt_String(deserializer); + var var_errorMessage = sse_decode_opt_String(deserializer); + return SyncStatusFfi( + state: var_state, + syncedFiles: var_syncedFiles, + totalFiles: var_totalFiles, + uploadingCount: var_uploadingCount, + downloadingCount: var_downloadingCount, + conflictCount: var_conflictCount, + errorCount: var_errorCount, + lastSyncTime: var_lastSyncTime, + errorMessage: var_errorMessage, + ); + } + + @protected + SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_uploaded = sse_decode_u_32(deserializer); + var var_downloaded = sse_decode_u_32(deserializer); + var var_renamed = sse_decode_u_32(deserializer); + var var_moved = sse_decode_u_32(deserializer); + var var_conflicts = sse_decode_u_32(deserializer); + var var_failed = sse_decode_u_32(deserializer); + var var_skipped = sse_decode_u_32(deserializer); + var var_deletedLocal = sse_decode_u_32(deserializer); + var var_deletedRemote = sse_decode_u_32(deserializer); + var var_durationMs = sse_decode_u_64(deserializer); + return SyncSummaryFfi( + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + conflicts: var_conflicts, + failed: var_failed, + skipped: var_skipped, + deletedLocal: var_deletedLocal, + deletedRemote: var_deletedRemote, + durationMs: var_durationMs, + ); + } + + @protected + SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_String(deserializer); + var var_trigger = sse_decode_String(deserializer); + var var_totalCount = sse_decode_u_32(deserializer); + var var_completedCount = sse_decode_u_32(deserializer); + var var_failedCount = sse_decode_u_32(deserializer); + var var_status = sse_decode_String(deserializer); + var var_createdAt = sse_decode_String(deserializer); + var var_updatedAt = sse_decode_String(deserializer); + var var_finishedAt = sse_decode_opt_String(deserializer); + return SyncTaskFfi( + id: var_id, + trigger: var_trigger, + totalCount: var_totalCount, + completedCount: var_completedCount, + failedCount: var_failedCount, + status: var_status, + createdAt: var_createdAt, + updatedAt: var_updatedAt, + finishedAt: var_finishedAt, + ); + } + + @protected + SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_i_64(deserializer); + var var_taskId = sse_decode_String(deserializer); + var var_relativePath = sse_decode_String(deserializer); + var var_actionType = sse_decode_String(deserializer); + var var_status = sse_decode_String(deserializer); + var var_fileSize = sse_decode_u_64(deserializer); + var var_errorMessage = sse_decode_opt_String(deserializer); + var var_createdAt = sse_decode_String(deserializer); + var var_updatedAt = sse_decode_String(deserializer); + return SyncTaskItemFfi( + id: var_id, + taskId: var_taskId, + relativePath: var_relativePath, + actionType: var_actionType, + status: var_status, + fileSize: var_fileSize, + errorMessage: var_errorMessage, + createdAt: var_createdAt, + updatedAt: var_updatedAt, + ); + } + + @protected + TaskItemFilterFfi sse_decode_task_item_filter_ffi( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_taskId = sse_decode_opt_String(deserializer); + var var_relativePathContains = sse_decode_opt_String(deserializer); + var var_actionType = sse_decode_opt_String(deserializer); + var var_status = sse_decode_opt_String(deserializer); + var var_limit = sse_decode_u_32(deserializer); + var var_offset = sse_decode_u_32(deserializer); + return TaskItemFilterFfi( + taskId: var_taskId, + relativePathContains: var_relativePathContains, + actionType: var_actionType, + status: var_status, + limit: var_limit, + offset: var_offset, + ); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void sse_encode_StreamSink_sync_event_ffi_Sse( + RustStreamSink self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_sync_event_ffi, + decodeErrorData: sse_decode_AnyhowException, + ), + ), + serializer, + ); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_box_autoadd_sync_config_ffi( + SyncConfigFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_sync_config_ffi(self, serializer); + } + + @protected + void sse_encode_box_autoadd_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_sync_summary_ffi(self, serializer); + } + + @protected + void sse_encode_box_autoadd_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_task_item_filter_ffi(self, serializer); + } + + @protected + void sse_encode_cloud_album_check_result_ffi( + CloudAlbumCheckResultFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bool(self.dcimExists, serializer); + sse_encode_bool(self.picturesExists, serializer); + sse_encode_opt_String(self.dcimUri, serializer); + sse_encode_opt_String(self.picturesUri, serializer); + } + + @protected + void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putPlatformInt64(self); + } + + @protected + void sse_encode_list_String(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_String(item, serializer); + } + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_list_sync_task_ffi( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_sync_task_ffi(item, serializer); + } + } + + @protected + void sse_encode_list_sync_task_item_ffi( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_sync_task_item_ffi(item, serializer); + } + } + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_String(self, serializer); + } + } + + @protected + void sse_encode_sync_config_ffi( + SyncConfigFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.baseUrl, serializer); + sse_encode_String(self.accessToken, serializer); + sse_encode_String(self.refreshToken, serializer); + sse_encode_String(self.localRoot, serializer); + sse_encode_String(self.remoteRoot, serializer); + sse_encode_String(self.syncMode, serializer); + sse_encode_String(self.conflictStrategy, serializer); + sse_encode_String(self.wcfDeleteMode, serializer); + sse_encode_u_32(self.maxConcurrentTransfers, serializer); + sse_encode_u_64(self.bandwidthLimitKbps, serializer); + sse_encode_list_String(self.excludedPaths, serializer); + sse_encode_u_32(self.maxWorkers, serializer); + sse_encode_String(self.dataDir, serializer); + sse_encode_String(self.clientId, serializer); + sse_encode_String(self.logLevel, serializer); + } + + @protected + void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case SyncErrorFfi_NotInitialized(): + sse_encode_i_32(0, serializer); + case SyncErrorFfi_NetworkError(message: final message): + sse_encode_i_32(1, serializer); + sse_encode_String(message, serializer); + case SyncErrorFfi_DiskFull( + needed: final needed, + available: final available, + ): + sse_encode_i_32(2, serializer); + sse_encode_u_64(needed, serializer); + sse_encode_u_64(available, serializer); + case SyncErrorFfi_AuthError(message: final message): + sse_encode_i_32(3, serializer); + sse_encode_String(message, serializer); + case SyncErrorFfi_ConflictError(count: final count): + sse_encode_i_32(4, serializer); + sse_encode_u_32(count, serializer); + case SyncErrorFfi_InternalError(message: final message): + sse_encode_i_32(5, serializer); + sse_encode_String(message, serializer); + } + } + + @protected + void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case SyncEventFfi_StateChanged(newState: final newState): + sse_encode_i_32(0, serializer); + sse_encode_String(newState, serializer); + case SyncEventFfi_Progress( + synced: final synced, + total: final total, + currentFile: final currentFile, + ): + sse_encode_i_32(1, serializer); + sse_encode_u_64(synced, serializer); + sse_encode_u_64(total, serializer); + sse_encode_String(currentFile, serializer); + case SyncEventFfi_FileUploaded( + localPath: final localPath, + remoteUri: final remoteUri, + ): + sse_encode_i_32(2, serializer); + sse_encode_String(localPath, serializer); + sse_encode_String(remoteUri, serializer); + case SyncEventFfi_FileDownloaded( + localPath: final localPath, + remoteUri: final remoteUri, + ): + sse_encode_i_32(3, serializer); + sse_encode_String(localPath, serializer); + sse_encode_String(remoteUri, serializer); + case SyncEventFfi_ConflictDetected( + localPath: final localPath, + conflictType: final conflictType, + ): + sse_encode_i_32(4, serializer); + sse_encode_String(localPath, serializer); + sse_encode_String(conflictType, serializer); + case SyncEventFfi_Error( + message: final message, + recoverable: final recoverable, + ): + sse_encode_i_32(5, serializer); + sse_encode_String(message, serializer); + sse_encode_bool(recoverable, serializer); + case SyncEventFfi_TokenExpired(): + sse_encode_i_32(6, serializer); + case SyncEventFfi_DiskSpaceWarning(availableMb: final availableMb): + sse_encode_i_32(7, serializer); + sse_encode_u_64(availableMb, serializer); + case SyncEventFfi_InitialSyncComplete(summary: final summary): + sse_encode_i_32(8, serializer); + sse_encode_box_autoadd_sync_summary_ffi(summary, serializer); + case SyncEventFfi_WorkerStarted( + taskId: final taskId, + trigger: final trigger, + uploadCount: final uploadCount, + downloadCount: final downloadCount, + ): + sse_encode_i_32(9, serializer); + sse_encode_String(taskId, serializer); + sse_encode_String(trigger, serializer); + sse_encode_u_32(uploadCount, serializer); + sse_encode_u_32(downloadCount, serializer); + case SyncEventFfi_WorkerCompleted( + taskId: final taskId, + uploaded: final uploaded, + downloaded: final downloaded, + renamed: final renamed, + moved: final moved, + failed: final failed, + durationMs: final durationMs, + ): + sse_encode_i_32(10, serializer); + sse_encode_String(taskId, serializer); + sse_encode_u_32(uploaded, serializer); + sse_encode_u_32(downloaded, serializer); + sse_encode_u_32(renamed, serializer); + sse_encode_u_32(moved, serializer); + sse_encode_u_32(failed, serializer); + sse_encode_u_64(durationMs, serializer); + case SyncEventFfi_WorkerFailed( + taskId: final taskId, + message: final message, + ): + sse_encode_i_32(11, serializer); + sse_encode_String(taskId, serializer); + sse_encode_String(message, serializer); + case SyncEventFfi_TaskItemUpdated( + taskId: final taskId, + relativePath: final relativePath, + action: final action, + status: final status, + ): + sse_encode_i_32(12, serializer); + sse_encode_String(taskId, serializer); + sse_encode_String(relativePath, serializer); + sse_encode_String(action, serializer); + sse_encode_String(status, serializer); + } + } + + @protected + void sse_encode_sync_status_ffi( + SyncStatusFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.state, serializer); + sse_encode_u_64(self.syncedFiles, serializer); + sse_encode_u_64(self.totalFiles, serializer); + sse_encode_u_32(self.uploadingCount, serializer); + sse_encode_u_32(self.downloadingCount, serializer); + sse_encode_u_32(self.conflictCount, serializer); + sse_encode_u_32(self.errorCount, serializer); + sse_encode_opt_String(self.lastSyncTime, serializer); + sse_encode_opt_String(self.errorMessage, serializer); + } + + @protected + void sse_encode_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_32(self.uploaded, serializer); + sse_encode_u_32(self.downloaded, serializer); + sse_encode_u_32(self.renamed, serializer); + sse_encode_u_32(self.moved, serializer); + sse_encode_u_32(self.conflicts, serializer); + sse_encode_u_32(self.failed, serializer); + sse_encode_u_32(self.skipped, serializer); + sse_encode_u_32(self.deletedLocal, serializer); + sse_encode_u_32(self.deletedRemote, serializer); + sse_encode_u_64(self.durationMs, serializer); + } + + @protected + void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.id, serializer); + sse_encode_String(self.trigger, serializer); + sse_encode_u_32(self.totalCount, serializer); + sse_encode_u_32(self.completedCount, serializer); + sse_encode_u_32(self.failedCount, serializer); + sse_encode_String(self.status, serializer); + sse_encode_String(self.createdAt, serializer); + sse_encode_String(self.updatedAt, serializer); + sse_encode_opt_String(self.finishedAt, serializer); + } + + @protected + void sse_encode_sync_task_item_ffi( + SyncTaskItemFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_64(self.id, serializer); + sse_encode_String(self.taskId, serializer); + sse_encode_String(self.relativePath, serializer); + sse_encode_String(self.actionType, serializer); + sse_encode_String(self.status, serializer); + sse_encode_u_64(self.fileSize, serializer); + sse_encode_opt_String(self.errorMessage, serializer); + sse_encode_String(self.createdAt, serializer); + sse_encode_String(self.updatedAt, serializer); + } + + @protected + void sse_encode_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_opt_String(self.taskId, serializer); + sse_encode_opt_String(self.relativePathContains, serializer); + sse_encode_opt_String(self.actionType, serializer); + sse_encode_opt_String(self.status, serializer); + sse_encode_u_32(self.limit, serializer); + sse_encode_u_32(self.offset, serializer); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } +} diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart new file mode 100644 index 0000000..c02cdfc --- /dev/null +++ b/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,328 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/ffi.dart'; +import 'api/ffi_types.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { + RustSyncApiApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_sync_event_ffi_Sse( + dynamic raw, + ); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw); + + @protected + SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw); + + @protected + TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw); + + @protected + CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw); + + @protected + PlatformInt64 dco_decode_i_64(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_sync_task_ffi(dynamic raw); + + @protected + List dco_decode_list_sync_task_item_ffi(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); + + @protected + SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); + + @protected + SyncEventFfi dco_decode_sync_event_ffi(dynamic raw); + + @protected + SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw); + + @protected + SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw); + + @protected + SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw); + + @protected + SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw); + + @protected + TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_sync_event_ffi_Sse( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi( + SseDeserializer deserializer, + ); + + @protected + SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi( + SseDeserializer deserializer, + ); + + @protected + TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi( + SseDeserializer deserializer, + ); + + @protected + CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi( + SseDeserializer deserializer, + ); + + @protected + PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_sync_task_ffi(SseDeserializer deserializer); + + @protected + List sse_decode_list_sync_task_item_ffi( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); + + @protected + SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); + + @protected + SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer); + + @protected + SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer); + + @protected + SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer); + + @protected + SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer); + + @protected + SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer); + + @protected + TaskItemFilterFfi sse_decode_task_item_filter_ffi( + SseDeserializer deserializer, + ); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_sync_event_ffi_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_sync_config_ffi( + SyncConfigFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_cloud_album_check_result_ffi( + CloudAlbumCheckResultFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_sync_task_ffi( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_sync_task_item_ffi( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_task_item_ffi( + SyncTaskItemFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustSyncApiWire implements BaseWire { + factory RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib) => + RustSyncApiWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustSyncApiWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart new file mode 100644 index 0000000..7188d14 --- /dev/null +++ b/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,328 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api/ffi.dart'; +import 'api/ffi_types.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { + RustSyncApiApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_sync_event_ffi_Sse( + dynamic raw, + ); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw); + + @protected + SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw); + + @protected + TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw); + + @protected + CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw); + + @protected + PlatformInt64 dco_decode_i_64(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_sync_task_ffi(dynamic raw); + + @protected + List dco_decode_list_sync_task_item_ffi(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); + + @protected + SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); + + @protected + SyncEventFfi dco_decode_sync_event_ffi(dynamic raw); + + @protected + SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw); + + @protected + SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw); + + @protected + SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw); + + @protected + SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw); + + @protected + TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_sync_event_ffi_Sse( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi( + SseDeserializer deserializer, + ); + + @protected + SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi( + SseDeserializer deserializer, + ); + + @protected + TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi( + SseDeserializer deserializer, + ); + + @protected + CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi( + SseDeserializer deserializer, + ); + + @protected + PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_sync_task_ffi(SseDeserializer deserializer); + + @protected + List sse_decode_list_sync_task_item_ffi( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); + + @protected + SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); + + @protected + SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer); + + @protected + SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer); + + @protected + SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer); + + @protected + SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer); + + @protected + SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer); + + @protected + TaskItemFilterFfi sse_decode_task_item_filter_ffi( + SseDeserializer deserializer, + ); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_sync_event_ffi_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_sync_config_ffi( + SyncConfigFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_cloud_album_check_result_ffi( + CloudAlbumCheckResultFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_sync_task_ffi( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_sync_task_item_ffi( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_summary_ffi( + SyncSummaryFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer); + + @protected + void sse_encode_sync_task_item_ffi( + SyncTaskItemFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_task_item_filter_ffi( + TaskItemFilterFfi self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustSyncApiWire implements BaseWire { + RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustSyncApiWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustSyncApiWasmModule._(JSObject _) implements JSObject {} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index e5e61b4..3076e8a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -126,3 +126,36 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() + +# === Rust Sync Engine === +find_program(CARGO_CMD cargo) +if(NOT CARGO_CMD) + message(WARNING "cargo not found, skipping Rust sync engine build") +else() + set(SYNC_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../native") + + if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "Profile") + set(CARGO_PROFILE "--release") + set(RUST_BUILD_DIR "release") + else() + set(CARGO_PROFILE "") + set(RUST_BUILD_DIR "debug") + endif() + + set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_target") + + add_custom_command( + OUTPUT "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so" + COMMAND ${CARGO_CMD} build ${CARGO_PROFILE} + --manifest-path "${SYNC_CORE_DIR}/Cargo.toml" + --target-dir "${RUST_TARGET_DIR}" + COMMENT "Building Rust sync engine..." + ) + + add_custom_target(sync_core_build DEPENDS "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so") + add_dependencies(${BINARY_NAME} sync_core_build) + + install(FILES "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..1fa9f1f --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,3056 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flutter_rust_bridge" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "java-locator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f" +dependencies = [ + "glob", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "java-locator", + "jni-sys 0.3.1", + "libloading", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify 0.11.1", + "kqueue", + "libc", + "log", + "mio", + "notify-types 2.1.0", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" +dependencies = [ + "file-id", + "log", + "notify 8.2.0", + "notify-types 2.1.0", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap 5.5.3", + "log", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync-android" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "jni", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "sync-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "dashmap 6.1.0", + "filetime", + "flutter_rust_bridge", + "futures", + "futures-util", + "hex", + "jni", + "mime_guess", + "notify-debouncer-full", + "once_cell", + "parking_lot", + "r2d2", + "r2d2_sqlite", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "sync-android", + "sync-linux", + "sync-windows", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", + "walkdir", + "windows", +] + +[[package]] +name = "sync-linux" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "notify 7.0.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "walkdir", +] + +[[package]] +name = "sync-windows" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "windows", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "rand 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..b9c9568 --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = [ + "sync-core", + "sync-windows", + "sync-linux", + "sync-android", +] + +[workspace.dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +parking_lot = "0.12" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/native/frb.yaml b/native/frb.yaml new file mode 100644 index 0000000..2b3c27d --- /dev/null +++ b/native/frb.yaml @@ -0,0 +1,5 @@ +rust_input: crate::api +rust_root: sync-core/ +dart_output: D:/flutter_projects/cloudreve4_flutter/lib/src/rust/ +dart_entrypoint_class_name: RustSyncApi +dart_enums_style: true diff --git a/native/sync-android/Cargo.toml b/native/sync-android/Cargo.toml new file mode 100644 index 0000000..916a5cb --- /dev/null +++ b/native/sync-android/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sync-android" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib"] + +[dependencies] +# 不依赖 sync-core,避免循环依赖 +jni = { version = "0.21", features = ["invocation"] } +thiserror = { workspace = true } +tracing = "0.1" +anyhow = { workspace = true } +tokio = { workspace = true } +async-trait = "0.1" diff --git a/native/sync-android/src/lib.rs b/native/sync-android/src/lib.rs new file mode 100644 index 0000000..35f4a5c --- /dev/null +++ b/native/sync-android/src/lib.rs @@ -0,0 +1,106 @@ +#![cfg(target_os = "android")] + +//! Android 平台适配层 +//! +//! 提供相册同步辅助功能: +//! - 文件类型过滤(仅图片/视频) +//! - 路径验证 +//! +//! 实际的扫描由 Dart 侧通过平台通道完成, +//! 上传和状态管理由 sync-core 的 sync_album 处理 + +use std::path::Path; + +/// 图片扩展名 +const IMAGE_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif", "raw", "tiff", "svg", +]; + +/// 视频扩展名 +const VIDEO_EXTENSIONS: &[&str] = &[ + "mp4", "mov", "avi", "mkv", "wmv", "flv", "3gp", "webm", +]; + +pub struct AndroidAdapter; + +impl Default for AndroidAdapter { + fn default() -> Self { + Self::new() + } +} + +impl AndroidAdapter { + pub fn new() -> Self { + Self + } + + /// 判断文件是否为媒体文件(图片或视频) + pub fn is_media_file(path: &str) -> bool { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) + } + + /// 判断文件是否为图片 + pub fn is_image_file(path: &str) -> bool { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + IMAGE_EXTENSIONS.contains(&ext.as_str()) + } + + /// 判断文件是否为视频 + pub fn is_video_file(path: &str) -> bool { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + VIDEO_EXTENSIONS.contains(&ext.as_str()) + } + + /// 从路径列表中过滤出媒体文件 + pub fn filter_media_files(paths: &[String]) -> Vec { + paths.iter() + .filter(|p| Self::is_media_file(p)) + .cloned() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_media_file() { + assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.jpg")); + assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.JPEG")); + assert!(AndroidAdapter::is_video_file("/sdcard/DCIM/VID_001.mp4")); + assert!(!AndroidAdapter::is_media_file("/sdcard/Download/doc.pdf")); + assert!(AndroidAdapter::is_media_file("/sdcard/Pictures/photo.webp")); + assert!(AndroidAdapter::is_media_file("/sdcard/DCIM/photo.heic")); + } + + #[test] + fn test_filter_media_files() { + let paths = vec![ + "/sdcard/DCIM/IMG_001.jpg".to_string(), + "/sdcard/Download/doc.pdf".to_string(), + "/sdcard/DCIM/VID_001.mp4".to_string(), + "/sdcard/Music/song.mp3".to_string(), + ]; + let filtered = AndroidAdapter::filter_media_files(&paths); + assert_eq!(filtered.len(), 2); + assert!(filtered.contains(&"/sdcard/DCIM/IMG_001.jpg".to_string())); + assert!(filtered.contains(&"/sdcard/DCIM/VID_001.mp4".to_string())); + } +} diff --git a/native/sync-core/Cargo.toml b/native/sync-core/Cargo.toml new file mode 100644 index 0000000..96ca817 --- /dev/null +++ b/native/sync-core/Cargo.toml @@ -0,0 +1,83 @@ +[package] +name = "sync-core" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +# FRB +flutter_rust_bridge = "=2.12.0" + +# 异步运行时 +tokio = { workspace = true } + +# HTTP +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } + +# 序列化 +serde = { workspace = true } +serde_json = { workspace = true } + +# 数据库 +rusqlite = { version = "0.32", features = ["bundled"] } +r2d2 = "0.8" +r2d2_sqlite = "0.25" + +# 文件系统 +walkdir = "2" +sha2 = "0.10" +hex = "0.4" +notify-debouncer-full = "0.7.0" + +# 错误处理 +thiserror = { workspace = true } +anyhow = { workspace = true } + +# 日志 +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# 并发 +dashmap = "6" + +# 工具 +uuid = { workspace = true } +chrono = { workspace = true } +parking_lot = { workspace = true } +futures = "0.3" +futures-util = "0.3" +bytes = "1" +filetime = "0.2" +async-trait = "0.1" +url = "2" +urlencoding = "2" +mime_guess = "2" +once_cell = "1" +tokio-util = { version = "0.7", features = ["compat"] } + +# SSE +# (已移除 eventsource 0.5 依赖,使用内联 SSE 解析器,避免旧版 tokio/net2 警告) + +# 平台依赖 (feature-gated) +[target.'cfg(windows)'.dependencies] +sync-windows = { path = "../sync-windows", optional = true } +windows = { version = "0.58", optional = true } + +[target.'cfg(target_os = "linux")'.dependencies] +sync-linux = { path = "../sync-linux", optional = true } + +[target.'cfg(target_os = "android")'.dependencies] +sync-android = { path = "../sync-android", optional = true } +jni = { version = "0.21", optional = true } + +[features] +default = [] +windows-cfapi = ["sync-windows", "windows"] +linux-notify = ["sync-linux"] +android-media = ["sync-android", "jni"] +event_sink_enabled = [] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } diff --git a/native/sync-core/src/api/ffi.rs b/native/sync-core/src/api/ffi.rs new file mode 100644 index 0000000..3e16de2 --- /dev/null +++ b/native/sync-core/src/api/ffi.rs @@ -0,0 +1,622 @@ +use flutter_rust_bridge::frb; +use std::sync::Arc; + +use crate::api::ffi_types::*; +use crate::sync_engine::SyncEngine; + +/// 全局引擎实例 +static ENGINE: once_cell::sync::OnceCell> = once_cell::sync::OnceCell::new(); + +/// 全局日志级别重载句柄(支持运行时热修改) +static LOG_RELOAD_HANDLE: once_cell::sync::OnceCell< + tracing_subscriber::reload::Handle, +> = once_cell::sync::OnceCell::new(); + +// 内部类型 -> FFI 类型的转换函数 + +fn error_to_ffi(e: crate::errors::SyncError) -> SyncErrorFfi { + match e { + crate::errors::SyncError::Network(msg) => SyncErrorFfi::NetworkError { message: msg }, + crate::errors::SyncError::DiskFull { needed, available } => { + SyncErrorFfi::DiskFull { needed, available } + } + crate::errors::SyncError::Auth(msg) => SyncErrorFfi::AuthError { message: msg }, + crate::errors::SyncError::Conflict { count } => SyncErrorFfi::ConflictError { count }, + crate::errors::SyncError::NotInitialized => SyncErrorFfi::NotInitialized, + _ => SyncErrorFfi::InternalError { + message: e.to_string(), + }, + } +} + +fn config_from_ffi(ffi: SyncConfigFfi) -> crate::models::SyncConfig { + use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode}; + use std::path::PathBuf; + + let sync_mode = match ffi.sync_mode.as_str() { + "upload_only" => SyncMode::UploadOnly, + "download_only" => SyncMode::DownloadOnly, + "album" => SyncMode::Album, + "mirror_wcf" => SyncMode::MirrorWcf, + _ => SyncMode::Full, + }; + + let conflict_strategy = match ffi.conflict_strategy.as_str() { + "keep_local" => ConflictStrategy::KeepLocal, + "keep_remote" => ConflictStrategy::KeepRemote, + "newest_wins" => ConflictStrategy::NewestWins, + "largest_wins" => ConflictStrategy::LargestWins, + "manual" => ConflictStrategy::Manual, + _ => ConflictStrategy::KeepBoth, + }; + + let wcf_delete_mode = match ffi.wcf_delete_mode.as_str() { + "wcf_delete_sync_remote" => WcfDeleteMode::SyncRemote, + _ => WcfDeleteMode::LocalOnly, + }; + + let bandwidth_limit = if ffi.bandwidth_limit_kbps > 0 { + Some(ffi.bandwidth_limit_kbps * 1024) + } else { + None + }; + + crate::models::SyncConfig { + base_url: ffi.base_url, + access_token: ffi.access_token, + refresh_token: ffi.refresh_token, + local_root: PathBuf::from(&ffi.local_root), + remote_root: ffi.remote_root, + sync_mode, + conflict_strategy, + wcf_delete_mode, + max_concurrent_transfers: ffi.max_concurrent_transfers as usize, + bandwidth_limit, + excluded_paths: ffi.excluded_paths, + max_workers: ffi.max_workers as usize, + data_dir: PathBuf::from(&ffi.data_dir), + client_id: ffi.client_id, + } +} + +fn config_to_ffi(c: &crate::models::SyncConfig) -> SyncConfigFfi { + use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode}; + + let sync_mode = match c.sync_mode { + SyncMode::Full => "full", + SyncMode::UploadOnly => "upload_only", + SyncMode::DownloadOnly => "download_only", + SyncMode::Album => "album", + SyncMode::MirrorWcf => "mirror_wcf", + }; + + let conflict_strategy = match c.conflict_strategy { + ConflictStrategy::KeepLocal => "keep_local", + ConflictStrategy::KeepRemote => "keep_remote", + ConflictStrategy::KeepBoth => "keep_both", + ConflictStrategy::NewestWins => "newest_wins", + ConflictStrategy::LargestWins => "largest_wins", + ConflictStrategy::Manual => "manual", + }; + + let wcf_delete_mode = match c.wcf_delete_mode { + WcfDeleteMode::LocalOnly => "wcf_delete_local_only", + WcfDeleteMode::SyncRemote => "wcf_delete_sync_remote", + }; + + SyncConfigFfi { + base_url: c.base_url.clone(), + access_token: c.access_token.clone(), + refresh_token: c.refresh_token.clone(), + local_root: c.local_root.to_string_lossy().to_string(), + remote_root: c.remote_root.clone(), + sync_mode: sync_mode.to_string(), + conflict_strategy: conflict_strategy.to_string(), + wcf_delete_mode: wcf_delete_mode.to_string(), + max_concurrent_transfers: c.max_concurrent_transfers as u32, + bandwidth_limit_kbps: c.bandwidth_limit.map(|b| b / 1024).unwrap_or(0), + excluded_paths: c.excluded_paths.clone(), + max_workers: c.max_workers as u32, + data_dir: c.data_dir.to_string_lossy().to_string(), + client_id: c.client_id.clone(), + log_level: String::new(), + } +} + +fn status_to_ffi(s: crate::models::SyncStatusSnapshot) -> SyncStatusFfi { + let error_msg = if let crate::models::SyncState::Error { ref message } = s.state { + Some(message.clone()) + } else { + s.error_message + }; + + let state = match s.state { + crate::models::SyncState::Idle => "idle".to_string(), + crate::models::SyncState::Initializing => "initializing".to_string(), + crate::models::SyncState::InitialSync { .. } => "initialSync".to_string(), + crate::models::SyncState::Continuous => "continuous".to_string(), + crate::models::SyncState::Paused => "paused".to_string(), + crate::models::SyncState::Error { .. } => "error".to_string(), + crate::models::SyncState::Stopped => "stopped".to_string(), + }; + + SyncStatusFfi { + state, + synced_files: s.synced_files, + total_files: s.total_files, + uploading_count: s.uploading_count, + downloading_count: s.downloading_count, + conflict_count: s.conflict_count, + error_count: s.error_count, + last_sync_time: s.last_sync_time, + error_message: error_msg, + } +} + +fn summary_to_ffi(s: crate::models::SyncSummary) -> SyncSummaryFfi { + SyncSummaryFfi { + uploaded: s.uploaded, + downloaded: s.downloaded, + renamed: s.renamed, + moved: s.moved, + conflicts: s.conflicts, + failed: s.failed, + skipped: s.skipped, + deleted_local: s.deleted_local, + deleted_remote: s.deleted_remote, + duration_ms: s.duration_ms, + } +} + +fn album_result_to_ffi(r: crate::models::CloudAlbumCheckResult) -> CloudAlbumCheckResultFfi { + CloudAlbumCheckResultFfi { + dcim_exists: r.dcim_exists, + pictures_exists: r.pictures_exists, + dcim_uri: r.dcim_uri, + pictures_uri: r.pictures_uri, + } +} + +/// 获取引擎引用,未初始化则返回错误 +fn get_engine() -> Result<&'static SyncEngine, SyncErrorFfi> { + ENGINE.get().map(|arc| arc.as_ref()).ok_or(SyncErrorFfi::NotInitialized) +} + +/// 内部:应用日志级别到 reload handle +fn apply_log_level(level: &str) { + if level.is_empty() { + return; + } + if let Some(handle) = LOG_RELOAD_HANDLE.get() { + let directive = format!("sync_core={}", level); + match handle.modify(|filter| { + *filter = tracing_subscriber::EnvFilter::new(&directive); + }) { + Ok(()) => eprintln!("[sync-core] 日志级别已切换为: {}", level), + Err(e) => eprintln!("[sync-core] 修改日志级别失败: {}", e), + } + } +} + +// ========== 生命周期 ========== + +/// 初始化同步引擎 +#[frb] +pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> { + eprintln!("[FFI] init_sync_engine ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps, log_level={}", + config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, + config.bandwidth_limit_kbps, config.log_level); + + // 确保本地同步目录存在 + let local_root = std::path::PathBuf::from(&config.local_root); + if !local_root.exists() { + std::fs::create_dir_all(&local_root).map_err(|e| SyncErrorFfi::InternalError { + message: format!("无法创建同步目录: {}", e), + })?; + } + + // 确保程序数据目录存在 + let data_dir = std::path::PathBuf::from(&config.data_dir); + let db_dir = data_dir.join("sync_core").join("datas"); + let log_dir = data_dir.join("sync_core").join("logs"); + if !db_dir.exists() { + std::fs::create_dir_all(&db_dir).map_err(|e| SyncErrorFfi::InternalError { + message: format!("无法创建数据库目录: {}", e), + })?; + } + if !log_dir.exists() { + std::fs::create_dir_all(&log_dir).map_err(|e| SyncErrorFfi::InternalError { + message: format!("无法创建日志目录: {}", e), + })?; + } + + // 初始化 tracing 日志:输出到程序数据目录的 logs 和 stderr + let log_path = log_dir.join("sync_log.txt"); + eprintln!("[sync-core] 日志文件: {}", log_path.display()); + + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .ok(); + + if log_file.is_none() { + eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display()); + } + + // 尝试初始化 subscriber(仅首次有效,后续调用忽略) + { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let filter = tracing_subscriber::EnvFilter::from_default_env() + .add_directive("sync_core=debug".parse().unwrap()); + + let (reload_filter, reload_handle) = + tracing_subscriber::reload::Layer::<_, tracing_subscriber::Registry>::new(filter); + LOG_RELOAD_HANDLE.set(reload_handle).ok(); + + let registry = tracing_subscriber::registry().with(reload_filter); + + if let Some(file) = log_file { + let _ = registry + .with(tracing_subscriber::fmt::layer() + .with_writer(std::sync::Mutex::new(file)) + .with_ansi(false)) + .with(tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr)) + .try_init(); + } else { + let _ = registry + .with(tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr)) + .try_init(); + } + } + + // 提取配置信息用于日志(在 move 之前) + let log_sync_mode = config.sync_mode.clone(); + let log_conflict_strategy = config.conflict_strategy.clone(); + let log_max_concurrent = config.max_concurrent_transfers; + let log_bandwidth = config.bandwidth_limit_kbps; + let log_level = config.log_level.clone(); + + let engine = SyncEngine::new(config_from_ffi(config)).await + .map_err(error_to_ffi)?; + + ENGINE.set(Arc::new(engine)) + .map_err(|_| SyncErrorFfi::InternalError { + message: "引擎已初始化".to_string(), + })?; + + tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display()); + tracing::info!( + "配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}kbps", + log_sync_mode, log_conflict_strategy, log_max_concurrent, log_bandwidth, + ); + if log_bandwidth > 0 { + tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效"); + } + + // 应用配置中的日志级别(热修改覆盖默认 debug) + apply_log_level(&log_level); + + Ok(()) +} + +/// 销毁同步引擎 +#[frb] +pub async fn dispose_sync_engine() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] dispose_sync_engine ←"); + let engine = get_engine()?; + engine.stop().await.map_err(error_to_ffi)?; + + #[cfg(feature = "windows-cfapi")] + { + engine.cleanup_wcf(); + } + + tracing::info!("同步引擎已停止"); + Ok(()) +} + +/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放) +/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用 +#[frb] +pub fn sync_shutdown() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] sync_shutdown ←"); + #[cfg(feature = "windows-cfapi")] + { + let engine = match ENGINE.get() { + Some(e) => e, + None => return Ok(()), + }; + engine.cleanup_wcf(); + } + tracing::info!("同步引擎已同步清理"); + Ok(()) +} + +// ========== 同步控制 ========== + +/// 执行初始全量同步 +#[frb] +pub async fn start_initial_sync() -> Result { + tracing::debug!("[FFI] start_initial_sync ←"); + let engine = get_engine()?; + engine.ensure_token_fresh(); + engine.run_initial_sync().await + .map(|s| { + tracing::debug!("[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", + s.uploaded, s.downloaded, s.conflicts, s.failed); + summary_to_ffi(s) + }) + .map_err(error_to_ffi) +} + +/// 启动持续同步(后台运行,立即返回) +#[frb] +pub async fn start_continuous_sync() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] start_continuous_sync ←"); + let engine = get_engine()?; + let engine = engine.clone(); + tokio::spawn(async move { + if let Err(e) = engine.run_continuous().await { + tracing::error!("持续同步异常退出: {}", e); + } + }); + tracing::debug!("[FFI] start_continuous_sync → spawned"); + Ok(()) +} + +/// 停止同步 +#[frb] +pub async fn stop_sync() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] stop_sync ←"); + let engine = get_engine()?; + engine.stop().await.map_err(error_to_ffi) +} + +/// 暂停同步 +#[frb] +pub async fn pause_sync() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] pause_sync ←"); + let engine = get_engine()?; + engine.pause().await.map_err(error_to_ffi) +} + +/// 恢复同步 +#[frb] +pub async fn resume_sync() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] resume_sync ←"); + let engine = get_engine()?; + engine.resume().await.map_err(error_to_ffi) +} + +/// 强制同步(重新扫描全量差异) +#[frb] +pub async fn force_sync() -> Result { + tracing::debug!("[FFI] force_sync ←"); + let engine = get_engine()?; + engine.force_sync().await + .map(|s| { + tracing::debug!("[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", + s.uploaded, s.downloaded, s.conflicts, s.failed); + summary_to_ffi(s) + }) + .map_err(error_to_ffi) +} + +/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 +#[frb] +pub async fn reset_sync() -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] reset_sync ←"); + let engine = get_engine()?; + engine.reset_sync().await.map_err(error_to_ffi) +} + +// ========== 状态查询 ========== + +/// 获取同步状态快照 +#[frb] +pub async fn get_sync_status() -> Result { + let engine = get_engine()?; + let s = engine.status(); + tracing::trace!("[FFI] get_sync_status → state={:?}, synced={}, total={}", s.state, s.synced_files, s.total_files); + Ok(status_to_ffi(s)) +} + +/// 获取活跃 Worker 数量 +#[frb] +pub async fn get_active_worker_count() -> Result { + let engine = get_engine()?; + let count = engine.active_worker_count(); + tracing::trace!("[FFI] get_active_worker_count → {}", count); + Ok(count) +} + +/// 获取同步配置 +#[frb] +pub async fn get_sync_config() -> Result { + let engine = get_engine()?; + let c = engine.config().await; + tracing::trace!("[FFI] get_sync_config → mode={:?}, conflict={:?}", c.sync_mode, c.conflict_strategy); + Ok(config_to_ffi(&c)) +} + +/// 更新同步配置 +#[frb] +pub async fn update_sync_config(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] update_sync_config ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps", + config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, config.bandwidth_limit_kbps); + let engine = get_engine()?; + engine.update_config(config_from_ffi(config)).await.map_err(error_to_ffi) +} + +// ========== Token 管理 ========== + +/// Dart 推送新 Token 给 Rust +#[frb] +pub async fn update_tokens(access_token: String) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] update_tokens ← token_len={}", access_token.len()); + let engine = get_engine()?; + engine.update_access_token(access_token).await; + Ok(()) +} + +// ========== Windows 专用 ========== + +/// 水合文件(Windows 按需下载) +#[frb] +pub async fn hydrate_file(local_path: String) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] hydrate_file ← path={}", local_path); + let engine = get_engine()?; + engine.hydrate_file(&local_path).await.map_err(error_to_ffi) +} + +// ========== Android 专用 ========== + +/// 同步相册到云端 +#[frb] +pub async fn sync_album_to_cloud( + album_paths: Vec, + remote_dcim_uri: String, +) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] sync_album_to_cloud ← paths={}, uri={}", album_paths.len(), remote_dcim_uri); + let engine = get_engine()?; + engine.sync_album(album_paths, &remote_dcim_uri).await.map_err(error_to_ffi) +} + +/// 检查云端是否存在 DCIM/Pictures 目录 +#[frb] +pub async fn check_cloud_album_dirs(base_uri: String) -> Result { + tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri); + let engine = get_engine()?; + engine.check_album_dirs(&base_uri).await + .map(|r| { + tracing::debug!("[FFI] check_cloud_album_dirs → dcim={}, pictures={}", r.dcim_exists, r.pictures_exists); + album_result_to_ffi(r) + }) + .map_err(error_to_ffi) +} + +/// 在云端创建 DCIM/Pictures 目录 +#[frb] +pub async fn create_cloud_album_dirs(base_uri: String) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri); + let engine = get_engine()?; + engine.create_album_dirs(&base_uri).await.map_err(error_to_ffi) +} + +// ========== 事件推送 ========== + +/// 注册 Rust→Dart 事件推送通道 +#[frb] +pub fn register_sync_event_sink(sink: crate::frb_generated::StreamSink) -> Result<(), SyncErrorFfi> { + tracing::debug!("[FFI] register_sync_event_sink ←"); + let engine = get_engine()?; + // 使用 tokio runtime 注册 + let rt = tokio::runtime::Handle::current(); + rt.block_on(engine.register_event_sink(sink)); + Ok(()) +} + +// ========== 日志级别 ========== + +/// 运行时热修改日志级别(立即生效,无需重启) +#[frb] +pub fn set_sync_log_level(level: String) -> Result<(), SyncErrorFfi> { + eprintln!("[FFI] set_sync_log_level ← level={}", level); + let valid_levels = ["error", "warn", "info", "debug", "trace"]; + let level_lower = level.to_lowercase(); + if !valid_levels.contains(&level_lower.as_str()) { + return Err(SyncErrorFfi::InternalError { + message: format!("无效的日志级别: {}, 可选: {:?}", level, valid_levels), + }); + } + + if LOG_RELOAD_HANDLE.get().is_none() { + return Err(SyncErrorFfi::NotInitialized); + } + + apply_log_level(&level_lower); + Ok(()) +} + +// ========== 任务查询 ========== + +/// 获取活跃的同步任务列表 +#[frb] +pub async fn get_active_tasks() -> Result, SyncErrorFfi> { + let engine = get_engine()?; + let tasks = engine.get_active_tasks().await.map_err(error_to_ffi)?; + tracing::trace!("[FFI] get_active_tasks → count={}", tasks.len()); + Ok(tasks.into_iter().map(task_to_ffi).collect()) +} + +/// 获取最近同步任务列表 +#[frb] +pub async fn get_recent_tasks(limit: u32) -> Result, SyncErrorFfi> { + tracing::trace!("[FFI] get_recent_tasks ← limit={}", limit); + let engine = get_engine()?; + let tasks = engine.get_recent_tasks(limit).await.map_err(error_to_ffi)?; + tracing::trace!("[FFI] get_recent_tasks → count={}", tasks.len()); + Ok(tasks.into_iter().map(task_to_ffi).collect()) +} + +/// 获取任务详情(任务项列表) +#[frb] +pub async fn get_task_detail(task_id: String) -> Result, SyncErrorFfi> { + tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id); + let engine = get_engine()?; + let items = engine.get_task_detail(&task_id).await.map_err(error_to_ffi)?; + tracing::trace!("[FFI] get_task_detail → count={}", items.len()); + Ok(items.into_iter().map(task_item_to_ffi).collect()) +} + +/// 多维度查询任务项 +#[frb] +pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result, SyncErrorFfi> { + tracing::trace!("[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}", + filter.task_id, filter.action_type, filter.status, filter.limit); + let engine = get_engine()?; + let model_filter = crate::models::TaskItemFilter { + task_id: filter.task_id, + relative_path_contains: filter.relative_path_contains, + action_type: filter.action_type, + status: filter.status, + limit: filter.limit.max(1).min(1000), + offset: filter.offset, + }; + let items = engine.query_task_items(&model_filter).await.map_err(error_to_ffi)?; + tracing::trace!("[FFI] query_task_items → count={}", items.len()); + Ok(items.into_iter().map(task_item_to_ffi).collect()) +} + +fn task_to_ffi(t: crate::models::SyncTask) -> SyncTaskFfi { + SyncTaskFfi { + id: t.id, + trigger: t.trigger.as_str().to_string(), + total_count: t.total_count, + completed_count: t.completed_count, + failed_count: t.failed_count, + status: t.status.as_str().to_string(), + created_at: t.created_at, + updated_at: t.updated_at, + finished_at: t.finished_at, + } +} + +fn task_item_to_ffi(i: crate::models::SyncTaskItem) -> SyncTaskItemFfi { + SyncTaskItemFfi { + id: i.id, + task_id: i.task_id, + relative_path: i.relative_path, + action_type: i.action_type.as_str().to_string(), + status: i.status.as_str().to_string(), + file_size: i.file_size, + error_message: i.error_message, + created_at: i.created_at, + updated_at: i.updated_at, + } +} diff --git a/native/sync-core/src/api/ffi_types.rs b/native/sync-core/src/api/ffi_types.rs new file mode 100644 index 0000000..36bdf44 --- /dev/null +++ b/native/sync-core/src/api/ffi_types.rs @@ -0,0 +1,164 @@ +/// FFI 错误类型 +#[derive(Debug, Clone)] +pub enum SyncErrorFfi { + NotInitialized, + NetworkError { message: String }, + DiskFull { needed: u64, available: u64 }, + AuthError { message: String }, + ConflictError { count: u32 }, + InternalError { message: String }, +} + +/// 同步配置 +#[derive(Debug, Clone)] +pub struct SyncConfigFfi { + pub base_url: String, + pub access_token: String, + pub refresh_token: String, + pub local_root: String, + pub remote_root: String, + pub sync_mode: String, + pub conflict_strategy: String, + pub wcf_delete_mode: String, + pub max_concurrent_transfers: u32, + pub bandwidth_limit_kbps: u64, + pub excluded_paths: Vec, + pub max_workers: u32, + pub data_dir: String, + pub client_id: String, + pub log_level: String, +} + +/// 同步状态快照 +#[derive(Debug, Clone)] +pub struct SyncStatusFfi { + pub state: String, + pub synced_files: u64, + pub total_files: u64, + pub uploading_count: u32, + pub downloading_count: u32, + pub conflict_count: u32, + pub error_count: u32, + pub last_sync_time: Option, + pub error_message: Option, +} + +/// 初始同步摘要 +#[derive(Debug, Clone)] +pub struct SyncSummaryFfi { + pub uploaded: u32, + pub downloaded: u32, + pub renamed: u32, + pub moved: u32, + pub conflicts: u32, + pub failed: u32, + pub skipped: u32, + pub deleted_local: u32, + pub deleted_remote: u32, + pub duration_ms: u64, +} + +/// 同步事件(Rust → Dart 推送) +#[derive(Debug, Clone)] +pub enum SyncEventFfi { + StateChanged { new_state: String }, + Progress { + synced: u64, + total: u64, + current_file: String, + }, + FileUploaded { + local_path: String, + remote_uri: String, + }, + FileDownloaded { + local_path: String, + remote_uri: String, + }, + ConflictDetected { + local_path: String, + conflict_type: String, + }, + Error { + message: String, + recoverable: bool, + }, + TokenExpired, + DiskSpaceWarning { available_mb: u64 }, + InitialSyncComplete { summary: SyncSummaryFfi }, + + // Worker 事件 + WorkerStarted { + task_id: String, + trigger: String, + upload_count: u32, + download_count: u32, + }, + WorkerCompleted { + task_id: String, + uploaded: u32, + downloaded: u32, + renamed: u32, + moved: u32, + failed: u32, + duration_ms: u64, + }, + WorkerFailed { + task_id: String, + message: String, + }, + TaskItemUpdated { + task_id: String, + relative_path: String, + action: String, + status: String, + }, +} + +/// Android: 云端相册目录检查结果 +#[derive(Debug, Clone)] +pub struct CloudAlbumCheckResultFfi { + pub dcim_exists: bool, + pub pictures_exists: bool, + pub dcim_uri: Option, + pub pictures_uri: Option, +} + +/// 同步任务摘要(FFI) +#[derive(Debug, Clone)] +pub struct SyncTaskFfi { + pub id: String, + pub trigger: String, + pub total_count: u32, + pub completed_count: u32, + pub failed_count: u32, + pub status: String, + pub created_at: String, + pub updated_at: String, + pub finished_at: Option, +} + +/// 同步任务项(FFI) +#[derive(Debug, Clone)] +pub struct SyncTaskItemFfi { + pub id: i64, + pub task_id: String, + pub relative_path: String, + pub action_type: String, + pub status: String, + pub file_size: u64, + pub error_message: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 任务项查询过滤器(FFI) +#[derive(Debug, Clone)] +pub struct TaskItemFilterFfi { + pub task_id: Option, + pub relative_path_contains: Option, + pub action_type: Option, + pub status: Option, + pub limit: u32, + pub offset: u32, +} diff --git a/native/sync-core/src/api/mod.rs b/native/sync-core/src/api/mod.rs new file mode 100644 index 0000000..81badff --- /dev/null +++ b/native/sync-core/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod ffi; +pub mod ffi_types; diff --git a/native/sync-core/src/api_client.rs b/native/sync-core/src/api_client.rs new file mode 100644 index 0000000..93012ee --- /dev/null +++ b/native/sync-core/src/api_client.rs @@ -0,0 +1,635 @@ +use crate::errors::{Result, SyncError}; +use crate::models::*; +use reqwest::Client; +use serde::Deserialize; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +#[derive(Debug, Clone, Deserialize)] +struct ApiResponse { + code: i32, + data: Option, + msg: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RefreshResponse { + access_token: String, + refresh_token: String, +} + +/// Token 刷新状态:确保并发请求只刷新一次 +struct RefreshState { + refreshing: bool, + notify: Arc, +} + +pub struct ApiClient { + base_url: String, + access_token: RwLock, + refresh_token: RwLock, + refresh_state: Arc>, + client: Client, + /// 流式下载专用 client,不设整体超时,仅限制连接和读取间隔 + download_client: Client, + client_id: String, +} + +impl ApiClient { + pub fn new(base_url: &str, access_token: &str, refresh_token: &str, client_id: &str) -> Self { + let client = Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + let download_client = Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .read_timeout(std::time::Duration::from_secs(300)) + .build() + .expect("Failed to create download HTTP client"); + + Self { + base_url: base_url.trim_end_matches('/').to_string(), + access_token: RwLock::new(access_token.to_string()), + refresh_token: RwLock::new(refresh_token.to_string()), + refresh_state: Arc::new(Mutex::new(RefreshState { refreshing: false, notify: Arc::new(tokio::sync::Notify::new()) })), + client, + download_client, + client_id: client_id.to_string(), + } + } + + pub async fn update_token(&self, token: String) { + *self.access_token.write().await = token; + } + + pub async fn update_tokens(&self, access: &str, refresh: &str) { + *self.access_token.write().await = access.to_string(); + *self.refresh_token.write().await = refresh.to_string(); + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn client_id(&self) -> &str { + &self.client_id + } + + pub async fn token(&self) -> String { + self.access_token.read().await.clone() + } + + /// 带并发去重的 token 刷新 + /// 多个任务同时遇到 401 时,只有一个执行刷新,其他等待刷新完成后自动获取新 token + pub async fn refresh_access_token(&self) -> Result<()> { + let mut state = self.refresh_state.lock().await; + + if state.refreshing { + // 已经有另一个任务在刷新了,等待通知 + let notify = state.notify.clone(); + drop(state); + notify.notified().await; + return Ok(()); + } + + state.refreshing = true; + let notify = state.notify.clone(); + drop(state); + + let result = self.do_refresh().await; + + let mut state = self.refresh_state.lock().await; + state.refreshing = false; + drop(state); + + // 通知所有等待者刷新已完成 + notify.notify_waiters(); + + result + } + + async fn do_refresh(&self) -> Result<()> { + let refresh_token = self.refresh_token.read().await.clone(); + if refresh_token.is_empty() { + return Err(SyncError::Auth("无 refresh_token,无法刷新".into())); + } + + tracing::info!("正在刷新 access_token..."); + + let resp = self.client + .post(format!("{}/session/token/refresh", self.base_url)) + .json(&serde_json::json!({ + "refresh_token": refresh_token, + })) + .send() + .await?; + + if !resp.status().is_success() { + return Err(SyncError::Auth(format!("Token 刷新失败: HTTP {}", resp.status()))); + } + + let api_resp: ApiResponse = resp.json().await?; + if api_resp.code != 0 { + return Err(SyncError::Auth(format!( + "Token 刷新失败: {}", + api_resp.msg.unwrap_or_else(|| "未知错误".into()) + ))); + } + + if let Some(data) = api_resp.data { + *self.access_token.write().await = data.access_token; + *self.refresh_token.write().await = data.refresh_token; + tracing::info!("access_token 刷新成功"); + Ok(()) + } else { + Err(SyncError::Auth("Token 刷新响应缺少数据".into())) + } + } + + /// 解析 API 响应 + async fn parse_response(&self, resp: reqwest::Response) -> Result { + if !resp.status().is_success() { + return Err(SyncError::Network(format!("HTTP {}", resp.status()))); + } + let api_resp: ApiResponse = resp.json().await?; + if api_resp.code == 401 { + return Err(SyncError::Auth("Login required".into())); + } + if api_resp.code == 40004 { + return Err(SyncError::ObjectExisted); + } + if api_resp.code == 40073 { + let items = api_resp.data + .and_then(|d| d.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|item| { + let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let token = item.get("token").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if !token.is_empty() { + Some(crate::errors::LockConflictItem { path, token }) + } else { + None + } + }) + .collect(); + return Err(SyncError::LockConflict { tokens: items }); + } + if api_resp.code != 0 { + return Err(SyncError::Network( + api_resp.msg.unwrap_or_else(|| format!("错误码: {}", api_resp.code)), + )); + } + Ok(api_resp.data.unwrap_or_default()) + } + + /// 发送带认证的请求,自动处理 401(刷新 token 后重试一次) + /// request_builder 接收当前 token,返回 RequestBuilder + /// 所有请求自动附加 X-Cr-Client-Id header,服务端据此过滤 SSE 自身事件 + async fn send_with_auth_retry( + &self, + request_builder: impl Fn(String) -> reqwest::RequestBuilder, + ) -> Result { + let client_id = self.client_id.clone(); + + // 第一次尝试 + let token = self.token().await; + let resp = request_builder(token) + .header("X-Cr-Client-Id", &client_id) + .send().await?; + let result = self.parse_response(resp).await; + + if let Err(SyncError::Auth(_)) = result { + // 刷新 token + self.refresh_access_token().await?; + // 用新 token 重试 + let new_token = self.token().await; + let resp = request_builder(new_token) + .header("X-Cr-Client-Id", &client_id) + .send().await?; + return self.parse_response(resp).await; + } + + result + } + + // ===== 文件列表 ===== + + /// 递归列出指定 URI 下的所有文件和目录 + pub async fn list_all_files(&self, uri: &str) -> Result> { + let mut all_files = Vec::new(); + self.list_all_files_recursive(uri, &mut all_files).await?; + Ok(all_files) + } + + fn list_all_files_recursive<'a>( + &'a self, + uri: &'a str, + result: &'a mut Vec, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if result.len() > 1_000_000 { + tracing::warn!("文件数量超过 100 万,截断初始同步"); + return Ok(()); + } + + let mut next_page_token: Option = None; + let mut page = 0u32; + let page_size = 2000u32; + let mut dirs_to_recurse = Vec::new(); + + loop { + let resp = self.list_files_page(uri, page, page_size, next_page_token.as_deref()).await?; + let count = resp.files.len(); + + for file in resp.files { + if file.is_dir { + dirs_to_recurse.push(file.uri.clone()); + } + result.push(file); + } + + if let Some(token) = resp.pagination.next_page_token { + next_page_token = Some(token); + } else if count < page_size as usize { + break; + } else { + page += 1; + } + } + + for dir_uri in dirs_to_recurse { + self.list_all_files_recursive(&dir_uri, result).await?; + } + + Ok(()) + }) + } + + pub async fn list_files_page( + &self, + uri: &str, + page: u32, + page_size: u32, + next_page_token: Option<&str>, + ) -> Result { + let data = self.send_with_auth_retry(|token| { + let mut req = self.client + .get(format!("{}/file", self.base_url)) + .bearer_auth(&token) + .query(&[ + ("uri", uri), + ("page", &page.to_string()), + ("page_size", &page_size.to_string()), + ]); + if let Some(npt) = next_page_token { + req = req.query(&[("next_page_token", npt)]); + } + req + }).await?; + + let parent_uri = uri.to_string(); + let files: Vec = if let Some(items) = data.get("files").and_then(|f| f.as_array()) { + items.iter().filter_map(|obj| { + let name_raw = obj.get("name")?.as_str()?.to_string(); + let name = percent_decode_str(&name_raw); + let path_raw = obj.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let path = percent_decode_str(&path_raw); + let entry_uri = if path_raw.is_empty() { + format!("{}/{}", parent_uri, name_raw) + } else { + path_raw.clone() + }; + let size = obj.get("size").and_then(|s| s.as_u64()).unwrap_or(0); + let file_type = obj.get("type").and_then(|t| t.as_u64()).unwrap_or(0); + let is_dir = file_type == 1; + let file_id = obj.get("id").and_then(|s| s.as_str()).map(String::from); + let created_at = obj.get("created_at").and_then(|t| t.as_str()).unwrap_or(""); + let updated_at = obj.get("updated_at").and_then(|t| t.as_str()).unwrap_or(""); + + Some(RemoteFileEntry { + uri: entry_uri, + name, + size, + mtime_ms: parse_timestamp(updated_at), + hash: None, + is_dir, + file_id, + path, + created_at_ms: parse_timestamp(created_at), + }) + }).collect() + } else { + tracing::warn!("API 响应中未找到 files 数组, data keys: {:?}", data.as_object().map(|m| m.keys().collect::>())); + Vec::new() + }; + + let pagination = data.get("pagination").cloned().unwrap_or_default(); + let next_token = pagination.get("next_page_token") + .and_then(|t| t.as_str()) + .map(String::from); + let is_cursor = pagination.get("is_cursor") + .and_then(|c| c.as_bool()) + .unwrap_or(false); + let total = pagination.get("total_items") + .and_then(|t| t.as_u64()) + .or_else(|| pagination.get("total").and_then(|t| t.as_u64())); + + Ok(ListFilesResponse { + files, + pagination: Pagination { + next_page_token: next_token, + is_cursor, + total, + }, + }) + } + + // ===== 上传 ===== + + pub async fn create_upload_session( + &self, + uri: &str, + size: u64, + overwrite: bool, + last_modified: Option, + mime_type: Option<&str>, + policy_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({ + "uri": uri, + "size": size, + }); + if overwrite { + body["entity_type"] = serde_json::Value::String("version".to_string()); + } + if let Some(mtime) = last_modified { + body["last_modified"] = serde_json::Value::Number(mtime.into()); + } + if let Some(mime) = mime_type { + body["mime_type"] = serde_json::Value::String(mime.to_string()); + } + if let Some(pid) = policy_id { + body["policy_id"] = serde_json::Value::String(pid.to_string()); + } + + let data = self.send_with_auth_retry(|token| { + self.client + .put(format!("{}/file/upload", self.base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + let session_id = data.get("session_id") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let chunk_size = data.get("chunk_size") + .and_then(|c| c.as_u64()) + .unwrap_or(10 * 1024 * 1024); + + Ok(UploadSession { + session_id, + chunk_size, + }) + } + + pub async fn upload_chunk( + &self, + session_id: &str, + index: u32, + data: &[u8], + ) -> Result<()> { + let chunk_data = data.to_vec(); + let content_len = data.len().to_string(); + self.send_with_auth_retry(|token| { + self.client + .post(format!( + "{}/file/upload/{}/{}", + self.base_url, session_id, index + )) + .bearer_auth(&token) + .header("Content-Length", &content_len) + .body(chunk_data.clone()) + }).await?; + Ok(()) + } + + // ===== 下载 ===== + + pub async fn get_download_url(&self, uris: &[&str]) -> Result> { + let body = serde_json::json!({ + "uris": uris, + "download": false, + }); + + let data = self.send_with_auth_retry(|token| { + self.client + .post(format!("{}/file/url", self.base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + let urls = data.get("urls") + .and_then(|u| u.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| item.get("url")?.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + Ok(urls) + } + + pub async fn stream_download( + &self, + url: &str, + offset: u64, + ) -> Result { + let mut req = self.download_client.get(url); + if offset > 0 { + req = req.header("Range", format!("bytes={}-", offset)); + } + let resp = req.send().await?; + Ok(resp) + } + + // ===== 创建文件/目录 ===== + + pub async fn create_directory(&self, parent_uri: &str, name: &str) -> Result { + let uri = format!("{}/{}", parent_uri, name); + let body = serde_json::json!({ + "uri": uri, + "type": "folder", + }); + + let _data = self.send_with_auth_retry(|token| { + self.client + .post(format!("{}/file/create", self.base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + Ok(RemoteFileEntry { + uri, + name: name.to_string(), + size: 0, + mtime_ms: 0, + hash: None, + is_dir: true, + file_id: None, + path: String::new(), + created_at_ms: 0, + }) + } + + // ===== 删除 ===== + + pub async fn delete_files(&self, uris: &[&str]) -> Result<()> { + let body = serde_json::json!({ + "uris": uris, + }); + + let result = self.send_with_auth_retry(|token| { + let client = &self.client; + let base_url = &self.base_url; + let body = body.clone(); + client + .delete(format!("{}/file", base_url)) + .bearer_auth(&token) + .json(&body) + }).await; + + match result { + Ok(_) => Ok(()), + Err(SyncError::LockConflict { tokens }) => { + for item in &tokens { + tracing::warn!( + "删除异常: code(40073), 进行解锁, token: {}, path: {}", + item.token, item.path + ); + } + self.force_unlock_files(&tokens).await?; + + // 解锁后重试删除 + self.send_with_auth_retry(|token| { + let client = &self.client; + let base_url = &self.base_url; + let body = body.clone(); + client + .delete(format!("{}/file", base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + Ok(()) + } + Err(e) => Err(e), + } + } + + /// 强制解锁文件 — DELETE /file/lock + pub async fn force_unlock_files(&self, items: &[crate::errors::LockConflictItem]) -> Result<()> { + let tokens: Vec<&str> = items.iter().map(|i| i.token.as_str()).collect(); + if tokens.is_empty() { + return Ok(()); + } + + let body = serde_json::json!({ + "tokens": tokens, + }); + + self.send_with_auth_retry(|token| { + let client = &self.client; + let base_url = &self.base_url; + let body = body.clone(); + client + .delete(format!("{}/file/lock", base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + tracing::info!("强制解锁完成: {} 个文件", items.len()); + Ok(()) + } + + // ===== 移动 ===== + + pub async fn move_files(&self, src_uris: &[&str], dst_uri: &str, copy: bool) -> Result<()> { + let body = serde_json::json!({ + "uris": src_uris, + "dst": dst_uri, + "copy": copy, + }); + + self.send_with_auth_retry(|token| { + self.client + .post(format!("{}/file/move", self.base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + Ok(()) + } + + // ===== 重命名 ===== + + pub async fn rename_file(&self, uri: &str, new_name: &str) -> Result<()> { + let body = serde_json::json!({ + "uri": uri, + "new_name": new_name, + }); + + self.send_with_auth_retry(|token| { + self.client + .post(format!("{}/file/rename", self.base_url)) + .bearer_auth(&token) + .json(&body) + }).await?; + + Ok(()) + } + + // ===== 获取文件信息 ===== + + pub async fn get_file_info(&self, uri: &str) -> Result { + let data = self.send_with_auth_retry(|token| { + self.client + .get(format!("{}/file/info", self.base_url)) + .bearer_auth(&token) + .query(&[("uri", uri)]) + }).await?; + + Ok(RemoteFileEntry { + uri: data.get("uri").and_then(|u| u.as_str()).unwrap_or(uri).to_string(), + name: percent_decode_str(data.get("name").and_then(|n| n.as_str()).unwrap_or("")), + size: data.get("size").and_then(|s| s.as_u64()).unwrap_or(0), + mtime_ms: data.get("updated_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0), + hash: None, + is_dir: data.get("type").and_then(|t| t.as_u64()).unwrap_or(0) == 1, + file_id: data.get("id").and_then(|i| i.as_str()).map(String::from), + path: percent_decode_str(data.get("path").and_then(|p| p.as_str()).unwrap_or("")), + created_at_ms: data.get("created_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0), + }) + } +} + +/// 解析 Cloudreve 时间戳 (ISO 8601 或 Unix ms) +fn parse_timestamp(s: &str) -> i64 { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return dt.timestamp_millis(); + } + if let Ok(ts) = s.parse::() { + if ts < 10_000_000_000 { + return ts * 1000; + } + return ts; + } + 0 +} + +fn percent_decode_str(s: &str) -> String { + urlencoding::decode(s).unwrap_or_else(|_| s.into()).to_string() +} diff --git a/native/sync-core/src/conflict_resolver.rs b/native/sync-core/src/conflict_resolver.rs new file mode 100644 index 0000000..5f031f6 --- /dev/null +++ b/native/sync-core/src/conflict_resolver.rs @@ -0,0 +1,70 @@ +use crate::models::*; + +#[derive(Clone)] +pub struct ConflictResolver { + pub default_strategy: ConflictStrategy, +} + +impl ConflictResolver { + pub fn new(strategy: ConflictStrategy) -> Self { + Self { + default_strategy: strategy, + } + } + + pub fn resolve( + &self, + conflict_type: ConflictType, + local_mtime: i64, + remote_mtime: i64, + local_size: u64, + remote_size: u64, + local_name: &str, + ) -> ConflictResolution { + self.resolve_with_strategy( + &self.default_strategy, + conflict_type, + local_mtime, + remote_mtime, + local_size, + remote_size, + local_name, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn resolve_with_strategy( + &self, + strategy: &ConflictStrategy, + _conflict_type: ConflictType, + local_mtime: i64, + remote_mtime: i64, + local_size: u64, + remote_size: u64, + local_name: &str, + ) -> ConflictResolution { + match strategy { + ConflictStrategy::KeepLocal => ConflictResolution::UploadLocal, + ConflictStrategy::KeepRemote => ConflictResolution::DownloadRemote, + ConflictStrategy::KeepBoth => { + let new_name = crate::utils::generate_conflict_name(local_name); + ConflictResolution::RenameLocal { new_name } + } + ConflictStrategy::NewestWins => { + if local_mtime > remote_mtime { + ConflictResolution::UploadLocal + } else { + ConflictResolution::DownloadRemote + } + } + ConflictStrategy::LargestWins => { + if local_size > remote_size { + ConflictResolution::UploadLocal + } else { + ConflictResolution::DownloadRemote + } + } + ConflictStrategy::Manual => ConflictResolution::MarkManual, + } + } +} diff --git a/native/sync-core/src/diff.rs b/native/sync-core/src/diff.rs new file mode 100644 index 0000000..0aa4a9b --- /dev/null +++ b/native/sync-core/src/diff.rs @@ -0,0 +1,204 @@ +use crate::models::*; +use std::collections::{HashMap, HashSet}; + +/// 三路差异计算: 本地 vs 远程 vs 数据库,按同步模式过滤 +pub fn compute_diff( + local_files: &[LocalFileEntry], + remote_files: &[RemoteFileEntry], + db_mappings: &HashMap, + remote_root: &str, + sync_mode: &SyncMode, +) -> SyncPlan { + let mut plan = SyncPlan::default(); + + // 构建索引: relative_path → entry(统一正斜杠) + let local_map: HashMap = local_files + .iter() + .map(|e| (crate::utils::normalize_path(&e.relative_path.to_string_lossy()), e)) + .collect(); + + let remote_map: HashMap = remote_files + .iter() + .map(|e| { + let rel = remote_relative_path(remote_root, &e.path, &e.name, e.is_dir); + (rel, e) + }) + .collect(); + + // 收集所有路径 + let mut all_paths: HashSet = HashSet::new(); + for k in local_map.keys() { + all_paths.insert(k.clone()); + } + for k in remote_map.keys() { + all_paths.insert(k.clone()); + } + for k in db_mappings.keys() { + all_paths.insert(k.clone()); + } + + for path in &all_paths { + let local = local_map.get(path.as_str()).copied(); + let remote = remote_map.get(path.as_str()).copied(); + let db = db_mappings.get(path.as_str()); + + match (local, remote, db) { + // 本地有,远程无 → 上传(UploadOnly、Full 和 MirrorWcf) + (Some(l), None, _) => { + if matches!(sync_mode, SyncMode::DownloadOnly) { + continue; + } + if !l.is_dir && l.size == 0 { + continue; + } + if let Some(db_m) = db { + if db_m.sync_status == SyncFileStatus::Synced { + plan.uploads.push(SyncAction { + relative_path: path.clone(), + local_entry: Some((*l).clone()), + remote_entry: None, + db_mapping: Some(db_m.clone()), + }); + } + } else { + plan.uploads.push(SyncAction { + relative_path: path.clone(), + local_entry: Some((*l).clone()), + remote_entry: None, + db_mapping: None, + }); + } + } + + // 远程有,本地无 → 下载(DownloadOnly、Full 和 MirrorWcf) + (None, Some(r), _) => { + if matches!(sync_mode, SyncMode::UploadOnly) { + continue; + } + if r.is_dir { + plan.mkdirs_local.push(path.clone()); + } else if r.size == 0 { + continue; + } else { + plan.downloads.push(SyncAction { + relative_path: path.clone(), + local_entry: None, + remote_entry: Some((*r).clone()), + db_mapping: db.cloned(), + }); + } + } + + // 两边都有 + (Some(l), Some(r), db_m) => { + if l.is_dir && r.is_dir { + continue; + } + + let content_match = match (&l.quick_hash, &r.hash) { + (lh, Some(rh)) if !lh.is_empty() && !rh.is_empty() => lh == rh, + _ => l.size == r.size, + }; + + if content_match { + // 内容一致,标记已同步 + } else { + let conflict_type = if l.is_dir != r.is_dir { + ConflictType::TypeMismatch + } else { + ConflictType::BothModified + }; + + plan.conflicts.push(SyncConflict { + relative_path: path.clone(), + conflict_type, + local_entry: Some((*l).clone()), + remote_entry: Some((*r).clone()), + db_mapping: db_m.cloned(), + }); + } + } + + (None, None, Some(_)) => {} + + _ => {} + } + } + + // 远程目录结构(UploadOnly、Full 和 MirrorWcf) + if !matches!(sync_mode, SyncMode::DownloadOnly) { + for (path, local) in &local_map { + if local.is_dir && !remote_map.contains_key(path.as_str()) { + plan.mkdirs_remote.push(path.clone()); + } + } + } + + // 按 sync_mode 自动解决冲突 + resolve_conflicts_by_mode(&mut plan, sync_mode); + + plan +} + +/// 根据同步模式自动解决冲突 +fn resolve_conflicts_by_mode(plan: &mut SyncPlan, sync_mode: &SyncMode) { + if plan.conflicts.is_empty() { + return; + } + + let conflicts = std::mem::take(&mut plan.conflicts); + for conflict in conflicts { + match sync_mode { + SyncMode::UploadOnly | SyncMode::MirrorWcf => { + // 冲突一律覆盖上传(MirrorWcf 本地编辑优先) + let action = SyncAction { + relative_path: conflict.relative_path.clone(), + local_entry: conflict.local_entry.clone(), + remote_entry: conflict.remote_entry.clone(), + db_mapping: conflict.db_mapping.clone(), + }; + plan.uploads.push(action); + } + SyncMode::DownloadOnly => { + // 冲突一律覆盖下载 + let action = SyncAction { + relative_path: conflict.relative_path.clone(), + local_entry: conflict.local_entry.clone(), + remote_entry: conflict.remote_entry.clone(), + db_mapping: conflict.db_mapping.clone(), + }; + plan.downloads.push(action); + } + _ => { + // Full: 保留冲突,由 Worker 用 conflict_strategy 解决 + plan.conflicts.push(conflict); + } + } + } +} + +/// 从远程 path 字段提取相对路径 +pub fn remote_relative_path(remote_root: &str, path: &str, name: &str, is_dir: bool) -> String { + let _ = is_dir; + // 1. 尝试匹配完整 URI 前缀(remote_root = cloudreve://my/example) + if let Some(rel) = path.strip_prefix(remote_root) { + return rel.trim_start_matches('/').to_string(); + } + // 2. SSE 的 path 是相对路径(如 /Readest/Books/file.txt),直接 trim 前导 / + if path.starts_with('/') { + return path.trim_start_matches('/').to_string(); + } + // 3. 回退:使用文件名 + name.to_string() +} + +/// 从字符串解析 SyncFileStatus +pub fn parse_sync_status_from_str(s: &str) -> SyncFileStatus { + match s { + "uploading" => SyncFileStatus::Uploading, + "downloading" => SyncFileStatus::Downloading, + "conflict" => SyncFileStatus::Conflict, + "placeholder" => SyncFileStatus::Placeholder, + _ => SyncFileStatus::Synced, + } +} diff --git a/native/sync-core/src/downloader.rs b/native/sync-core/src/downloader.rs new file mode 100644 index 0000000..e08f656 --- /dev/null +++ b/native/sync-core/src/downloader.rs @@ -0,0 +1,308 @@ +use crate::api_client::ApiClient; +use crate::errors::{Result, SyncError}; +use crate::file_lock::FileLockRegistry; +use crate::models::*; +use crate::sync_db::SyncDb; +use tokio::sync::Semaphore; + +/// 下载单个文件(含重试 + 断点续传),受并发信号量控制 +#[allow(clippy::too_many_arguments)] +pub async fn download_file( + task_id: &str, + action: &SyncAction, + config: &WorkerConfig, + api: &ApiClient, + db: &SyncDb, + file_locks: &FileLockRegistry, + semaphore: &Semaphore, + root_id: &str, +) -> Result<()> { + let remote = action.remote_entry.as_ref().ok_or_else(|| { + SyncError::Internal("下载操作缺少远程文件信息".into()) + })?; + + let _lock = file_locks.acquire(&action.relative_path).await; + + if remote.is_dir { + let local_path = config.local_root.join(&action.relative_path); + tokio::fs::create_dir_all(&local_path).await?; + tracing::debug!("[{}] 创建本地目录: {}", task_id, action.relative_path); + return Ok(()); + } + + let local_path = config.local_root.join(&action.relative_path); + + let _permit = semaphore.acquire().await + .map_err(|e| SyncError::Internal(format!("获取传输信号量失败: {}", e)))?; + + // 信号量获取后标记为 Running(实际开始传输) + let _ = db + .update_task_item_status_by_path( + task_id, + &action.relative_path, + "download", + &TaskItemStatus::Running, + None, + ) + .await; + + tracing::info!("[{}] 开始下载: {} ({}bytes)", task_id, action.relative_path, remote.size); + + // 确保父目录存在 + if let Some(parent) = local_path.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + + let max_retries = 3u32; + let mut attempt = 0u32; + let tmp_path = local_path.with_extension(".sync_tmp"); + + loop { + attempt += 1; + + // 检查临时文件已有大小,用于断点续传 + let resume_offset = if tmp_path.exists() { + tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0) + } else { + 0 + }; + + let urls = match api.get_download_url(&[&remote.uri]).await { + Ok(urls) => urls, + Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())), + Err(e) if attempt <= max_retries => { + let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000); + tracing::warn!("[{}] 下载重试 ({}/{}): {} 获取链接失败: {}", task_id, attempt, max_retries, action.relative_path, e); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + continue; + } + Err(e) => return Err(e), + }; + + let download_url = match urls.first() { + Some(u) => u.clone(), + None => { + tracing::error!("[{}] 未获取到下载链接, uri={}", task_id, remote.uri); + return Err(SyncError::Network("未获取到下载链接".into())); + } + }; + + let resp = match api.stream_download(&download_url, resume_offset).await { + Ok(r) => r, + Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())), + Err(e) if attempt <= max_retries => { + let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000); + tracing::warn!("[{}] 下载重试 ({}/{}): {} 连接失败: {}", task_id, attempt, max_retries, action.relative_path, e); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + continue; + } + Err(e) => return Err(e), + }; + + match stream_to_file(resp, &tmp_path, config.bandwidth_limit, resume_offset).await { + Ok(_) => { + tracing::debug!("[{}] 下载写入完成: {} ({}bytes)", task_id, tmp_path.display(), remote.size); + tokio::fs::rename(&tmp_path, &local_path).await?; + + if remote.mtime_ms > 0 { + let mtime = std::time::UNIX_EPOCH + std::time::Duration::from_millis(remote.mtime_ms as u64); + let _ = filetime::set_file_mtime(&local_path, filetime::FileTime::from_system_time(mtime)); + } + + let local_hash = crate::utils::quick_hash(&local_path, remote.size).await.unwrap_or_default(); + db.upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.to_string(), + local_path: std::path::PathBuf::from(&action.relative_path), + remote_uri: remote.uri.clone(), + remote_file_id: remote.file_id.clone(), + local_hash: Some(local_hash.clone()), + remote_hash: remote.hash.clone().or(Some(local_hash)), + local_mtime: Some(remote.mtime_ms), + remote_mtime: Some(remote.mtime_ms), + local_size: Some(remote.size), + remote_size: Some(remote.size), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }).await?; + + tracing::info!("[{}] 下载完成: {}", task_id, action.relative_path); + return Ok(()); + } + Err(e) if attempt <= max_retries => { + let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000); + // 保留临时文件用于断点续传 + let existing_size = tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0); + if existing_size > 0 { + tracing::warn!( + "[{}] 下载重试 ({}/{}): {} 写入失败(已下载{}bytes,将从断点续传): {}", + task_id, attempt, max_retries, action.relative_path, existing_size, e, + ); + } else { + tracing::warn!( + "[{}] 下载重试 ({}/{}): {} 写入失败: {}", + task_id, attempt, max_retries, action.relative_path, e, + ); + } + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + continue; + } + Err(e) => { + let _ = tokio::fs::remove_file(&tmp_path).await; + return Err(e); + } + } + } +} + +/// 流式写入文件(含带宽限速 + 断点续传) +/// resume_offset > 0 时以追加模式打开文件,跳过已下载部分 +pub async fn stream_to_file( + resp: reqwest::Response, + tmp_path: &std::path::Path, + bandwidth_limit: Option, + resume_offset: u64, +) -> Result<()> { + use tokio::io::{AsyncWriteExt, AsyncSeekExt}; + use futures_util::StreamExt; + + let mut file = if resume_offset > 0 && tmp_path.exists() { + // 断点续传:追加模式 + let mut f = tokio::fs::OpenOptions::new() + .read(true) + .write(true) + .open(tmp_path) + .await?; + f.seek(std::io::SeekFrom::End(0)).await?; + f + } else { + tokio::fs::File::create(tmp_path).await? + }; + + let mut stream = resp.bytes_stream(); + let mut total_bytes = resume_offset; + + match bandwidth_limit { + None => { + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?; + file.write_all(&chunk).await?; + } + } + Some(limit) => { + let transfer_start = std::time::Instant::now(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?; + total_bytes += chunk.len() as u64; + file.write_all(&chunk).await?; + + let expected_elapsed = std::time::Duration::from_micros( + total_bytes * 1_000_000 / limit + ); + let actual_elapsed = transfer_start.elapsed(); + if expected_elapsed > actual_elapsed { + tokio::time::sleep(expected_elapsed - actual_elapsed).await; + } + } + } + } + + file.flush().await?; + Ok(()) +} + +/// 从 URL 下载文件到指定路径(WCF 水合使用) +pub async fn download_file_from_url( + download_url: &str, + local_path: &std::path::Path, + expected_size: u64, + bandwidth_limit: Option, +) -> Result<()> { + let client = reqwest::Client::new(); + let mut request = client.get(download_url); + + // 断点续传 + let resume_offset = if local_path.exists() { + let metadata = tokio::fs::metadata(local_path).await?; + if metadata.len() > 0 && metadata.len() < expected_size { + request = request.header("Range", format!("bytes={}-", metadata.len())); + metadata.len() + } else { + 0 + } + } else { + 0 + }; + + let resp = request.send().await.map_err(|e| SyncError::Network(e.to_string()))?; + + if !resp.status().is_success() && resp.status().as_u16() != 206 { + return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status()))); + } + + let tmp_path = local_path.with_extension(format!( + "{}.sync_tmp", + local_path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default() + )); + + stream_to_file(resp, &tmp_path, bandwidth_limit, resume_offset).await?; + + // 重命名临时文件为最终文件 + if tmp_path.exists() { + let _ = std::fs::remove_file(local_path); + std::fs::rename(&tmp_path, local_path) + .map_err(|e| SyncError::FileSystem(format!("重命名临时文件失败: {}", e)))?; + } + + Ok(()) +} + +/// 从 URL 下载数据到内存缓冲区(WCF CfExecute 水合使用) +pub async fn download_to_buffer( + api: &ApiClient, + download_url: &str, + bandwidth_limit: Option, +) -> Result> { + use futures_util::StreamExt; + + let resp = api.stream_download(download_url, 0).await?; + + if !resp.status().is_success() { + return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status()))); + } + + let content_length = resp.content_length(); + let mut buffer = Vec::with_capacity(content_length.unwrap_or(0) as usize); + let mut stream = resp.bytes_stream(); + let mut total_bytes: u64 = 0; + + match bandwidth_limit { + None => { + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?; + buffer.extend_from_slice(&chunk); + } + } + Some(limit) => { + let transfer_start = std::time::Instant::now(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?; + total_bytes += chunk.len() as u64; + buffer.extend_from_slice(&chunk); + + let expected_elapsed = std::time::Duration::from_micros( + total_bytes * 1_000_000 / limit + ); + let actual_elapsed = transfer_start.elapsed(); + if expected_elapsed > actual_elapsed { + tokio::time::sleep(expected_elapsed - actual_elapsed).await; + } + } + } + } + + Ok(buffer) +} diff --git a/native/sync-core/src/errors.rs b/native/sync-core/src/errors.rs new file mode 100644 index 0000000..ed7ecf4 --- /dev/null +++ b/native/sync-core/src/errors.rs @@ -0,0 +1,82 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SyncError { + #[error("网络错误: {0}")] + Network(String), + + #[error("磁盘空间不足: 需要 {needed} 字节, 可用 {available} 字节")] + DiskFull { needed: u64, available: u64 }, + + #[error("认证错误: {0}")] + Auth(String), + + #[error("远程文件已存在")] + ObjectExisted, + + #[error("文件锁定冲突")] + LockConflict { tokens: Vec }, + + #[error("冲突: {count} 个文件存在冲突")] + Conflict { count: u32 }, + + #[error("数据库错误: {0}")] + Database(String), + + #[error("文件系统错误: {0}")] + FileSystem(String), + + #[error("路径遍历攻击: {path} 不在 {root} 下")] + PathTraversal { path: String, root: String }, + + #[error("引擎未初始化")] + NotInitialized, + + #[error("引擎已初始化")] + AlreadyInitialized, + + #[error("同步已取消")] + Cancelled, + + #[error("内部错误: {0}")] + Internal(String), +} + +impl From for SyncError { + fn from(e: rusqlite::Error) -> Self { + SyncError::Database(e.to_string()) + } +} + +impl From for SyncError { + fn from(e: r2d2::Error) -> Self { + SyncError::Database(e.to_string()) + } +} + +impl From for SyncError { + fn from(e: reqwest::Error) -> Self { + SyncError::Network(e.to_string()) + } +} + +impl From for SyncError { + fn from(e: std::io::Error) -> Self { + SyncError::FileSystem(e.to_string()) + } +} + +impl From for SyncError { + fn from(e: tokio::task::JoinError) -> Self { + SyncError::Internal(e.to_string()) + } +} + +/// 锁冲突条目 — 来自 40073 响应的 data 数组 +#[derive(Debug, Clone)] +pub struct LockConflictItem { + pub path: String, + pub token: String, +} + +pub type Result = std::result::Result; diff --git a/native/sync-core/src/event_handler/mod.rs b/native/sync-core/src/event_handler/mod.rs new file mode 100644 index 0000000..7e66231 --- /dev/null +++ b/native/sync-core/src/event_handler/mod.rs @@ -0,0 +1,290 @@ +mod sse; + +use crate::api_client::ApiClient; +use crate::errors::Result; +use crate::models::{RemoteFileEntry, RemoteFileEvent}; +use sse::SseFileEvent; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +pub struct EventHandler { + api: Arc, + client_id: String, +} + +impl EventHandler { + pub fn new(api: Arc, client_id: String) -> Self { + Self { api, client_id } + } + + /// 订阅服务端 SSE 事件流 (GET /file/events) + pub async fn subscribe_sse(&self, uri: &str) -> Result> { + let (tx, rx) = mpsc::channel(256); + + let api = self.api.clone(); + let client_id = self.client_id.clone(); + let remote_root = uri.to_string(); + + tokio::spawn(async move { + loop { + let token = api.token().await; + let base_url = api.base_url().to_string(); + + match Self::connect_sse(&base_url, &token, &client_id, &remote_root, &tx).await { + Ok(_) => { + tracing::info!("[SSE] 连接关闭,5秒后重连..."); + } + Err(e) => { + tracing::warn!("[SSE] 连接错误: {},5秒后重连...", e); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + + if tx.is_closed() { + break; + } + } + + tracing::info!("[SSE] 订阅任务退出"); + }); + + Ok(rx) + } + + /// 建立 SSE 连接,使用 eventsource parser 正确解析事件 + async fn connect_sse( + base_url: &str, + token: &str, + client_id: &str, + remote_root: &str, + tx: &mpsc::Sender, + ) -> Result<()> { + use sse::{SseEvent, SseParseResult, sse_parse_line}; + + let url = format!("{}/file/events", base_url); + + tracing::info!("[SSE] 正在连接: {}?uri={}", url, remote_root); + + let client = reqwest::Client::new(); + let resp = client + .get(&url) + .bearer_auth(token) + .header("X-Cr-Client-Id", client_id) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .query(&[("uri", remote_root)]) + .send() + .await?; + + if !resp.status().is_success() { + return Err(crate::errors::SyncError::Network( + format!("[SSE] 连接失败: HTTP {}", resp.status()), + )); + } + + tracing::info!("[SSE] 连接成功,开始监听事件 (uri={})", remote_root); + + let mut stream = resp.bytes_stream(); + use futures_util::StreamExt; + + let mut line_buf = String::new(); + let mut event = SseEvent::new(); + let mut event_count: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| crate::errors::SyncError::Network(e.to_string()))?; + let text = String::from_utf8_lossy(&chunk); + line_buf.push_str(&text); + + while let Some(newline_pos) = line_buf.find('\n') { + let line = line_buf[..=newline_pos].to_string(); + line_buf = line_buf[newline_pos + 1..].to_string(); + + match sse_parse_line(&line, &mut event) { + SseParseResult::Next => {} + SseParseResult::Dispatch => { + if event.is_empty() { + event.clear(); + continue; + } + + event_count += 1; + let event_type = event.event_type.clone().unwrap_or_default(); + + if event_type == "event" { + let data = event.data.trim(); + let events: Vec = if data.starts_with('[') { + serde_json::from_str(data).unwrap_or_default() + } else { + serde_json::from_str(data) + .map(|e| vec![e]) + .unwrap_or_else(|_| { + tracing::warn!("[SSE] 无法解析事件数据: {}", data); + Vec::new() + }) + }; + + for ev in &events { + tracing::info!( + "[SSE] 原始事件: type={}, file_id={}, from={}, to={}", + ev.event_type, ev.file_id, ev.from, ev.to + ); + if let Some(remote_event) = Self::parse_sse_event(ev, remote_root) { + if tx.send(remote_event).await.is_err() { + return Ok(()); + } + } + } + } else if event_type == "reconnect-required" { + tracing::info!("[SSE] 服务端要求重连"); + return Ok(()); + } else if event_type == "subscribed" { + tracing::info!("[SSE] 订阅确认成功"); + } else if event_type == "keep-alive" { + } else { + tracing::debug!( + "[SSE] 忽略未知事件: type={:?}, data={}", + event.event_type, + event.data.trim() + ); + } + + event.clear(); + } + SseParseResult::SetRetry(retry) => { + tracing::debug!("[SSE] 服务端设置重试间隔: {:?}", retry); + } + } + } + } + + tracing::info!("[SSE] 流结束,共处理 {} 个事件", event_count); + Ok(()) + } + + /// 将 SSE 文件事件转为内部 RemoteFileEvent + fn parse_sse_event(ev: &SseFileEvent, remote_root: &str) -> Option { + let full_uri = |path: &str| -> String { + if path.starts_with("cloudreve://") || path.starts_with("http") { + path.to_string() + } else { + format!("{}{}", remote_root.trim_end_matches('/'), path) + } + }; + + match ev.event_type.as_str() { + "create" | "modify" => { + let uri = full_uri(&ev.from); + Some(RemoteFileEvent::Modified(RemoteFileEntry { + name: ev.from.split('/').next_back() + .unwrap_or("").to_string(), + path: ev.from.clone(), + uri, + size: 0, + mtime_ms: 0, + hash: None, + is_dir: false, + file_id: Some(ev.file_id.clone()), + created_at_ms: 0, + })) + } + "delete" => { + let uri = full_uri(&ev.from); + Some(RemoteFileEvent::Deleted { + uri, + name: ev.from.split('/').next_back() + .unwrap_or("").to_string(), + }) + } + "rename" | "move" => { + let old_uri = full_uri(&ev.from); + let new_uri = full_uri(&ev.to); + let new_entry = RemoteFileEntry { + name: ev.to.split('/').next_back() + .unwrap_or("").to_string(), + path: ev.to.clone(), + uri: new_uri, + size: 0, + mtime_ms: 0, + hash: None, + is_dir: false, + file_id: Some(ev.file_id.clone()), + created_at_ms: 0, + }; + + let from_parent = ev.from.rfind('/').map(|i| &ev.from[..i]).unwrap_or(""); + let to_parent = ev.to.rfind('/').map(|i| &ev.to[..i]).unwrap_or(""); + + if from_parent == to_parent { + Some(RemoteFileEvent::Renamed { old_uri, new_entry }) + } else { + Some(RemoteFileEvent::Moved { old_uri, new_entry }) + } + } + _ => { + tracing::debug!("[SSE] 忽略未知事件类型: {}", ev.event_type); + None + } + } + } +} + +/// 事件防抖器:同一文件在 debounce_window 内的多次变更合并为一次 +pub struct EventDebouncer { + pending: HashMap, + debounce_window: Duration, +} + +impl EventDebouncer { + pub fn new(debounce_window: Duration) -> Self { + Self { + pending: HashMap::new(), + debounce_window, + } + } + + pub fn should_process(&mut self, path: &PathBuf) -> bool { + let now = Instant::now(); + if let Some(last) = self.pending.get(path) { + if now.duration_since(*last) < self.debounce_window { + self.pending.insert(path.clone(), now); + return false; + } + } + self.pending.insert(path.clone(), now); + true + } + + pub fn cleanup(&mut self) { + let now = Instant::now(); + self.pending.retain(|_, last| now.duration_since(*last) < self.debounce_window); + } +} + +/// 收集时间窗口内的批量远程事件 +pub async fn batch_remote_events( + rx: &mut mpsc::Receiver, + window: Duration, +) -> Vec { + let mut events = Vec::new(); + + match tokio::time::timeout(window, rx.recv()).await { + Ok(Some(event)) => events.push(event), + _ => return events, + } + + let deadline = Instant::now() + window; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Some(event)) => events.push(event), + _ => break, + } + } + + events +} diff --git a/native/sync-core/src/event_handler/sse.rs b/native/sync-core/src/event_handler/sse.rs new file mode 100644 index 0000000..4d3c3cb --- /dev/null +++ b/native/sync-core/src/event_handler/sse.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +/// SSE 事件缓冲区 +#[derive(Default)] +pub(crate) struct SseEvent { + pub event_type: Option, + pub data: String, + pub id: Option, +} + +impl SseEvent { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.event_type.is_none() && self.data.is_empty() + } + + pub fn clear(&mut self) { + self.event_type = None; + self.data.clear(); + self.id = None; + } +} + +pub(crate) enum SseParseResult { + Next, + Dispatch, + SetRetry(Duration), +} + +pub(crate) fn sse_parse_line(line: &str, event: &mut SseEvent) -> SseParseResult { + let line = line.trim_end_matches(['\r', '\n']); + + if line.is_empty() { + if event.is_empty() { + return SseParseResult::Next; + } + return SseParseResult::Dispatch; + } + + if line.starts_with(':') { + return SseParseResult::Next; + } + + let (field, value) = if let Some(colon_pos) = line.find(':') { + let field = &line[..colon_pos]; + let value = line[colon_pos + 1..].strip_prefix(' ').unwrap_or(&line[colon_pos + 1..]); + (field, value) + } else { + (line, "") + }; + + match field { + "event" => event.event_type = Some(value.to_string()), + "data" => { + if !event.data.is_empty() { + event.data.push('\n'); + } + event.data.push_str(value); + } + "id" => event.id = Some(value.to_string()), + "retry" => { + if let Ok(ms) = value.parse::() { + return SseParseResult::SetRetry(Duration::from_millis(ms)); + } + } + _ => {} + } + + SseParseResult::Next +} + +/// SSE 事件中的文件变更条目 +#[derive(Debug, serde::Deserialize)] +pub(crate) struct SseFileEvent { + #[serde(rename = "type")] + pub event_type: String, + pub file_id: String, + pub from: String, + #[serde(default)] + #[allow(dead_code)] + pub to: String, +} diff --git a/native/sync-core/src/event_sink.rs b/native/sync-core/src/event_sink.rs new file mode 100644 index 0000000..1086247 --- /dev/null +++ b/native/sync-core/src/event_sink.rs @@ -0,0 +1,57 @@ +use crate::api::ffi_types::SyncEventFfi; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Rust→Dart 事件推送 — 封装 FRB StreamSink +/// +/// **重要**: StreamSink.add() 需要 SyncEventFfi 实现 SseEncode trait, +/// 该 trait 由 flutter_rust_bridge_codegen 自动生成。 +/// 在运行 codegen 之前,emit() 仅写日志不实际推送。 +/// 运行 codegen 后,StreamSink 可用,emit() 将实际推送到 Dart。 +pub struct EventSink { + sink: Arc>>>, + available: std::sync::atomic::AtomicBool, +} + +impl Default for EventSink { + fn default() -> Self { + Self::new() + } +} + +impl EventSink { + pub fn new() -> Self { + Self { + sink: Arc::new(Mutex::new(None)), + available: std::sync::atomic::AtomicBool::new(false), + } + } + + /// 注册 StreamSink + pub async fn register(&self, sink: crate::frb_generated::StreamSink) { + self.available.store(true, std::sync::atomic::Ordering::Relaxed); + *self.sink.lock().await = Some(sink); + } + + /// 推送事件到 Dart + pub async fn emit(&self, event: SyncEventFfi) { + if self.available.load(std::sync::atomic::Ordering::Relaxed) { + self.emit_inner(event).await; + } + } + + /// 实际推送 — 仅在 StreamSink 可用时调用 + /// 此方法在 FRB codegen 生成 SseEncode 实现后才编译通过 + #[cfg(feature = "event_sink_enabled")] + async fn emit_inner(&self, event: SyncEventFfi) { + if let Some(sink) = self.sink.lock().await.as_ref() { + let _ = sink.add(event); + } + } + + #[cfg(not(feature = "event_sink_enabled"))] + async fn emit_inner(&self, _event: SyncEventFfi) { + // codegen 生成前为空操作,仅写日志 + tracing::debug!("EventSink: 事件未推送(FRB codegen 未运行)"); + } +} diff --git a/native/sync-core/src/file_lock.rs b/native/sync-core/src/file_lock.rs new file mode 100644 index 0000000..d4cfac2 --- /dev/null +++ b/native/sync-core/src/file_lock.rs @@ -0,0 +1,71 @@ +use dashmap::DashMap; +use std::sync::Arc; +use std::sync::Weak; +use tokio::sync::{Mutex, OwnedMutexGuard}; + +/// 文件锁注册表 — 防止同一文件被并发操作(如上传中删除) +pub struct FileLockRegistry { + locks: DashMap>>, +} + +impl Default for FileLockRegistry { + fn default() -> Self { + Self::new() + } +} + +impl FileLockRegistry { + pub fn new() -> Self { + Self { + locks: DashMap::new(), + } + } + + /// 阻塞等待获取文件锁,返回守卫 + pub async fn acquire(&self, path: &str) -> FileLockGuard<'_> { + let lock = self + .locks + .entry(path.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let guard = lock.lock_owned().await; + FileLockGuard { + registry: self, + path: path.to_string(), + _guard: guard, + } + } + + /// 移除无人等待的锁条目(守卫 Drop 时调用) + fn cleanup_if_unused(&self, path: &str) { + if let Some(entry) = self.locks.get(path) { + if Arc::strong_count(entry.value()) == 1 { + drop(entry); + self.locks.remove(path); + } + } + } + + /// 供外部引用计数判断 + pub fn get_weak(&self, path: &str) -> Weak> { + let lock = self + .locks + .entry(path.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + Arc::downgrade(&lock) + } +} + +/// 文件锁守卫 — Drop 时自动清理无人等待的锁条目 +pub struct FileLockGuard<'a> { + registry: &'a FileLockRegistry, + path: String, + _guard: OwnedMutexGuard<()>, +} + +impl Drop for FileLockGuard<'_> { + fn drop(&mut self) { + self.registry.cleanup_if_unused(&self.path); + } +} diff --git a/native/sync-core/src/frb_generated.rs b/native/sync-core/src/frb_generated.rs new file mode 100644 index 0000000..d7128f5 --- /dev/null +++ b/native/sync-core/src/frb_generated.rs @@ -0,0 +1,2270 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 264517153; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__ffi__check_cloud_album_dirs_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "check_cloud_album_dirs", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_base_uri = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = + crate::api::ffi::check_cloud_album_dirs(api_base_uri).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__create_cloud_album_dirs_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "create_cloud_album_dirs", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_base_uri = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = + crate::api::ffi::create_cloud_album_dirs(api_base_uri).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__dispose_sync_engine_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "dispose_sync_engine", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::dispose_sync_engine().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__force_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "force_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::force_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_active_tasks_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_active_tasks", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_active_tasks().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_active_worker_count_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_active_worker_count", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_active_worker_count().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_recent_tasks_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_recent_tasks", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_limit = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_recent_tasks(api_limit).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_sync_config_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_sync_config", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_sync_config().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_sync_status_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_sync_status", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_sync_status().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__get_task_detail_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_task_detail", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_task_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_task_detail(api_task_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__hydrate_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "hydrate_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_local_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::hydrate_file(api_local_path).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__init_sync_engine_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "init_sync_engine", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_config = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::init_sync_engine(api_config).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__pause_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "pause_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::pause_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__query_task_items_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "query_task_items", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_filter = + ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::query_task_items(api_filter).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__register_sync_event_sink_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "register_sync_event_sink", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_sink = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>((move || { + let output_ok = crate::api::ffi::register_sync_event_sink(api_sink)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__ffi__reset_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "reset_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::reset_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__resume_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "resume_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::resume_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__set_sync_log_level_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_sync_log_level", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_level = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>((move || { + let output_ok = crate::api::ffi::set_sync_log_level(api_level)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__ffi__start_continuous_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "start_continuous_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::start_continuous_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__start_initial_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "start_initial_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::start_initial_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__stop_sync_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "stop_sync", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::stop_sync().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__sync_album_to_cloud_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "sync_album_to_cloud", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_album_paths = >::sse_decode(&mut deserializer); + let api_remote_dcim_uri = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::sync_album_to_cloud( + api_album_paths, + api_remote_dcim_uri, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__sync_shutdown_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "sync_shutdown", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>((move || { + let output_ok = crate::api::ffi::sync_shutdown()?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__ffi__update_sync_config_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "update_sync_config", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_config = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::update_sync_config(api_config).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__ffi__update_tokens_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "update_tokens", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_access_token = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::update_tokens(api_access_token).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode + for StreamSink< + crate::api::ffi_types::SyncEventFfi, + flutter_rust_bridge::for_generated::SseCodec, + > +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for crate::api::ffi_types::CloudAlbumCheckResultFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_dcimExists = ::sse_decode(deserializer); + let mut var_picturesExists = ::sse_decode(deserializer); + let mut var_dcimUri = >::sse_decode(deserializer); + let mut var_picturesUri = >::sse_decode(deserializer); + return crate::api::ffi_types::CloudAlbumCheckResultFfi { + dcim_exists: var_dcimExists, + pictures_exists: var_picturesExists, + dcim_uri: var_dcimUri, + pictures_uri: var_picturesUri, + }; + } +} + +impl SseDecode for i64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i64::().unwrap() + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for crate::api::ffi_types::SyncConfigFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_baseUrl = ::sse_decode(deserializer); + let mut var_accessToken = ::sse_decode(deserializer); + let mut var_refreshToken = ::sse_decode(deserializer); + let mut var_localRoot = ::sse_decode(deserializer); + let mut var_remoteRoot = ::sse_decode(deserializer); + let mut var_syncMode = ::sse_decode(deserializer); + let mut var_conflictStrategy = ::sse_decode(deserializer); + let mut var_wcfDeleteMode = ::sse_decode(deserializer); + let mut var_maxConcurrentTransfers = ::sse_decode(deserializer); + let mut var_bandwidthLimitKbps = ::sse_decode(deserializer); + let mut var_excludedPaths = >::sse_decode(deserializer); + let mut var_maxWorkers = ::sse_decode(deserializer); + let mut var_dataDir = ::sse_decode(deserializer); + let mut var_clientId = ::sse_decode(deserializer); + let mut var_logLevel = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncConfigFfi { + base_url: var_baseUrl, + access_token: var_accessToken, + refresh_token: var_refreshToken, + local_root: var_localRoot, + remote_root: var_remoteRoot, + sync_mode: var_syncMode, + conflict_strategy: var_conflictStrategy, + wcf_delete_mode: var_wcfDeleteMode, + max_concurrent_transfers: var_maxConcurrentTransfers, + bandwidth_limit_kbps: var_bandwidthLimitKbps, + excluded_paths: var_excludedPaths, + max_workers: var_maxWorkers, + data_dir: var_dataDir, + client_id: var_clientId, + log_level: var_logLevel, + }; + } +} + +impl SseDecode for crate::api::ffi_types::SyncErrorFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::ffi_types::SyncErrorFfi::NotInitialized; + } + 1 => { + let mut var_message = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncErrorFfi::NetworkError { + message: var_message, + }; + } + 2 => { + let mut var_needed = ::sse_decode(deserializer); + let mut var_available = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncErrorFfi::DiskFull { + needed: var_needed, + available: var_available, + }; + } + 3 => { + let mut var_message = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncErrorFfi::AuthError { + message: var_message, + }; + } + 4 => { + let mut var_count = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncErrorFfi::ConflictError { count: var_count }; + } + 5 => { + let mut var_message = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncErrorFfi::InternalError { + message: var_message, + }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::ffi_types::SyncEventFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + let mut var_newState = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::StateChanged { + new_state: var_newState, + }; + } + 1 => { + let mut var_synced = ::sse_decode(deserializer); + let mut var_total = ::sse_decode(deserializer); + let mut var_currentFile = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::Progress { + synced: var_synced, + total: var_total, + current_file: var_currentFile, + }; + } + 2 => { + let mut var_localPath = ::sse_decode(deserializer); + let mut var_remoteUri = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::FileUploaded { + local_path: var_localPath, + remote_uri: var_remoteUri, + }; + } + 3 => { + let mut var_localPath = ::sse_decode(deserializer); + let mut var_remoteUri = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::FileDownloaded { + local_path: var_localPath, + remote_uri: var_remoteUri, + }; + } + 4 => { + let mut var_localPath = ::sse_decode(deserializer); + let mut var_conflictType = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::ConflictDetected { + local_path: var_localPath, + conflict_type: var_conflictType, + }; + } + 5 => { + let mut var_message = ::sse_decode(deserializer); + let mut var_recoverable = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::Error { + message: var_message, + recoverable: var_recoverable, + }; + } + 6 => { + return crate::api::ffi_types::SyncEventFfi::TokenExpired; + } + 7 => { + let mut var_availableMb = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::DiskSpaceWarning { + available_mb: var_availableMb, + }; + } + 8 => { + let mut var_summary = + ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::InitialSyncComplete { + summary: var_summary, + }; + } + 9 => { + let mut var_taskId = ::sse_decode(deserializer); + let mut var_trigger = ::sse_decode(deserializer); + let mut var_uploadCount = ::sse_decode(deserializer); + let mut var_downloadCount = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::WorkerStarted { + task_id: var_taskId, + trigger: var_trigger, + upload_count: var_uploadCount, + download_count: var_downloadCount, + }; + } + 10 => { + let mut var_taskId = ::sse_decode(deserializer); + let mut var_uploaded = ::sse_decode(deserializer); + let mut var_downloaded = ::sse_decode(deserializer); + let mut var_renamed = ::sse_decode(deserializer); + let mut var_moved = ::sse_decode(deserializer); + let mut var_failed = ::sse_decode(deserializer); + let mut var_durationMs = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::WorkerCompleted { + task_id: var_taskId, + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + failed: var_failed, + duration_ms: var_durationMs, + }; + } + 11 => { + let mut var_taskId = ::sse_decode(deserializer); + let mut var_message = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::WorkerFailed { + task_id: var_taskId, + message: var_message, + }; + } + 12 => { + let mut var_taskId = ::sse_decode(deserializer); + let mut var_relativePath = ::sse_decode(deserializer); + let mut var_action = ::sse_decode(deserializer); + let mut var_status = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncEventFfi::TaskItemUpdated { + task_id: var_taskId, + relative_path: var_relativePath, + action: var_action, + status: var_status, + }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for crate::api::ffi_types::SyncStatusFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_state = ::sse_decode(deserializer); + let mut var_syncedFiles = ::sse_decode(deserializer); + let mut var_totalFiles = ::sse_decode(deserializer); + let mut var_uploadingCount = ::sse_decode(deserializer); + let mut var_downloadingCount = ::sse_decode(deserializer); + let mut var_conflictCount = ::sse_decode(deserializer); + let mut var_errorCount = ::sse_decode(deserializer); + let mut var_lastSyncTime = >::sse_decode(deserializer); + let mut var_errorMessage = >::sse_decode(deserializer); + return crate::api::ffi_types::SyncStatusFfi { + state: var_state, + synced_files: var_syncedFiles, + total_files: var_totalFiles, + uploading_count: var_uploadingCount, + downloading_count: var_downloadingCount, + conflict_count: var_conflictCount, + error_count: var_errorCount, + last_sync_time: var_lastSyncTime, + error_message: var_errorMessage, + }; + } +} + +impl SseDecode for crate::api::ffi_types::SyncSummaryFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_uploaded = ::sse_decode(deserializer); + let mut var_downloaded = ::sse_decode(deserializer); + let mut var_renamed = ::sse_decode(deserializer); + let mut var_moved = ::sse_decode(deserializer); + let mut var_conflicts = ::sse_decode(deserializer); + let mut var_failed = ::sse_decode(deserializer); + let mut var_skipped = ::sse_decode(deserializer); + let mut var_deletedLocal = ::sse_decode(deserializer); + let mut var_deletedRemote = ::sse_decode(deserializer); + let mut var_durationMs = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncSummaryFfi { + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + conflicts: var_conflicts, + failed: var_failed, + skipped: var_skipped, + deleted_local: var_deletedLocal, + deleted_remote: var_deletedRemote, + duration_ms: var_durationMs, + }; + } +} + +impl SseDecode for crate::api::ffi_types::SyncTaskFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); + let mut var_trigger = ::sse_decode(deserializer); + let mut var_totalCount = ::sse_decode(deserializer); + let mut var_completedCount = ::sse_decode(deserializer); + let mut var_failedCount = ::sse_decode(deserializer); + let mut var_status = ::sse_decode(deserializer); + let mut var_createdAt = ::sse_decode(deserializer); + let mut var_updatedAt = ::sse_decode(deserializer); + let mut var_finishedAt = >::sse_decode(deserializer); + return crate::api::ffi_types::SyncTaskFfi { + id: var_id, + trigger: var_trigger, + total_count: var_totalCount, + completed_count: var_completedCount, + failed_count: var_failedCount, + status: var_status, + created_at: var_createdAt, + updated_at: var_updatedAt, + finished_at: var_finishedAt, + }; + } +} + +impl SseDecode for crate::api::ffi_types::SyncTaskItemFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); + let mut var_taskId = ::sse_decode(deserializer); + let mut var_relativePath = ::sse_decode(deserializer); + let mut var_actionType = ::sse_decode(deserializer); + let mut var_status = ::sse_decode(deserializer); + let mut var_fileSize = ::sse_decode(deserializer); + let mut var_errorMessage = >::sse_decode(deserializer); + let mut var_createdAt = ::sse_decode(deserializer); + let mut var_updatedAt = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncTaskItemFfi { + id: var_id, + task_id: var_taskId, + relative_path: var_relativePath, + action_type: var_actionType, + status: var_status, + file_size: var_fileSize, + error_message: var_errorMessage, + created_at: var_createdAt, + updated_at: var_updatedAt, + }; + } +} + +impl SseDecode for crate::api::ffi_types::TaskItemFilterFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_taskId = >::sse_decode(deserializer); + let mut var_relativePathContains = >::sse_decode(deserializer); + let mut var_actionType = >::sse_decode(deserializer); + let mut var_status = >::sse_decode(deserializer); + let mut var_limit = ::sse_decode(deserializer); + let mut var_offset = ::sse_decode(deserializer); + return crate::api::ffi_types::TaskItemFilterFfi { + task_id: var_taskId, + relative_path_contains: var_relativePathContains, + action_type: var_actionType, + status: var_status, + limit: var_limit, + offset: var_offset, + }; + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__ffi__check_cloud_album_dirs_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__ffi__create_cloud_album_dirs_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__ffi__dispose_sync_engine_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__ffi__force_sync_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__ffi__get_active_tasks_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__ffi__get_active_worker_count_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__ffi__get_recent_tasks_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__ffi__get_sync_config_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__ffi__get_sync_status_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__ffi__get_task_detail_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__ffi__hydrate_file_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__ffi__init_sync_engine_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__ffi__pause_sync_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__ffi__query_task_items_impl(port, ptr, rust_vec_len, data_len), + 15 => { + wire__crate__api__ffi__register_sync_event_sink_impl(port, ptr, rust_vec_len, data_len) + } + 16 => wire__crate__api__ffi__reset_sync_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__ffi__resume_sync_impl(port, ptr, rust_vec_len, data_len), + 18 => wire__crate__api__ffi__set_sync_log_level_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__ffi__start_continuous_sync_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__ffi__start_initial_sync_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__ffi__stop_sync_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__ffi__sync_album_to_cloud_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__ffi__sync_shutdown_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__ffi__update_sync_config_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__ffi__update_tokens_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::CloudAlbumCheckResultFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.dcim_exists.into_into_dart().into_dart(), + self.pictures_exists.into_into_dart().into_dart(), + self.dcim_uri.into_into_dart().into_dart(), + self.pictures_uri.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::CloudAlbumCheckResultFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::CloudAlbumCheckResultFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::CloudAlbumCheckResultFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncConfigFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.base_url.into_into_dart().into_dart(), + self.access_token.into_into_dart().into_dart(), + self.refresh_token.into_into_dart().into_dart(), + self.local_root.into_into_dart().into_dart(), + self.remote_root.into_into_dart().into_dart(), + self.sync_mode.into_into_dart().into_dart(), + self.conflict_strategy.into_into_dart().into_dart(), + self.wcf_delete_mode.into_into_dart().into_dart(), + self.max_concurrent_transfers.into_into_dart().into_dart(), + self.bandwidth_limit_kbps.into_into_dart().into_dart(), + self.excluded_paths.into_into_dart().into_dart(), + self.max_workers.into_into_dart().into_dart(), + self.data_dir.into_into_dart().into_dart(), + self.client_id.into_into_dart().into_dart(), + self.log_level.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncConfigFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncConfigFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncConfigFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncErrorFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::ffi_types::SyncErrorFfi::NotInitialized => [0.into_dart()].into_dart(), + crate::api::ffi_types::SyncErrorFfi::NetworkError { message } => { + [1.into_dart(), message.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncErrorFfi::DiskFull { needed, available } => [ + 2.into_dart(), + needed.into_into_dart().into_dart(), + available.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncErrorFfi::AuthError { message } => { + [3.into_dart(), message.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncErrorFfi::ConflictError { count } => { + [4.into_dart(), count.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncErrorFfi::InternalError { message } => { + [5.into_dart(), message.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncErrorFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncErrorFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncErrorFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncEventFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::ffi_types::SyncEventFfi::StateChanged { new_state } => { + [0.into_dart(), new_state.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncEventFfi::Progress { + synced, + total, + current_file, + } => [ + 1.into_dart(), + synced.into_into_dart().into_dart(), + total.into_into_dart().into_dart(), + current_file.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::FileUploaded { + local_path, + remote_uri, + } => [ + 2.into_dart(), + local_path.into_into_dart().into_dart(), + remote_uri.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::FileDownloaded { + local_path, + remote_uri, + } => [ + 3.into_dart(), + local_path.into_into_dart().into_dart(), + remote_uri.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::ConflictDetected { + local_path, + conflict_type, + } => [ + 4.into_dart(), + local_path.into_into_dart().into_dart(), + conflict_type.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::Error { + message, + recoverable, + } => [ + 5.into_dart(), + message.into_into_dart().into_dart(), + recoverable.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::TokenExpired => [6.into_dart()].into_dart(), + crate::api::ffi_types::SyncEventFfi::DiskSpaceWarning { available_mb } => { + [7.into_dart(), available_mb.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncEventFfi::InitialSyncComplete { summary } => { + [8.into_dart(), summary.into_into_dart().into_dart()].into_dart() + } + crate::api::ffi_types::SyncEventFfi::WorkerStarted { + task_id, + trigger, + upload_count, + download_count, + } => [ + 9.into_dart(), + task_id.into_into_dart().into_dart(), + trigger.into_into_dart().into_dart(), + upload_count.into_into_dart().into_dart(), + download_count.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::WorkerCompleted { + task_id, + uploaded, + downloaded, + renamed, + moved, + failed, + duration_ms, + } => [ + 10.into_dart(), + task_id.into_into_dart().into_dart(), + uploaded.into_into_dart().into_dart(), + downloaded.into_into_dart().into_dart(), + renamed.into_into_dart().into_dart(), + moved.into_into_dart().into_dart(), + failed.into_into_dart().into_dart(), + duration_ms.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::WorkerFailed { task_id, message } => [ + 11.into_dart(), + task_id.into_into_dart().into_dart(), + message.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::ffi_types::SyncEventFfi::TaskItemUpdated { + task_id, + relative_path, + action, + status, + } => [ + 12.into_dart(), + task_id.into_into_dart().into_dart(), + relative_path.into_into_dart().into_dart(), + action.into_into_dart().into_dart(), + status.into_into_dart().into_dart(), + ] + .into_dart(), + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncEventFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncEventFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncEventFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncStatusFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.state.into_into_dart().into_dart(), + self.synced_files.into_into_dart().into_dart(), + self.total_files.into_into_dart().into_dart(), + self.uploading_count.into_into_dart().into_dart(), + self.downloading_count.into_into_dart().into_dart(), + self.conflict_count.into_into_dart().into_dart(), + self.error_count.into_into_dart().into_dart(), + self.last_sync_time.into_into_dart().into_dart(), + self.error_message.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncStatusFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncStatusFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncStatusFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncSummaryFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.uploaded.into_into_dart().into_dart(), + self.downloaded.into_into_dart().into_dart(), + self.renamed.into_into_dart().into_dart(), + self.moved.into_into_dart().into_dart(), + self.conflicts.into_into_dart().into_dart(), + self.failed.into_into_dart().into_dart(), + self.skipped.into_into_dart().into_dart(), + self.deleted_local.into_into_dart().into_dart(), + self.deleted_remote.into_into_dart().into_dart(), + self.duration_ms.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncSummaryFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncSummaryFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncSummaryFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncTaskFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.id.into_into_dart().into_dart(), + self.trigger.into_into_dart().into_dart(), + self.total_count.into_into_dart().into_dart(), + self.completed_count.into_into_dart().into_dart(), + self.failed_count.into_into_dart().into_dart(), + self.status.into_into_dart().into_dart(), + self.created_at.into_into_dart().into_dart(), + self.updated_at.into_into_dart().into_dart(), + self.finished_at.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncTaskFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncTaskFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncTaskFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncTaskItemFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.id.into_into_dart().into_dart(), + self.task_id.into_into_dart().into_dart(), + self.relative_path.into_into_dart().into_dart(), + self.action_type.into_into_dart().into_dart(), + self.status.into_into_dart().into_dart(), + self.file_size.into_into_dart().into_dart(), + self.error_message.into_into_dart().into_dart(), + self.created_at.into_into_dart().into_dart(), + self.updated_at.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncTaskItemFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncTaskItemFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncTaskItemFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::TaskItemFilterFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.task_id.into_into_dart().into_dart(), + self.relative_path_contains.into_into_dart().into_dart(), + self.action_type.into_into_dart().into_dart(), + self.status.into_into_dart().into_dart(), + self.limit.into_into_dart().into_dart(), + self.offset.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::TaskItemFilterFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::TaskItemFilterFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::TaskItemFilterFfi { + self + } +} + +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode + for StreamSink< + crate::api::ffi_types::SyncEventFfi, + flutter_rust_bridge::for_generated::SseCodec, + > +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for crate::api::ffi_types::CloudAlbumCheckResultFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.dcim_exists, serializer); + ::sse_encode(self.pictures_exists, serializer); + >::sse_encode(self.dcim_uri, serializer); + >::sse_encode(self.pictures_uri, serializer); + } +} + +impl SseEncode for i64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i64::(self).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for crate::api::ffi_types::SyncConfigFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.base_url, serializer); + ::sse_encode(self.access_token, serializer); + ::sse_encode(self.refresh_token, serializer); + ::sse_encode(self.local_root, serializer); + ::sse_encode(self.remote_root, serializer); + ::sse_encode(self.sync_mode, serializer); + ::sse_encode(self.conflict_strategy, serializer); + ::sse_encode(self.wcf_delete_mode, serializer); + ::sse_encode(self.max_concurrent_transfers, serializer); + ::sse_encode(self.bandwidth_limit_kbps, serializer); + >::sse_encode(self.excluded_paths, serializer); + ::sse_encode(self.max_workers, serializer); + ::sse_encode(self.data_dir, serializer); + ::sse_encode(self.client_id, serializer); + ::sse_encode(self.log_level, serializer); + } +} + +impl SseEncode for crate::api::ffi_types::SyncErrorFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::ffi_types::SyncErrorFfi::NotInitialized => { + ::sse_encode(0, serializer); + } + crate::api::ffi_types::SyncErrorFfi::NetworkError { message } => { + ::sse_encode(1, serializer); + ::sse_encode(message, serializer); + } + crate::api::ffi_types::SyncErrorFfi::DiskFull { needed, available } => { + ::sse_encode(2, serializer); + ::sse_encode(needed, serializer); + ::sse_encode(available, serializer); + } + crate::api::ffi_types::SyncErrorFfi::AuthError { message } => { + ::sse_encode(3, serializer); + ::sse_encode(message, serializer); + } + crate::api::ffi_types::SyncErrorFfi::ConflictError { count } => { + ::sse_encode(4, serializer); + ::sse_encode(count, serializer); + } + crate::api::ffi_types::SyncErrorFfi::InternalError { message } => { + ::sse_encode(5, serializer); + ::sse_encode(message, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::ffi_types::SyncEventFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::ffi_types::SyncEventFfi::StateChanged { new_state } => { + ::sse_encode(0, serializer); + ::sse_encode(new_state, serializer); + } + crate::api::ffi_types::SyncEventFfi::Progress { + synced, + total, + current_file, + } => { + ::sse_encode(1, serializer); + ::sse_encode(synced, serializer); + ::sse_encode(total, serializer); + ::sse_encode(current_file, serializer); + } + crate::api::ffi_types::SyncEventFfi::FileUploaded { + local_path, + remote_uri, + } => { + ::sse_encode(2, serializer); + ::sse_encode(local_path, serializer); + ::sse_encode(remote_uri, serializer); + } + crate::api::ffi_types::SyncEventFfi::FileDownloaded { + local_path, + remote_uri, + } => { + ::sse_encode(3, serializer); + ::sse_encode(local_path, serializer); + ::sse_encode(remote_uri, serializer); + } + crate::api::ffi_types::SyncEventFfi::ConflictDetected { + local_path, + conflict_type, + } => { + ::sse_encode(4, serializer); + ::sse_encode(local_path, serializer); + ::sse_encode(conflict_type, serializer); + } + crate::api::ffi_types::SyncEventFfi::Error { + message, + recoverable, + } => { + ::sse_encode(5, serializer); + ::sse_encode(message, serializer); + ::sse_encode(recoverable, serializer); + } + crate::api::ffi_types::SyncEventFfi::TokenExpired => { + ::sse_encode(6, serializer); + } + crate::api::ffi_types::SyncEventFfi::DiskSpaceWarning { available_mb } => { + ::sse_encode(7, serializer); + ::sse_encode(available_mb, serializer); + } + crate::api::ffi_types::SyncEventFfi::InitialSyncComplete { summary } => { + ::sse_encode(8, serializer); + ::sse_encode(summary, serializer); + } + crate::api::ffi_types::SyncEventFfi::WorkerStarted { + task_id, + trigger, + upload_count, + download_count, + } => { + ::sse_encode(9, serializer); + ::sse_encode(task_id, serializer); + ::sse_encode(trigger, serializer); + ::sse_encode(upload_count, serializer); + ::sse_encode(download_count, serializer); + } + crate::api::ffi_types::SyncEventFfi::WorkerCompleted { + task_id, + uploaded, + downloaded, + renamed, + moved, + failed, + duration_ms, + } => { + ::sse_encode(10, serializer); + ::sse_encode(task_id, serializer); + ::sse_encode(uploaded, serializer); + ::sse_encode(downloaded, serializer); + ::sse_encode(renamed, serializer); + ::sse_encode(moved, serializer); + ::sse_encode(failed, serializer); + ::sse_encode(duration_ms, serializer); + } + crate::api::ffi_types::SyncEventFfi::WorkerFailed { task_id, message } => { + ::sse_encode(11, serializer); + ::sse_encode(task_id, serializer); + ::sse_encode(message, serializer); + } + crate::api::ffi_types::SyncEventFfi::TaskItemUpdated { + task_id, + relative_path, + action, + status, + } => { + ::sse_encode(12, serializer); + ::sse_encode(task_id, serializer); + ::sse_encode(relative_path, serializer); + ::sse_encode(action, serializer); + ::sse_encode(status, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for crate::api::ffi_types::SyncStatusFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.state, serializer); + ::sse_encode(self.synced_files, serializer); + ::sse_encode(self.total_files, serializer); + ::sse_encode(self.uploading_count, serializer); + ::sse_encode(self.downloading_count, serializer); + ::sse_encode(self.conflict_count, serializer); + ::sse_encode(self.error_count, serializer); + >::sse_encode(self.last_sync_time, serializer); + >::sse_encode(self.error_message, serializer); + } +} + +impl SseEncode for crate::api::ffi_types::SyncSummaryFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.uploaded, serializer); + ::sse_encode(self.downloaded, serializer); + ::sse_encode(self.renamed, serializer); + ::sse_encode(self.moved, serializer); + ::sse_encode(self.conflicts, serializer); + ::sse_encode(self.failed, serializer); + ::sse_encode(self.skipped, serializer); + ::sse_encode(self.deleted_local, serializer); + ::sse_encode(self.deleted_remote, serializer); + ::sse_encode(self.duration_ms, serializer); + } +} + +impl SseEncode for crate::api::ffi_types::SyncTaskFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); + ::sse_encode(self.trigger, serializer); + ::sse_encode(self.total_count, serializer); + ::sse_encode(self.completed_count, serializer); + ::sse_encode(self.failed_count, serializer); + ::sse_encode(self.status, serializer); + ::sse_encode(self.created_at, serializer); + ::sse_encode(self.updated_at, serializer); + >::sse_encode(self.finished_at, serializer); + } +} + +impl SseEncode for crate::api::ffi_types::SyncTaskItemFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); + ::sse_encode(self.task_id, serializer); + ::sse_encode(self.relative_path, serializer); + ::sse_encode(self.action_type, serializer); + ::sse_encode(self.status, serializer); + ::sse_encode(self.file_size, serializer); + >::sse_encode(self.error_message, serializer); + ::sse_encode(self.created_at, serializer); + ::sse_encode(self.updated_at, serializer); + } +} + +impl SseEncode for crate::api::ffi_types::TaskItemFilterFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.task_id, serializer); + >::sse_encode(self.relative_path_contains, serializer); + >::sse_encode(self.action_type, serializer); + >::sse_encode(self.status, serializer); + ::sse_encode(self.limit, serializer); + ::sse_encode(self.offset, serializer); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u64::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/native/sync-core/src/fs_scanner.rs b/native/sync-core/src/fs_scanner.rs new file mode 100644 index 0000000..52cacab --- /dev/null +++ b/native/sync-core/src/fs_scanner.rs @@ -0,0 +1,156 @@ +#[cfg(unix)] +use std::collections::HashSet; +use crate::errors::Result; +use crate::models::LocalFileEntry; +use crate::utils::quick_hash; +use std::path::Path; +use walkdir::WalkDir; + +/// 需要跳过的文件/目录名前缀和名称 +pub const SKIP_NAMES: &[&str] = &[ + ".DS_Store", + "Thumbs.db", + "desktop.ini", +]; + +/// 需要跳过的文件扩展名(同步临时文件) +pub const SKIP_EXTENSIONS: &[&str] = &[ + "sync_tmp", + "sync_temp", +]; + +pub struct FsScanner; + +impl Default for FsScanner { + fn default() -> Self { + Self::new() + } +} + +impl FsScanner { + pub fn new() -> Self { + Self + } + + /// 递归扫描本地目录 + /// `compute_hash`: 是否计算文件 quick_hash(MirrorWcf 模式下可跳过以加速扫描) + pub async fn scan( + &self, + root: &Path, + depth_limit: u32, + follow_symlinks: bool, + compute_hash: bool, + ) -> Result> { + let mut entries = Vec::new(); + #[cfg(unix)] + let mut visited_inodes: HashSet<(u64, u64)> = HashSet::new(); + + let walker = WalkDir::new(root) + .max_depth(depth_limit as usize) + .follow_links(follow_symlinks); + + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(e) => { + tracing::warn!("扫描跳过: {}", e); + continue; + } + }; + + // 符号链接处理 + if entry.path_is_symlink() && !follow_symlinks { + continue; + } + + // 跳过同步元数据文件和系统文件 + let file_name = entry.file_name().to_string_lossy(); + if SKIP_NAMES.iter().any(|s| file_name == *s) { + continue; + } + if file_name.starts_with(".sync_") { + continue; + } + // 跳过临时文件扩展名 + if let Some(ext) = entry.path().extension() { + if SKIP_EXTENSIONS.iter().any(|e| ext == *e) { + continue; + } + } + // 跳过冲突副本文件 + if crate::utils::is_conflict_file(&file_name) { + continue; + } + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(e) => { + tracing::warn!("无法读取元数据 {}: {}", entry.path().display(), e); + continue; + } + }; + + // inode 去重(防止硬链接/符号链接循环) + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let key = (metadata.dev(), metadata.ino()); + if !visited_inodes.insert(key) { + continue; + } + } + + let relative_path = entry.path().strip_prefix(root) + .unwrap_or(entry.path()) + .to_path_buf(); + + // 跳过根目录自身(relative_path 为空) + if relative_path.to_string_lossy().is_empty() { + continue; + } + + if metadata.is_dir() { + entries.push(LocalFileEntry { + relative_path, + size: 0, + mtime_ms: 0, + quick_hash: String::new(), + is_dir: true, + mime_type: None, + }); + } else if metadata.is_file() { + let size = metadata.len(); + let mtime_ms = metadata.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + + let hash = if compute_hash { + quick_hash(entry.path(), size).await.unwrap_or_default() + } else { + String::new() + }; + let mime_type = guess_mime_type(entry.path()); + + entries.push(LocalFileEntry { + relative_path, + size, + mtime_ms, + quick_hash: hash, + is_dir: false, + mime_type, + }); + } + } + + Ok(entries) + } +} + +/// 根据文件扩展名推断 MIME 类型 +pub fn guess_mime_type(path: &Path) -> Option { + mime_guess::from_path(path) + .first() + .map(|m| m.to_string()) +} diff --git a/native/sync-core/src/lib.rs b/native/sync-core/src/lib.rs new file mode 100644 index 0000000..fc451f1 --- /dev/null +++ b/native/sync-core/src/lib.rs @@ -0,0 +1,19 @@ +mod frb_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */ +pub mod api; +pub mod models; +pub mod errors; +pub mod utils; +pub mod sync_db; +pub mod api_client; +pub mod fs_scanner; +pub mod conflict_resolver; +pub mod transfer; +pub mod event_handler; +pub mod sync_engine; +pub mod file_lock; +pub mod diff; +pub mod uploader; +pub mod downloader; +pub mod worker; +pub mod event_sink; +pub mod platform; diff --git a/native/sync-core/src/models.rs b/native/sync-core/src/models.rs new file mode 100644 index 0000000..02bd295 --- /dev/null +++ b/native/sync-core/src/models.rs @@ -0,0 +1,601 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +// ===== 同步状态 ===== + +#[derive(Debug, Clone, PartialEq)] +pub enum SyncState { + Idle, + Initializing, + InitialSync { progress: InitialSyncProgress }, + Continuous, + Paused, + Error { message: String }, + Stopped, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct InitialSyncProgress { + pub scanned_local: u64, + pub scanned_remote: u64, + pub uploaded: u64, + pub downloaded: u64, + pub conflicts: u64, + pub total_to_sync: u64, +} + +// ===== 同步配置 ===== + +#[derive(Debug, Clone)] +pub struct SyncConfig { + pub base_url: String, + pub access_token: String, + pub refresh_token: String, + pub local_root: PathBuf, + pub remote_root: String, + pub sync_mode: SyncMode, + pub conflict_strategy: ConflictStrategy, + pub wcf_delete_mode: WcfDeleteMode, + pub max_concurrent_transfers: usize, + pub bandwidth_limit: Option, + pub excluded_paths: Vec, + pub max_workers: usize, + pub data_dir: PathBuf, + pub client_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SyncMode { + Full, + UploadOnly, + DownloadOnly, + Album, + MirrorWcf, +} + +// ===== 文件条目 ===== + +#[derive(Debug, Clone)] +pub struct LocalFileEntry { + pub relative_path: PathBuf, + pub size: u64, + pub mtime_ms: i64, + pub quick_hash: String, + pub is_dir: bool, + pub mime_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteFileEntry { + pub uri: String, + pub name: String, + pub size: u64, + pub mtime_ms: i64, + pub hash: Option, + pub is_dir: bool, + pub file_id: Option, + pub path: String, + pub created_at_ms: i64, +} + +// ===== 文件映射 ===== + +#[derive(Debug, Clone)] +pub struct FileMapping { + pub id: i64, + pub sync_root_id: String, + pub local_path: PathBuf, + pub remote_uri: String, + pub remote_file_id: Option, + pub local_hash: Option, + pub remote_hash: Option, + pub local_mtime: Option, + pub remote_mtime: Option, + pub local_size: Option, + pub remote_size: Option, + pub sync_status: SyncFileStatus, + pub is_placeholder: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SyncFileStatus { + Synced, + Uploading, + Downloading, + Conflict, + Placeholder, +} + +// ===== 冲突 ===== + +#[derive(Debug, Clone, PartialEq)] +pub enum ConflictType { + BothModified, + DeleteVsModify, + NameCollision, + TypeMismatch, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConflictStrategy { + KeepLocal, + KeepRemote, + KeepBoth, + NewestWins, + LargestWins, + Manual, +} + +/// 镜像同步(WCF)本地删除模式 — 仅 MirrorWcf 模式下生效 +#[derive(Debug, Clone, PartialEq)] +pub enum WcfDeleteMode { + /// 仅删除本地,保留远程(可重新水合) + LocalOnly, + /// 同步删除远程副本 + SyncRemote, +} + +#[derive(Debug, Clone)] +pub enum ConflictResolution { + UploadLocal, + DownloadRemote, + RenameLocal { new_name: String }, + DeleteLocal, + DeleteRemote, + MarkManual, +} + +// ===== 传输 ===== + +#[derive(Debug, Clone)] +pub struct TransferTask { + pub id: i64, + pub sync_root_id: String, + pub file_mapping_id: Option, + pub direction: TransferDirection, + pub local_path: PathBuf, + pub remote_uri: String, + pub file_size: u64, + pub bytes_done: u64, + pub status: TransferStatus, + pub retry_count: u32, + pub max_retries: u32, + pub error_message: Option, + pub session_id: Option, + pub chunk_index: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TransferDirection { + Upload, + Download, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TransferStatus { + Pending, + Active, + Paused, + Completed, + Failed, +} + +#[derive(Debug, Clone)] +pub struct TransferConfig { + pub max_concurrent: usize, + pub max_retries: u32, + pub retry_base_delay_ms: u64, + pub retry_max_delay_ms: u64, + pub bandwidth_limit: Option, + pub disk_space_reserve: u64, +} + +impl Default for TransferConfig { + fn default() -> Self { + Self { + max_concurrent: 3, + max_retries: 5, + retry_base_delay_ms: 1000, + retry_max_delay_ms: 60000, + bandwidth_limit: None, + disk_space_reserve: 1024 * 1024 * 1024, + } + } +} + +// ===== 同步摘要 ===== + +#[derive(Debug, Clone, Default)] +pub struct SyncSummary { + pub uploaded: u32, + pub downloaded: u32, + pub renamed: u32, + pub moved: u32, + pub conflicts: u32, + pub failed: u32, + pub skipped: u32, + pub deleted_local: u32, + pub deleted_remote: u32, + pub duration_ms: u64, +} + +// ===== 同步状态快照 ===== + +#[derive(Debug, Clone)] +pub struct SyncStatusSnapshot { + pub state: SyncState, + pub synced_files: u64, + pub total_files: u64, + pub uploading_count: u32, + pub downloading_count: u32, + pub conflict_count: u32, + pub error_count: u32, + pub last_sync_time: Option, + pub error_message: Option, +} + +// ===== 本地文件事件 ===== + +#[derive(Debug, Clone)] +pub enum LocalFileEvent { + Created(Vec), + Modified(Vec), + Deleted(Vec), + Renamed { old_paths: Vec, new_paths: Vec }, + Moved { old_paths: Vec, new_paths: Vec }, +} + +impl LocalFileEvent { + pub fn paths(&self) -> &[PathBuf] { + match self { + LocalFileEvent::Created(p) => p, + LocalFileEvent::Modified(p) => p, + LocalFileEvent::Deleted(p) => p, + LocalFileEvent::Renamed { .. } | LocalFileEvent::Moved { .. } => &[], + } + } +} + +// ===== 远程文件事件 ===== + +#[derive(Debug, Clone)] +pub enum RemoteFileEvent { + Created(RemoteFileEntry), + Modified(RemoteFileEntry), + Deleted { uri: String, name: String }, + Renamed { old_uri: String, new_entry: RemoteFileEntry }, + Moved { old_uri: String, new_entry: RemoteFileEntry }, +} + +// ===== 平台回调事件 (Windows CFApi) ===== + +#[derive(Debug, Clone)] +pub enum PlatformCallbackEvent { + HydrateRequested { + local_path: String, + transfer_key: i64, + }, +} + +// ===== 上传会话 ===== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadSession { + pub session_id: String, + pub chunk_size: u64, +} + +// ===== API 分页响应 ===== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListFilesResponse { + pub files: Vec, + pub pagination: Pagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pagination { + pub next_page_token: Option, + pub is_cursor: bool, + pub total: Option, +} + +// ===== 同步计划 ===== + +/// 重命名操作(本地触发 → 远程重命名,同目录下改文件名) +#[derive(Debug, Clone)] +pub struct RenameAction { + pub old_relative_path: String, + pub new_relative_path: String, + pub remote_uri: String, + pub new_name: String, +} + +/// 移动操作(本地触发 → 远程移动,跨目录) +#[derive(Debug, Clone)] +pub struct MoveAction { + pub old_relative_path: String, + pub new_relative_path: String, + pub remote_uri: String, + pub dst_remote_dir_uri: String, +} + +/// 本地重命名/移动操作(远程触发 → 本地执行) +#[derive(Debug, Clone)] +pub struct LocalRenameAction { + pub old_relative_path: String, + pub new_relative_path: String, + pub new_remote_uri: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SyncPlan { + pub uploads: Vec, + pub downloads: Vec, + pub delete_local: Vec, + pub delete_remote: Vec, + pub rename_remote: Vec, + pub move_remote: Vec, + pub rename_local: Vec, + pub move_local: Vec, + pub conflicts: Vec, + pub mkdirs_local: Vec, + pub mkdirs_remote: Vec, + /// 需要递归扫描的顶层目录(持续同步场景,目录整体提交给 Worker) + pub scan_dirs: Vec, +} + +impl SyncPlan { + /// 判断是否有实际操作需要执行(含 scan_dirs 则为非空,因为扫描可能发现新文件) + pub fn has_work(&self) -> bool { + !self.uploads.is_empty() + || !self.downloads.is_empty() + || !self.delete_local.is_empty() + || !self.delete_remote.is_empty() + || !self.rename_remote.is_empty() + || !self.move_remote.is_empty() + || !self.rename_local.is_empty() + || !self.move_local.is_empty() + || !self.conflicts.is_empty() + || !self.scan_dirs.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct SyncAction { + pub relative_path: String, + pub local_entry: Option, + pub remote_entry: Option, + pub db_mapping: Option, +} + +#[derive(Debug, Clone)] +pub struct SyncConflict { + pub relative_path: String, + pub conflict_type: ConflictType, + pub local_entry: Option, + pub remote_entry: Option, + pub db_mapping: Option, +} + +impl SyncPlan { + pub fn total_actions(&self) -> u64 { + self.uploads.len() as u64 + + self.downloads.len() as u64 + + self.delete_local.len() as u64 + + self.delete_remote.len() as u64 + + self.rename_remote.len() as u64 + + self.move_remote.len() as u64 + } +} + +// ===== Worker / Task 类型 ===== + +/// Worker 配置快照 — 创建时一次性读取,运行中不读任何锁 +#[derive(Debug, Clone)] +pub struct WorkerConfig { + pub local_root: PathBuf, + pub remote_root: String, + pub max_concurrent_transfers: usize, + pub bandwidth_limit: Option, + pub conflict_strategy: ConflictStrategy, + pub wcf_delete_mode: WcfDeleteMode, + pub sync_root_id: String, + pub sync_mode: SyncMode, +} + +/// Worker 触发来源 +#[derive(Debug, Clone, PartialEq)] +pub enum WorkerTrigger { + InitialSync, + Continuous, + Manual, +} + +impl WorkerTrigger { + pub fn as_str(&self) -> &'static str { + match self { + WorkerTrigger::InitialSync => "initial_sync", + WorkerTrigger::Continuous => "continuous", + WorkerTrigger::Manual => "manual", + } + } +} + +/// Worker 级别状态 +#[derive(Debug, Clone, PartialEq)] +pub enum WorkerStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl WorkerStatus { + pub fn as_str(&self) -> &'static str { + match self { + WorkerStatus::Pending => "pending", + WorkerStatus::Running => "running", + WorkerStatus::Completed => "completed", + WorkerStatus::Failed => "failed", + WorkerStatus::Cancelled => "cancelled", + } + } + +} + +impl std::str::FromStr for WorkerStatus { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "pending" => Ok(WorkerStatus::Pending), + "running" => Ok(WorkerStatus::Running), + "completed" => Ok(WorkerStatus::Completed), + "failed" => Ok(WorkerStatus::Failed), + "cancelled" => Ok(WorkerStatus::Cancelled), + _ => Err(()), + } + } +} + +/// 单文件操作状态 +#[derive(Debug, Clone, PartialEq)] +pub enum TaskItemStatus { + Pending, + Running, + Completed, + Failed, + Skipped, +} + +impl TaskItemStatus { + pub fn as_str(&self) -> &'static str { + match self { + TaskItemStatus::Pending => "pending", + TaskItemStatus::Running => "running", + TaskItemStatus::Completed => "completed", + TaskItemStatus::Failed => "failed", + TaskItemStatus::Skipped => "skipped", + } + } + +} + +impl std::str::FromStr for TaskItemStatus { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "pending" => Ok(TaskItemStatus::Pending), + "running" => Ok(TaskItemStatus::Running), + "completed" => Ok(TaskItemStatus::Completed), + "failed" => Ok(TaskItemStatus::Failed), + "skipped" => Ok(TaskItemStatus::Skipped), + _ => Err(()), + } + } +} + +/// 操作类型 — 精确记录每个文件的操作 +#[derive(Debug, Clone, PartialEq)] +pub enum TaskActionType { + Upload, + Download, + DeleteLocal, + DeleteRemote, + Rename, + Move, + MkdirRemote, + MkdirLocal, + ConflictResolve, + CreatePlaceholder, +} + +impl TaskActionType { + pub fn as_str(&self) -> &'static str { + match self { + TaskActionType::Upload => "upload", + TaskActionType::Download => "download", + TaskActionType::DeleteLocal => "delete_local", + TaskActionType::DeleteRemote => "delete_remote", + TaskActionType::Rename => "rename", + TaskActionType::Move => "move", + TaskActionType::MkdirRemote => "mkdir_remote", + TaskActionType::MkdirLocal => "mkdir_local", + TaskActionType::ConflictResolve => "conflict_resolve", + TaskActionType::CreatePlaceholder => "create_placeholder", + } + } + +} + +impl std::str::FromStr for TaskActionType { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "upload" => Ok(TaskActionType::Upload), + "download" => Ok(TaskActionType::Download), + "delete_local" => Ok(TaskActionType::DeleteLocal), + "delete_remote" => Ok(TaskActionType::DeleteRemote), + "rename" => Ok(TaskActionType::Rename), + "move" => Ok(TaskActionType::Move), + "mkdir_remote" => Ok(TaskActionType::MkdirRemote), + "mkdir_local" => Ok(TaskActionType::MkdirLocal), + "conflict_resolve" => Ok(TaskActionType::ConflictResolve), + "create_placeholder" => Ok(TaskActionType::CreatePlaceholder), + _ => Err(()), + } + } +} + +/// Worker 级别的任务摘要 — DB 持久化,供 UI 查询 +#[derive(Debug, Clone)] +pub struct SyncTask { + pub id: String, + pub trigger: WorkerTrigger, + pub total_count: u32, + pub completed_count: u32, + pub failed_count: u32, + pub status: WorkerStatus, + pub created_at: String, + pub updated_at: String, + pub finished_at: Option, +} + +/// 单文件操作记录 — DB 持久化,支持多维度查询 +#[derive(Debug, Clone)] +pub struct SyncTaskItem { + pub id: i64, + pub task_id: String, + pub relative_path: String, + pub action_type: TaskActionType, + pub status: TaskItemStatus, + pub file_size: u64, + pub error_message: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 任务项查询过滤器 +#[derive(Debug, Clone, Default)] +pub struct TaskItemFilter { + pub task_id: Option, + pub relative_path_contains: Option, + pub action_type: Option, + pub status: Option, + pub limit: u32, + pub offset: u32, +} + +// ===== 云端相册检查结果 ===== + +#[derive(Debug, Clone)] +pub struct CloudAlbumCheckResult { + pub dcim_exists: bool, + pub pictures_exists: bool, + pub dcim_uri: Option, + pub pictures_uri: Option, +} diff --git a/native/sync-core/src/platform/mod.rs b/native/sync-core/src/platform/mod.rs new file mode 100644 index 0000000..c63cd54 --- /dev/null +++ b/native/sync-core/src/platform/mod.rs @@ -0,0 +1,36 @@ +/// 平台适配器 trait — 各平台(Windows WCF / Linux / Android)实现此接口 +#[cfg(feature = "windows-cfapi")] +use async_trait::async_trait; +#[cfg(feature = "windows-cfapi")] +use crate::errors::Result; +#[cfg(feature = "windows-cfapi")] +use crate::models::{LocalFileEvent, RemoteFileEntry}; +#[cfg(feature = "windows-cfapi")] +use std::path::Path; +#[cfg(feature = "windows-cfapi")] +use tokio::sync::mpsc; + +#[cfg(feature = "windows-cfapi")] +#[async_trait] +pub trait PlatformAdapter: Send + Sync { + /// 初始同步后的平台初始化 + async fn post_initial_sync(&self, config: &crate::models::SyncConfig) -> Result<()>; + + /// 监听本地文件变化,返回事件接收器 + fn watch_local_changes(&self) -> Result>; + + /// 创建本地文件占位符(Windows)或实际文件(Linux) + async fn create_local_entry(&self, entry: &RemoteFileEntry, local_path: &Path) -> Result<()>; + + /// 水合文件 + async fn hydrate_file(&self, local_path: &Path, remote_url: &str) -> Result<()>; + + /// 脱水文件 + async fn dehydrate_file(&self, local_path: &Path) -> Result<()>; + + /// 关闭平台监听 + async fn shutdown(&self) -> Result<()>; +} + +#[cfg(feature = "windows-cfapi")] +pub mod wcf; diff --git a/native/sync-core/src/platform/wcf.rs b/native/sync-core/src/platform/wcf.rs new file mode 100644 index 0000000..58f8777 --- /dev/null +++ b/native/sync-core/src/platform/wcf.rs @@ -0,0 +1,152 @@ +/// Windows Cloud Filter API 平台适配器 +/// 仅在 windows-cfapi feature 启用时编译 +#[cfg(feature = "windows-cfapi")] +use std::path::Path; +#[cfg(feature = "windows-cfapi")] +use std::sync::Arc; +#[cfg(feature = "windows-cfapi")] +use tokio::sync::mpsc; + +#[cfg(feature = "windows-cfapi")] +use crate::api_client::ApiClient; +#[cfg(feature = "windows-cfapi")] +use crate::errors::Result; +#[cfg(feature = "windows-cfapi")] +use crate::models::SyncConfig; +#[cfg(feature = "windows-cfapi")] +use crate::sync_db::SyncDb; +#[cfg(feature = "windows-cfapi")] +use crate::worker::PlaceholderCreator; + +#[cfg(feature = "windows-cfapi")] +pub struct WcfPlatformAdapter { + adapter: std::sync::Mutex, + fetch_rx: std::sync::Mutex>>, + #[allow(dead_code)] + db: Arc, + #[allow(dead_code)] + api: Arc, + #[allow(dead_code)] + config: SyncConfig, +} + +#[cfg(feature = "windows-cfapi")] +impl WcfPlatformAdapter { + pub fn new( + db: Arc, + api: Arc, + config: SyncConfig, + ) -> anyhow::Result { + let mut adapter = sync_windows::WindowsAdapter::new(); + + // 注册同步根目录 + adapter.register_sync_root( + &config.local_root, + "Cloudreve4", + "1.0", + )?; + + // 连接同步根,注册回调 + adapter.connect_sync_root(&config.local_root)?; + let fetch_rx = adapter.take_callback_receiver(); + + tracing::info!("WcfPlatformAdapter 初始化完成: {}", config.local_root.display()); + + Ok(Self { + adapter: std::sync::Mutex::new(adapter), + fetch_rx: std::sync::Mutex::new(fetch_rx), + db, + api, + config, + }) + } + + /// 取走 FETCH_DATA 回调接收端(供 SyncEngine 持续同步消费) + pub fn take_fetch_receiver(&self) -> Option> { + self.fetch_rx.lock().unwrap().take() + } + + /// 创建占位符文件 + pub fn create_placeholder_for_remote( + &self, + base_dir: &Path, + file_name: &str, + file_size: u64, + remote_uri: &str, + remote_hash: Option<&str>, + remote_mtime_ms: i64, + ) -> Result<()> { + let file_identity = serde_json::to_vec(&serde_json::json!({ + "uri": remote_uri, + "size": file_size, + "hash": remote_hash.unwrap_or(""), + "mtime_ms": remote_mtime_ms, + })).unwrap_or_default(); + + self.adapter.lock().unwrap().create_single_placeholder( + base_dir, + file_name, + file_size, + &file_identity, + ).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))?; + + Ok(()) + } + + /// 水合文件(按需下载) + pub fn hydrate_file(&self, local_path: &Path) -> Result<()> { + self.adapter.lock().unwrap().hydrate_placeholder(local_path) + .map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } + + /// 脱水文件(释放本地空间) + pub fn dehydrate_file(&self, local_path: &Path) -> Result<()> { + self.adapter.lock().unwrap().dehydrate_placeholder(local_path) + .map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } + + /// 通过 CfExecute 将数据推送回 CFApi(内核层写入,绕过文件锁) + pub fn fulfill_fetch_data( + connection_key: i64, + transfer_key: i64, + data: &[u8], + offset: i64, + ) -> Result<()> { + sync_windows::WindowsAdapter::fulfill_fetch_data(connection_key, transfer_key, data, offset) + .map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } + + /// 通过 CfExecute 报告水合失败 + pub fn reject_fetch_data( + connection_key: i64, + transfer_key: i64, + ) -> Result<()> { + sync_windows::WindowsAdapter::reject_fetch_data(connection_key, transfer_key) + .map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } + + /// 断开连接 + pub fn disconnect(&self) -> Result<()> { + self.adapter.lock().unwrap().disconnect() + .map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } +} + +#[cfg(feature = "windows-cfapi")] +#[async_trait::async_trait] +impl PlaceholderCreator for WcfPlatformAdapter { + async fn create_placeholder_file( + &self, + base_dir: &Path, + file_name: String, + file_size: u64, + file_identity: &[u8], + ) -> Result<()> { + self.adapter.lock().unwrap().create_single_placeholder( + base_dir, + &file_name, + file_size, + file_identity, + ).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string())) + } +} diff --git a/native/sync-core/src/sync_db.rs b/native/sync-core/src/sync_db.rs new file mode 100644 index 0000000..0ea3267 --- /dev/null +++ b/native/sync-core/src/sync_db.rs @@ -0,0 +1,972 @@ +use r2d2::CustomizeConnection; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::Connection; +use std::path::Path; +use tokio::sync::Mutex; + +use crate::errors::Result; +use crate::models::*; + +pub struct SyncDb { + write_conn: Mutex, + read_pool: r2d2::Pool, +} + +#[derive(Debug)] +struct SyncDbConnectionCustomizer; + +impl CustomizeConnection for SyncDbConnectionCustomizer { + fn on_acquire(&self, conn: &mut Connection) -> std::result::Result<(), rusqlite::Error> { + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA busy_timeout=5000; + PRAGMA synchronous=NORMAL;", + )?; + Ok(()) + } +} + +impl SyncDb { + pub fn read_pool(&self) -> r2d2::Pool { + self.read_pool.clone() + } + + pub fn open(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let write_conn = Connection::open(db_path)?; + write_conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA busy_timeout=5000; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON; + PRAGMA temp_store=MEMORY;", + )?; + + Self::run_migrations(&write_conn)?; + + let manager = SqliteConnectionManager::file(db_path); + let read_pool = r2d2::Pool::builder() + .max_size(4) + .connection_customizer(Box::new(SyncDbConnectionCustomizer)) + .build(manager)?; + + Ok(Self { + write_conn: Mutex::new(write_conn), + read_pool, + }) + } + + fn run_migrations(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sync_root ( + id TEXT PRIMARY KEY, + local_path TEXT NOT NULL UNIQUE, + remote_uri TEXT NOT NULL, + sync_mode TEXT NOT NULL DEFAULT 'full', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS file_mapping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_root_id TEXT NOT NULL REFERENCES sync_root(id), + local_path TEXT NOT NULL, + remote_uri TEXT NOT NULL, + remote_file_id TEXT, + local_hash TEXT, + remote_hash TEXT, + local_mtime INTEGER, + remote_mtime INTEGER, + local_size INTEGER, + remote_size INTEGER, + sync_status TEXT NOT NULL DEFAULT 'synced', + is_placeholder INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + UNIQUE(sync_root_id, local_path) + ); + + CREATE INDEX IF NOT EXISTS idx_file_mapping_remote ON file_mapping(sync_root_id, remote_uri); + CREATE INDEX IF NOT EXISTS idx_file_mapping_status ON file_mapping(sync_status); + + CREATE TABLE IF NOT EXISTS conflict ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_root_id TEXT NOT NULL REFERENCES sync_root(id), + local_path TEXT NOT NULL, + conflict_type TEXT NOT NULL, + resolution TEXT, + local_hash TEXT, + remote_hash TEXT, + created_at TEXT NOT NULL, + resolved_at TEXT + ); + + CREATE TABLE IF NOT EXISTS transfer_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_root_id TEXT NOT NULL REFERENCES sync_root(id), + file_mapping_id INTEGER REFERENCES file_mapping(id), + direction TEXT NOT NULL, + local_path TEXT NOT NULL, + remote_uri TEXT NOT NULL, + file_size INTEGER NOT NULL, + bytes_done INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 5, + error_message TEXT, + session_id TEXT, + chunk_index INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_transfer_status ON transfer_queue(status); + + CREATE TABLE IF NOT EXISTS sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_root_id TEXT NOT NULL, + event_type TEXT NOT NULL, + details TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS album_sync_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_path TEXT NOT NULL UNIQUE, + remote_uri TEXT NOT NULL, + file_hash TEXT, + synced_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sync_task ( + id TEXT PRIMARY KEY, + trigger TEXT NOT NULL, + total_count INTEGER NOT NULL DEFAULT 0, + completed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + finished_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sync_task_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES sync_task(id), + relative_path TEXT NOT NULL, + action_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + file_size INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_task_item_task ON sync_task_item(task_id); + CREATE INDEX IF NOT EXISTS idx_task_item_path ON sync_task_item(relative_path); + CREATE INDEX IF NOT EXISTS idx_task_item_action ON sync_task_item(action_type); + CREATE INDEX IF NOT EXISTS idx_task_item_status ON sync_task_item(status); + CREATE INDEX IF NOT EXISTS idx_task_status ON sync_task(status);", + )?; + + // 添加 task_item 唯一索引(去重:保留 id 最小的记录) + // 先清理重复数据,再建唯一索引 + let existing: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_task_item_unique'")? + .query_map([], |r| r.get(0))? + .filter_map(|r| r.ok()) + .collect(); + if existing.is_empty() { + conn.execute_batch( + "DELETE FROM sync_task_item WHERE id NOT IN ( + SELECT MIN(id) FROM sync_task_item GROUP BY task_id, relative_path, action_type + ); + CREATE UNIQUE INDEX idx_task_item_unique ON sync_task_item(task_id, relative_path, action_type);" + )?; + } + + Ok(()) + } + + // ===== sync_root 操作 ===== + + pub async fn upsert_sync_root(&self, root: &SyncConfig) -> Result { + let local_path = root.local_root.to_string_lossy().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let sync_mode = match root.sync_mode { + SyncMode::Full => "full", + SyncMode::UploadOnly => "upload_only", + SyncMode::DownloadOnly => "download_only", + SyncMode::Album => "album", + SyncMode::MirrorWcf => "mirror_wcf", + }; + + let conn = self.write_conn.lock().await; + + // 先查找是否已存在 + let existing_id: Option = conn + .query_row( + "SELECT id FROM sync_root WHERE local_path = ?1", + rusqlite::params![local_path], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing_id { + // 更新已有记录,保留 id 和 created_at + conn.execute( + "UPDATE sync_root SET remote_uri = ?1, sync_mode = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![root.remote_root, sync_mode, now, id], + )?; + Ok(id) + } else { + // 新建记录 + let id = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO sync_root (id, local_path, remote_uri, sync_mode, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)", + rusqlite::params![id, local_path, root.remote_root, sync_mode, now], + )?; + Ok(id) + } + } + + // ===== file_mapping 操作 ===== + + pub async fn get_file_mapping( + &self, + sync_root_id: &str, + local_path: &str, + ) -> Result> { + let pool = self.read_pool.clone(); + let sync_root_id = sync_root_id.to_string(); + let local_path = local_path.to_string(); + + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder + FROM file_mapping WHERE sync_root_id = ?1 AND local_path = ?2", + )?; + + let mapping = stmt + .query_row(rusqlite::params![sync_root_id, local_path], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) + }) + .ok(); + + Ok(mapping) + }) + .await??; + + Ok(result) + } + + /// 通过远程 URI 查找文件映射(WCF 水合时使用) + pub async fn find_mapping_by_remote_uri( + &self, + sync_root_id: &str, + remote_uri: &str, + ) -> Result> { + let pool = self.read_pool.clone(); + let sync_root_id = sync_root_id.to_string(); + let remote_uri = remote_uri.to_string(); + + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder + FROM file_mapping WHERE sync_root_id = ?1 AND remote_uri = ?2", + )?; + + let mapping = stmt + .query_row(rusqlite::params![sync_root_id, remote_uri], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) + }) + .ok(); + + Ok(mapping) + }) + .await??; + + Ok(result) + } + + /// 查询所有占位符文件映射(用于 WCF 退出时清理) + #[cfg(feature = "windows-cfapi")] + pub async fn list_placeholder_mappings( + &self, + sync_root_id: &str, + ) -> Result> { + let pool = self.read_pool.clone(); + let sync_root_id = sync_root_id.to_string(); + + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder + FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", + )?; + + let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) + })?.filter_map(|m| m.ok()).collect(); + + Ok(mappings) + }).await??; + + Ok(result) + } + + pub async fn upsert_file_mapping(&self, mapping: &FileMapping) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + let status_str = sync_status_str(&mapping.sync_status); + let local_path = crate::utils::normalize_path(&mapping.local_path.to_string_lossy()); + let is_placeholder = if mapping.is_placeholder { 1 } else { 0 }; + + conn.execute( + "INSERT OR REPLACE INTO file_mapping + (sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + rusqlite::params![ + mapping.sync_root_id, + local_path, + mapping.remote_uri, + mapping.remote_file_id, + mapping.local_hash, + mapping.remote_hash, + mapping.local_mtime, + mapping.remote_mtime, + mapping.local_size, + mapping.remote_size, + status_str, + is_placeholder, + now, + ], + )?; + Ok(()) + } + + pub async fn delete_file_mapping(&self, sync_root_id: &str, local_path: &str) -> Result { + let conn = self.write_conn.lock().await; + let rows = conn.execute( + "DELETE FROM file_mapping WHERE sync_root_id = ?1 AND local_path = ?2", + rusqlite::params![sync_root_id, local_path], + )?; + Ok(rows > 0) + } + + /// 更新文件映射的路径(重命名时使用) + pub async fn update_file_mapping_path( + &self, + sync_root_id: &str, + old_relative_path: &str, + new_relative_path: &str, + new_remote_uri: &str, + ) -> Result { + let conn = self.write_conn.lock().await; + let rows = conn.execute( + "UPDATE file_mapping SET local_path = ?1, remote_uri = ?2 WHERE sync_root_id = ?3 AND local_path = ?4", + rusqlite::params![new_relative_path, new_remote_uri, sync_root_id, old_relative_path], + )?; + Ok(rows > 0) + } + + /// 删除指定目录前缀下的所有文件映射(含目录自身) + pub async fn delete_file_mapping_prefix( + &self, + sync_root_id: &str, + dir_prefix: &str, + ) -> Result { + let conn = self.write_conn.lock().await; + // 匹配: dir_prefix 自身、dir_prefix/... 子路径 + let exact = dir_prefix; + let child_prefix = format!("{}/", dir_prefix); + let rows = conn.execute( + "DELETE FROM file_mapping WHERE sync_root_id = ?1 AND (local_path = ?2 OR local_path LIKE ?3 || '%')", + rusqlite::params![sync_root_id, exact, child_prefix], + )?; + Ok(rows as u64) + } + + // ===== transfer_queue 操作 ===== + + pub async fn add_transfer(&self, task: &TransferTask) -> Result { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + let direction = match task.direction { + TransferDirection::Upload => "upload", + TransferDirection::Download => "download", + }; + let status = transfer_status_str(&task.status); + let local_path = task.local_path.to_string_lossy().to_string(); + + conn.execute( + "INSERT INTO transfer_queue + (sync_root_id, file_mapping_id, direction, local_path, remote_uri, + file_size, bytes_done, status, retry_count, max_retries, + error_message, session_id, chunk_index, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14)", + rusqlite::params![ + task.sync_root_id, + task.file_mapping_id, + direction, + local_path, + task.remote_uri, + task.file_size, + task.bytes_done, + status, + task.retry_count, + task.max_retries, + task.error_message, + task.session_id, + task.chunk_index, + now, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + pub async fn update_transfer_progress(&self, id: i64, bytes_done: u64) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE transfer_queue SET bytes_done = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![bytes_done, now, id], + )?; + Ok(()) + } + + pub async fn complete_transfer(&self, id: i64) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE transfer_queue SET status = 'completed', updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, id], + )?; + Ok(()) + } + + pub async fn fail_transfer(&self, id: i64, error: &str) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE transfer_queue SET status = 'failed', error_message = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![error, now, id], + )?; + Ok(()) + } + + pub async fn get_interrupted_transfers(&self) -> Result> { + let pool = self.read_pool.clone(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, file_mapping_id, direction, local_path, remote_uri, + file_size, bytes_done, status, retry_count, max_retries, + error_message, session_id, chunk_index + FROM transfer_queue WHERE status IN ('active', 'pending')", + )?; + let tasks = stmt + .query_map([], |row| Ok(transfer_task_from_row(row)))? + .filter_map(|t| t.ok()) + .collect(); + Ok(tasks) + }) + .await??; + Ok(result) + } + + // ===== album_sync_record 操作 ===== + + pub async fn get_album_sync_records( + &self, + ) -> Result> { + let pool = self.read_pool.clone(); + let result = tokio::task::spawn_blocking( + move || -> Result> { + let conn = pool.get()?; + let mut stmt = + conn.prepare("SELECT local_path, remote_uri FROM album_sync_record")?; + let records = stmt + .query_map([], |row| { + let local: String = row.get(0)?; + let remote: String = row.get(1)?; + Ok((local, remote)) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(records) + }, + ) + .await??; + Ok(result) + } + + pub async fn add_album_sync_record( + &self, + local_path: &str, + remote_uri: &str, + file_hash: &str, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT OR REPLACE INTO album_sync_record (local_path, remote_uri, file_hash, synced_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![local_path, remote_uri, file_hash, now], + )?; + Ok(()) + } + + // ===== sync_log 操作 ===== + + pub async fn add_log( + &self, + sync_root_id: &str, + event_type: &str, + details: Option<&str>, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO sync_log (sync_root_id, event_type, details, created_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![sync_root_id, event_type, details, now], + )?; + Ok(()) + } + + // ===== sync_task 操作 ===== + + pub async fn create_sync_task(&self, task: &SyncTask) -> Result<()> { + let conn = self.write_conn.lock().await; + conn.execute( + "INSERT INTO sync_task (id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + task.id, + task.trigger.as_str(), + task.total_count, + task.completed_count, + task.failed_count, + task.status.as_str(), + task.created_at, + task.updated_at, + task.finished_at, + ], + )?; + Ok(()) + } + + pub async fn update_sync_task_status( + &self, + task_id: &str, + status: &WorkerStatus, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task SET status = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![status.as_str(), now, task_id], + )?; + Ok(()) + } + + pub async fn update_sync_task_total_count( + &self, + task_id: &str, + total_count: u32, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task SET total_count = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![total_count, now, task_id], + )?; + Ok(()) + } + + pub async fn finish_sync_task( + &self, + task_id: &str, + status: &WorkerStatus, + completed: u32, + failed: u32, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task SET status = ?1, completed_count = ?2, failed_count = ?3, updated_at = ?4, finished_at = ?5 WHERE id = ?6", + rusqlite::params![status.as_str(), completed, failed, now, now, task_id], + )?; + Ok(()) + } + + pub async fn increment_task_completed(&self, task_id: &str) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task SET completed_count = completed_count + 1, updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, task_id], + )?; + Ok(()) + } + + pub async fn increment_task_failed(&self, task_id: &str) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task SET failed_count = failed_count + 1, updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, task_id], + )?; + Ok(()) + } + + pub async fn get_active_sync_tasks(&self) -> Result> { + let pool = self.read_pool.clone(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at + FROM sync_task WHERE status IN ('pending', 'running') + ORDER BY created_at DESC" + )?; + let tasks = stmt.query_map([], sync_task_from_row)? + .filter_map(|t| t.ok()).collect(); + Ok(tasks) + }).await??; + Ok(result) + } + + pub async fn get_recent_sync_tasks(&self, limit: u32) -> Result> { + let pool = self.read_pool.clone(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at + FROM sync_task ORDER BY created_at DESC LIMIT ?1" + )?; + let tasks = stmt.query_map(rusqlite::params![limit], sync_task_from_row)? + .filter_map(|t| t.ok()).collect(); + Ok(tasks) + }).await??; + Ok(result) + } + + // ===== sync_task_item 操作 ===== + + pub async fn create_sync_task_item(&self, item: &SyncTaskItem) -> Result { + let conn = self.write_conn.lock().await; + conn.execute( + "INSERT INTO sync_task_item (task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + item.task_id, + item.relative_path, + item.action_type.as_str(), + item.status.as_str(), + item.file_size, + item.error_message, + item.created_at, + item.updated_at, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// 批量插入 task_item(单次持锁,用事务包裹) + pub async fn create_sync_task_items_batch(&self, items: &[SyncTaskItem]) -> Result<()> { + let conn = self.write_conn.lock().await; + let tx = conn.unchecked_transaction()?; + for item in items { + tx.execute( + "INSERT INTO sync_task_item (task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + item.task_id, + item.relative_path, + item.action_type.as_str(), + item.status.as_str(), + item.file_size, + item.error_message, + item.created_at, + item.updated_at, + ], + )?; + } + tx.commit()?; + Ok(()) + } + + pub async fn update_sync_task_item_status( + &self, + item_id: i64, + status: &TaskItemStatus, + error_message: Option<&str>, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task_item SET status = ?1, error_message = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![status.as_str(), error_message, now, item_id], + )?; + Ok(()) + } + + /// 按 task_id + relative_path + action_type 更新 task_item 状态 + pub async fn update_task_item_status_by_path( + &self, + task_id: &str, + relative_path: &str, + action_type: &str, + status: &TaskItemStatus, + error_message: Option<&str>, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE sync_task_item SET status = ?1, error_message = ?2, updated_at = ?3 WHERE task_id = ?4 AND relative_path = ?5 AND action_type = ?6", + rusqlite::params![status.as_str(), error_message, now, task_id, relative_path, action_type], + )?; + Ok(()) + } + + pub async fn get_sync_task_items(&self, task_id: &str) -> Result> { + let pool = self.read_pool.clone(); + let task_id = task_id.to_string(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at + FROM sync_task_item WHERE task_id = ?1 + ORDER BY CASE status + WHEN 'running' THEN 0 + WHEN 'pending' THEN 1 + WHEN 'failed' THEN 2 + WHEN 'completed' THEN 3 + WHEN 'skipped' THEN 4 + ELSE 5 END, id" + )?; + let items = stmt.query_map(rusqlite::params![task_id], sync_task_item_from_row)? + .filter_map(|i| i.ok()).collect(); + Ok(items) + }).await??; + Ok(result) + } + + pub async fn query_task_items(&self, filter: &TaskItemFilter) -> Result> { + let pool = self.read_pool.clone(); + let filter = filter.clone(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut sql = String::from( + "SELECT id, task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at + FROM sync_task_item WHERE 1=1" + ); + let mut params: Vec> = Vec::new(); + + if let Some(ref task_id) = filter.task_id { + sql.push_str(" AND task_id = ?"); + params.push(Box::new(task_id.clone())); + } + if let Some(ref path_contains) = filter.relative_path_contains { + sql.push_str(" AND relative_path LIKE ?"); + params.push(Box::new(format!("%{}%", path_contains))); + } + if let Some(ref action_type) = filter.action_type { + sql.push_str(" AND action_type = ?"); + params.push(Box::new(action_type.clone())); + } + if let Some(ref status) = filter.status { + sql.push_str(" AND status = ?"); + params.push(Box::new(status.clone())); + } + + sql.push_str(" ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'pending' THEN 1 WHEN 'failed' THEN 2 WHEN 'completed' THEN 3 WHEN 'skipped' THEN 4 ELSE 5 END, id DESC LIMIT ? OFFSET ?"); + params.push(Box::new(filter.limit)); + params.push(Box::new(filter.offset)); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let items = stmt.query_map(param_refs.as_slice(), sync_task_item_from_row)? + .filter_map(|i| i.ok()).collect(); + Ok(items) + }).await??; + Ok(result) + } + + /// 清空所有同步数据(保留 sync_root 和 album_sync_record) + pub async fn reset_sync_data(&self) -> Result<()> { + let conn = self.write_conn.lock().await; + conn.execute_batch( + "DELETE FROM sync_task_item; + DELETE FROM sync_task; + DELETE FROM file_mapping; + DELETE FROM conflict; + DELETE FROM transfer_queue; + DELETE FROM sync_log;", + )?; + Ok(()) + } +} + +pub fn parse_sync_status(s: &str) -> SyncFileStatus { + match s { + "uploading" => SyncFileStatus::Uploading, + "downloading" => SyncFileStatus::Downloading, + "conflict" => SyncFileStatus::Conflict, + "placeholder" => SyncFileStatus::Placeholder, + _ => SyncFileStatus::Synced, + } +} + +fn sync_status_str(s: &SyncFileStatus) -> &'static str { + match s { + SyncFileStatus::Synced => "synced", + SyncFileStatus::Uploading => "uploading", + SyncFileStatus::Downloading => "downloading", + SyncFileStatus::Conflict => "conflict", + SyncFileStatus::Placeholder => "placeholder", + } +} + +fn transfer_status_str(s: &TransferStatus) -> &'static str { + match s { + TransferStatus::Pending => "pending", + TransferStatus::Active => "active", + TransferStatus::Paused => "paused", + TransferStatus::Completed => "completed", + TransferStatus::Failed => "failed", + } +} + +fn transfer_task_from_row(row: &rusqlite::Row<'_>) -> TransferTask { + let direction_str: String = row.get(3).unwrap_or_default(); + let direction = if direction_str == "upload" { + TransferDirection::Upload + } else { + TransferDirection::Download + }; + let status_str: String = row.get(8).unwrap_or_default(); + let status = match status_str.as_str() { + "active" => TransferStatus::Active, + "paused" => TransferStatus::Paused, + "completed" => TransferStatus::Completed, + "failed" => TransferStatus::Failed, + _ => TransferStatus::Pending, + }; + + TransferTask { + id: row.get(0).unwrap_or(0), + sync_root_id: row.get(1).unwrap_or_default(), + file_mapping_id: row.get(2).ok(), + direction, + local_path: std::path::PathBuf::from(row.get::<_, String>(4).unwrap_or_default()), + remote_uri: row.get(5).unwrap_or_default(), + file_size: row.get(6).unwrap_or(0), + bytes_done: row.get(7).unwrap_or(0), + status, + retry_count: row.get(9).unwrap_or(0), + max_retries: row.get(10).unwrap_or(5), + error_message: row.get(11).ok(), + session_id: row.get(12).ok(), + chunk_index: row.get(13).ok(), + } +} + +fn sync_task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let trigger_str: String = row.get(1)?; + let trigger = match trigger_str.as_str() { + "initial_sync" => WorkerTrigger::InitialSync, + "continuous" => WorkerTrigger::Continuous, + "manual" => WorkerTrigger::Manual, + _ => WorkerTrigger::Manual, + }; + let status_str: String = row.get(5)?; + Ok(SyncTask { + id: row.get(0)?, + trigger, + total_count: row.get(2)?, + completed_count: row.get(3)?, + failed_count: row.get(4)?, + status: status_str + .parse::() + .unwrap_or(WorkerStatus::Pending), + created_at: row.get(6)?, + updated_at: row.get(7)?, + finished_at: row.get(8)?, + }) +} + +fn sync_task_item_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let action_str: String = row.get(3)?; + let status_str: String = row.get(4)?; + Ok(SyncTaskItem { + id: row.get(0)?, + task_id: row.get(1)?, + relative_path: row.get(2)?, + action_type: action_str + .parse::() + .unwrap_or(TaskActionType::Upload), + status: status_str + .parse::() + .unwrap_or(TaskItemStatus::Pending), + file_size: row.get(5)?, + error_message: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} diff --git a/native/sync-core/src/sync_engine/album.rs b/native/sync-core/src/sync_engine/album.rs new file mode 100644 index 0000000..554623f --- /dev/null +++ b/native/sync-core/src/sync_engine/album.rs @@ -0,0 +1,63 @@ +use crate::errors::Result; +use crate::models::*; +use std::path::Path; + +use super::SyncEngine; + +impl SyncEngine { + pub async fn sync_album(&self, album_paths: Vec, remote_dcim_uri: &str) -> Result<()> { + let synced = self.db.get_album_sync_records().await?; + let new_photos: Vec<_> = album_paths.iter().filter(|p| !synced.contains_key(*p)).collect(); + let total = new_photos.len(); + if total == 0 { return Ok(()); } + + for (i, photo_path) in new_photos.iter().enumerate() { + let local_path = Path::new(photo_path); + let file_name = local_path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("photo_{}", i)); + + match tokio::fs::metadata(photo_path).await { + Ok(metadata) => { + let file_size = metadata.len(); + match self.api.create_upload_session(remote_dcim_uri, file_size, false, None, None, None).await { + Ok(session) => { + match crate::uploader::upload_file_chunked(&self.api, local_path, &session).await { + Ok(_) => { + let remote_uri = format!("{}/{}", remote_dcim_uri, file_name); + let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default(); + if let Err(e) = self.db.add_album_sync_record(photo_path, &remote_uri, &hash).await { + tracing::warn!("记录同步状态失败: {}", e); + } + tracing::info!("照片上传完成 ({}/{}): {}", i + 1, total, file_name); + } + Err(e) => tracing::error!("上传照片失败 {}: {}", file_name, e), + } + } + Err(e) => tracing::error!("创建上传会话失败 {}: {}", file_name, e), + } + } + Err(e) => tracing::warn!("无法读取照片元数据 {}: {}", photo_path, e), + } + } + Ok(()) + } + + pub async fn check_album_dirs(&self, base_uri: &str) -> Result { + let files = self.api.list_files_page(base_uri, 0, 200, None).await?; + let dcim_exists = files.files.iter().any(|f| f.name == "DCIM" && f.is_dir); + let pictures_exists = files.files.iter().any(|f| f.name == "Pictures" && f.is_dir); + Ok(CloudAlbumCheckResult { + dcim_exists, + pictures_exists, + dcim_uri: if dcim_exists { Some(format!("{}/DCIM", base_uri)) } else { None }, + pictures_uri: if pictures_exists { Some(format!("{}/Pictures", base_uri)) } else { None }, + }) + } + + pub async fn create_album_dirs(&self, base_uri: &str) -> Result<()> { + self.api.create_directory(base_uri, "DCIM").await?; + self.api.create_directory(base_uri, "Pictures").await?; + Ok(()) + } +} diff --git a/native/sync-core/src/sync_engine/continuous_sync.rs b/native/sync-core/src/sync_engine/continuous_sync.rs new file mode 100644 index 0000000..9fd517a --- /dev/null +++ b/native/sync-core/src/sync_engine/continuous_sync.rs @@ -0,0 +1,215 @@ +use crate::errors::Result; +use crate::event_handler::EventHandler; +use crate::models::*; + +use super::SyncEngine; + +impl SyncEngine { + /// 持续同步:双事件源驱动 (SSE + 本地文件监听),按 sync_mode 选择事件源 + pub async fn run_continuous(&self) -> Result<()> { + let event_handler = EventHandler::new( + self.api.clone(), + self.api.client_id().to_string(), + ); + + let (local_root, remote_root, sync_mode) = { + let config = self.config.read().await; + (config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone()) + }; + + // 仅 DownloadOnly、Full 和 MirrorWcf 订阅 SSE + let mut remote_rx = if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf) { + Some(event_handler.subscribe_sse(&remote_root).await?) + } else { + tracing::info!("仅上传模式: 不订阅 SSE 远程事件"); + None + }; + + // 仅 UploadOnly、Full 和 MirrorWcf 启动本地文件监听 + let mut local_rx = if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf) { + Some(spawn_local_watcher(&local_root, self.shutdown_token.lock().unwrap().clone())) + } else { + tracing::info!("仅下载模式: 不启动本地文件监听"); + None + }; + + *self.state.write().await = SyncState::Continuous; + tracing::info!("持续同步已启动, 模式={:?}", sync_mode); + + // MirrorWcf: 取走 WCF 回调接收端 + #[cfg(feature = "windows-cfapi")] + let mut wcf_fetch_rx = if matches!(sync_mode, SyncMode::MirrorWcf) { + self.wcf_fetch_rx.lock().unwrap().take() + } else { + None + }; + #[cfg(not(feature = "windows-cfapi"))] + let _wcf_fetch_rx: Option<()> = None; + + let mut debounce = crate::event_handler::EventDebouncer::new( + std::time::Duration::from_millis(500), + ); + + let shutdown_token = self.shutdown_token.lock().unwrap().clone(); + loop { + tokio::select! { + _ = shutdown_token.cancelled() => { + tracing::info!("持续同步收到停止信号"); + break; + } + + // 本地文件变化(仅 UploadOnly / Full / MirrorWcf) + Some(event) = async { + match &mut local_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let mut all_events = vec![event]; + let idle_timeout = std::time::Duration::from_secs(3); + if let Some(rx) = &mut local_rx { + while let Ok(Some(e)) = tokio::time::timeout(idle_timeout, rx.recv()).await { + all_events.push(e) + } + } + + self.handle_local_events(all_events, &local_root, &mut debounce).await; + } + + // 远程文件变化(DownloadOnly / Full / MirrorWcf) + Some(event) = async { + match &mut remote_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + self.handle_remote_event(event, &local_root, &remote_root).await; + } + + // WCF 水合请求(仅 MirrorWcf) + request = async { + #[cfg(feature = "windows-cfapi")] + { + match &mut wcf_fetch_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } + #[cfg(not(feature = "windows-cfapi"))] + { + let _: Option<()> = std::future::pending().await; + None::<()> + } + } => { + if let Some(req) = request { + #[cfg(feature = "windows-cfapi")] + self.handle_wcf_fetch(req, &local_root).await; + #[cfg(not(feature = "windows-cfapi"))] + let _: () = req; + } + } + + // 定期心跳 + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + tracing::trace!("持续同步心跳"); + debounce.cleanup(); + } + } + } + + Ok(()) + } +} + +/// 启动本地文件监听器,返回事件接收端 +fn spawn_local_watcher( + watch_root: &std::path::Path, + shutdown_token: tokio_util::sync::CancellationToken, +) -> tokio::sync::mpsc::Receiver { + let (local_tx, rx) = tokio::sync::mpsc::channel::(256); + let watch_root = watch_root.to_path_buf(); + + std::thread::spawn(move || { + use notify_debouncer_full::notify::{RecursiveMode, EventKind}; + use notify_debouncer_full::notify::event::{ModifyKind, RenameMode}; + use notify_debouncer_full::new_debouncer; + + let tx = local_tx.clone(); + let shutdown = shutdown_token.clone(); + + let mut debouncer = match new_debouncer( + std::time::Duration::from_millis(500), + None, + move |result: notify_debouncer_full::DebounceEventResult| { + match result { + Ok(events) => { + for event in events { + if shutdown.is_cancelled() { return; } + let kind = event.kind; + let paths = &event.paths; + + let filtered: Vec<_> = paths.iter() + .filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false)) + .cloned() + .collect(); + if filtered.is_empty() { continue; } + + match kind { + EventKind::Create(_) => { + let _ = tx.blocking_send(LocalFileEvent::Created(filtered)); + } + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {} + EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {} + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => { + if filtered.len() == 2 { + let _ = tx.blocking_send(LocalFileEvent::Renamed { + old_paths: vec![filtered[0].clone()], + new_paths: vec![filtered[1].clone()], + }); + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => { + let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); + } + EventKind::Modify(_) => { + let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); + } + EventKind::Remove(_) => { + let _ = tx.blocking_send(LocalFileEvent::Deleted(filtered)); + } + _ => {} + } + } + } + Err(errors) => { + for e in errors { + tracing::warn!("文件监听去抖错误: {}", e); + } + } + } + }, + ) { + Ok(d) => d, + Err(e) => { + tracing::error!("无法启动文件监听: {}", e); + return; + } + }; + + if let Err(e) = debouncer.watch(&watch_root, RecursiveMode::Recursive) { + tracing::error!("文件监听启动失败: {}", e); + return; + } + + tracing::info!("本地文件监听已启动(debouncer): {}", watch_root.display()); + + while !shutdown_token.is_cancelled() { + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + let _ = debouncer.unwatch(&watch_root); + tracing::info!("本地文件监听已停止"); + }); + + rx +} diff --git a/native/sync-core/src/sync_engine/initial_sync.rs b/native/sync-core/src/sync_engine/initial_sync.rs new file mode 100644 index 0000000..08bdcaf --- /dev/null +++ b/native/sync-core/src/sync_engine/initial_sync.rs @@ -0,0 +1,124 @@ +use crate::errors::{Result, SyncError}; +use crate::fs_scanner::FsScanner; +use crate::models::*; +use std::time::Instant; + +use super::SyncEngine; + +impl SyncEngine { + /// 初始全量同步(受 sync_lock 保护,同一时间只允许一个同步操作) + pub async fn run_initial_sync(&self) -> Result { + // 获取互斥锁:如果旧同步还在运行,等待其退出(旧同步的 token 已被取消,worker 会快速退出) + let _guard = self.sync_lock.lock().await; + + // 检查是否已被取消 + if self.shutdown_token.lock().unwrap().is_cancelled() { + tracing::info!("初始同步已取消,跳过"); + return Err(SyncError::Internal("同步已被取消".into())); + } + + let start = Instant::now(); + + *self.state.write().await = SyncState::Initializing; + + let (local_root, remote_root, sync_mode) = { + let config = self.config.read().await; + (config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone()) + }; + tracing::info!("开始初始同步, 模式={:?}", sync_mode); + + let scanner = FsScanner::new(); + tracing::info!("开始扫描本地文件系统: {}", local_root.display()); + // MirrorWcf 模式跳过 hash 计算(占位符 size=0 被 diff 跳过,水合文件 hash 在需要时再算) + let compute_hash = !matches!(sync_mode, SyncMode::MirrorWcf); + let local_files = scanner.scan(&local_root, 50, false, compute_hash).await?; + tracing::info!("本地扫描完成: {} 个条目", local_files.len()); + + if self.shutdown_token.lock().unwrap().is_cancelled() { + return Err(SyncError::Internal("同步已被取消".into())); + } + + tracing::info!("开始扫描远程文件树: {}", remote_root); + let remote_files = self.api.list_all_files(&remote_root).await?; + tracing::info!("远程扫描完成: {} 个条目", remote_files.len()); + + if self.shutdown_token.lock().unwrap().is_cancelled() { + return Err(SyncError::Internal("同步已被取消".into())); + } + + let db_mappings = self.load_all_mappings().await?; + let plan = crate::diff::compute_diff(&local_files, &remote_files, &db_mappings, &remote_root, &sync_mode); + tracing::info!( + "差异计算完成: 上传={}, 下载={}, 删本地={}, 删远程={}, 冲突={}", + plan.uploads.len(), + plan.downloads.len(), + plan.delete_local.len(), + plan.delete_remote.len(), + plan.conflicts.len(), + ); + + if self.shutdown_token.lock().unwrap().is_cancelled() { + return Err(SyncError::Internal("同步已被取消".into())); + } + + *self.state.write().await = SyncState::InitialSync { + progress: InitialSyncProgress { + scanned_local: local_files.len() as u64, + scanned_remote: remote_files.len() as u64, + total_to_sync: plan.total_actions(), + ..Default::default() + }, + }; + + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + + // MirrorWcf 模式:初始化 WCF 平台适配器(仅首次,重复初始化会触发 CFApi 重新水合) + #[cfg(feature = "windows-cfapi")] + if matches!(sync_mode, SyncMode::MirrorWcf) { + let already_initialized = self.platform_adapter.lock().unwrap().is_some(); + if !already_initialized { + let config = self.config.read().await; + let adapter = crate::platform::wcf::WcfPlatformAdapter::new( + self.db.clone(), + self.api.clone(), + config.clone(), + ).map_err(|e| crate::errors::SyncError::Internal(e.to_string()))?; + let fetch_rx = adapter.take_fetch_receiver(); + *self.wcf_fetch_rx.lock().unwrap() = fetch_rx; + let adapter_arc = std::sync::Arc::new(adapter); + *self.platform_adapter.lock().unwrap() = Some(adapter_arc.clone()); + self.worker_pool.set_platform_adapter(adapter_arc); + *self.cached_local_root.lock().unwrap() = config.local_root.clone(); + tracing::info!("MirrorWcf: WCF 平台适配器已初始化"); + } else { + tracing::info!("MirrorWcf: WCF 平台适配器已存在,跳过重复初始化"); + } + } + + let result = self.worker_pool.submit( + plan, worker_config, WorkerTrigger::InitialSync, conflict_resolver, + ).await; + + match result { + Ok(summary) => { + *self.state.write().await = SyncState::Continuous; + tracing::info!("初始同步完成, 耗时 {}ms", start.elapsed().as_millis()); + Ok(SyncSummary { + duration_ms: start.elapsed().as_millis() as u64, + ..summary + }) + } + Err(e) => { + // 被取消时不需要设 Error 状态 + if self.shutdown_token.lock().unwrap().is_cancelled() { + tracing::info!("初始同步已取消"); + Err(SyncError::Internal("同步已被取消".into())) + } else { + Err(e) + } + } + } + // _guard 在此处 drop,释放 sync_lock + } +} diff --git a/native/sync-core/src/sync_engine/local_events.rs b/native/sync-core/src/sync_engine/local_events.rs new file mode 100644 index 0000000..d822835 --- /dev/null +++ b/native/sync-core/src/sync_engine/local_events.rs @@ -0,0 +1,476 @@ +use crate::models::*; +use std::path::Path; + +use super::SyncEngine; + +impl SyncEngine { + /// 处理本地事件批次 + pub(crate) async fn handle_local_events( + &self, + all_events: Vec, + local_root: &std::path::Path, + debounce: &mut crate::event_handler::EventDebouncer, + ) { + // DownloadOnly: 忽略所有本地事件 + let (sync_mode, wcf_delete_mode) = { + let config = self.config.read().await; + (config.sync_mode.clone(), config.wcf_delete_mode.clone()) + }; + if matches!(sync_mode, SyncMode::DownloadOnly) { + return; + } + + let root_id = self.sync_root_id.clone().unwrap_or_default(); + + // 清理过期的 suppress 记录(超过 30 秒) + let now = std::time::Instant::now(); + self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30); + + // === 第一步:提取 Renamed/Moved 事件,查 DB 构建操作 === + let mut rename_remote: Vec = Vec::new(); + let mut move_remote: Vec = Vec::new(); + let mut handled_old_rels: std::collections::HashSet = std::collections::HashSet::new(); + let mut handled_new_rels: std::collections::HashSet = std::collections::HashSet::new(); + + for event in &all_events { + match event { + LocalFileEvent::Renamed { old_paths, new_paths } => { + for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) { + if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) { + if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) { + tracing::trace!("本地重命名被抑制(远程操作导致): {} -> {}", old_rel, new_rel); + continue; + } + let new_name = new_path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await { + tracing::info!("检测到本地重命名: {} -> {}", old_rel, new_rel); + rename_remote.push(RenameAction { + old_relative_path: old_rel.clone(), + new_relative_path: new_rel.clone(), + remote_uri: mapping.remote_uri.clone(), + new_name, + }); + handled_old_rels.insert(old_rel); + handled_new_rels.insert(new_rel); + } else { + tracing::info!("本地重命名但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel); + } + } + } + } + LocalFileEvent::Moved { old_paths, new_paths } => { + for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) { + if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) { + if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) { + tracing::trace!("本地移动被抑制(远程操作导致): {} -> {}", old_rel, new_rel); + continue; + } + if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await { + let remote_root = { self.config.read().await.remote_root.clone() }; + let new_rel_path = std::path::PathBuf::from(&new_rel); + let dst_dir_rel = new_rel_path.parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + let dst_remote_dir_uri = format!("{}/{}", + remote_root.trim_end_matches('/'), + dst_dir_rel.trim_start_matches('/')); + + tracing::info!("检测到本地移动: {} -> {}", old_rel, new_rel); + move_remote.push(MoveAction { + old_relative_path: old_rel.clone(), + new_relative_path: new_rel.clone(), + remote_uri: mapping.remote_uri.clone(), + dst_remote_dir_uri, + }); + handled_old_rels.insert(old_rel); + handled_new_rels.insert(new_rel); + } else { + tracing::info!("本地移动但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel); + } + } + } + } + _ => {} + } + } + + // === 第二步:按事件类型分类路径,跳过已识别为 rename/move 的路径 === + let mut create_paths: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut delete_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for event in &all_events { + for path in event.paths() { + if !debounce.should_process(path) { + continue; + } + + let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + if crate::fs_scanner::SKIP_NAMES.iter().any(|s| file_name == *s) + || file_name.starts_with(".sync_") + || crate::utils::is_conflict_file(&file_name) { + continue; + } + + let relative = path.strip_prefix(local_root) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + let relative = crate::utils::normalize_path(&relative); + + if handled_old_rels.contains(&relative) || handled_new_rels.contains(&relative) + || self.suppress_paths.contains_key(&relative) { + continue; + } + + match event { + LocalFileEvent::Created(_) | LocalFileEvent::Modified(_) => { + delete_paths.remove(relative.as_str()); + create_paths.insert(relative, path.clone()); + } + LocalFileEvent::Deleted(_) => { + if crate::utils::is_conflict_file(&relative) { + continue; + } + create_paths.remove(relative.as_str()); + delete_paths.insert(relative); + } + LocalFileEvent::Renamed { .. } | LocalFileEvent::Moved { .. } => {} + } + } + } + debounce.cleanup(); + + // === hash 匹配回退:检测 delete+create 为 rename 的情况 === + // MirrorWcf: 跳过 hash 匹配回退,因为读取占位符文件会被 CFApi 拦截导致 426 超时 + if !delete_paths.is_empty() && !create_paths.is_empty() && !matches!(sync_mode, SyncMode::MirrorWcf) { + let mut matched_deletes: std::collections::HashSet = std::collections::HashSet::new(); + let mut matched_creates: std::collections::HashSet = std::collections::HashSet::new(); + + for (new_rel, new_path) in &create_paths { + if let Ok(metadata) = tokio::fs::metadata(new_path).await { + if metadata.is_dir() || metadata.len() == 0 { continue; } + let new_hash = crate::utils::quick_hash(new_path, metadata.len()).await.unwrap_or_default(); + if new_hash.is_empty() { continue; } + + for del_rel in &delete_paths { + if matched_deletes.contains(del_rel.as_str()) { continue; } + if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, del_rel).await { + if mapping.local_hash.as_deref() == Some(&new_hash) { + let new_name = new_path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let old_dir = std::path::PathBuf::from(del_rel).parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + let new_dir = std::path::PathBuf::from(new_rel.as_str()).parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + + if old_dir == new_dir { + tracing::info!("hash匹配检测到重命名: {} -> {}", del_rel, new_rel); + rename_remote.push(RenameAction { + old_relative_path: del_rel.clone(), + new_relative_path: new_rel.clone(), + remote_uri: mapping.remote_uri.clone(), + new_name, + }); + } else { + let remote_root = { self.config.read().await.remote_root.clone() }; + let dst_remote_dir_uri = format!("{}/{}", + remote_root.trim_end_matches('/'), + new_dir.trim_start_matches('/')); + tracing::info!("hash匹配检测到移动: {} -> {}", del_rel, new_rel); + move_remote.push(MoveAction { + old_relative_path: del_rel.clone(), + new_relative_path: new_rel.clone(), + remote_uri: mapping.remote_uri.clone(), + dst_remote_dir_uri, + }); + } + matched_deletes.insert(del_rel.clone()); + matched_creates.insert(new_rel.clone()); + break; + } + } + } + } + } + + create_paths.retain(|rel, _| !matched_creates.contains(rel.as_str())); + delete_paths.retain(|rel| !matched_deletes.contains(rel.as_str())); + } + + // === 提交重命名任务 === + if !rename_remote.is_empty() { + let plan = SyncPlan { + rename_remote, + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + + // === 提交移动任务 === + if !move_remote.is_empty() { + let plan = SyncPlan { + move_remote, + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + + // === 提交上传任务 (Create/Modify) === + if !create_paths.is_empty() { + let mut uploads = Vec::new(); + let mut dir_paths: Vec = Vec::new(); + let mut skipped_unstable = 0usize; + let mut skipped_uploading = 0usize; + + for (relative, path) in &create_paths { + if let Ok(metadata) = tokio::fs::metadata(path).await { + if !metadata.is_dir() && metadata.len() == 0 { + continue; + } + + if metadata.is_dir() { + dir_paths.push(relative.clone()); + continue; + } + + if self.worker_pool.is_uploading(relative) { + skipped_uploading += 1; + tracing::debug!("文件正在上传中,跳过: {}", relative); + continue; + } + + if !is_file_stable(path).await { + skipped_unstable += 1; + tracing::debug!("文件尚未稳定(可能正在写入),跳过: {}", relative); + continue; + } + + let size = metadata.len(); + // MirrorWcf: 跳过 quick_hash,因为读取占位符文件会被 CFApi 拦截导致 426 超时 + let quick_hash = if matches!(sync_mode, SyncMode::MirrorWcf) { + String::new() + } else { + crate::utils::quick_hash(path, size).await.unwrap_or_default() + }; + + let db_mapping = self.db.get_file_mapping(&root_id, relative).await.ok().flatten(); + if let Some(ref mapping) = db_mapping { + if !quick_hash.is_empty() && mapping.local_hash.as_deref() == Some(&quick_hash) { + continue; + } + if mapping.is_placeholder { + continue; + } + } + + let mtime_ms = metadata.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let mime_type = crate::fs_scanner::guess_mime_type(path); + + uploads.push(SyncAction { + relative_path: relative.clone(), + local_entry: Some(LocalFileEntry { + relative_path: std::path::PathBuf::from(relative), + size, + mtime_ms, + quick_hash, + is_dir: false, + mime_type, + }), + remote_entry: None, + db_mapping, + }); + } + } + + let scan_dirs = find_top_level_dirs(&dir_paths); + + let all_handled: std::collections::HashSet<&String> = handled_old_rels.iter() + .chain(handled_new_rels.iter()) + .collect(); + let filtered_scan_dirs: Vec = scan_dirs.into_iter().filter(|dir| { + if all_handled.iter().any(|rel| { + rel.starts_with(dir.as_str()) + && rel.as_bytes().get(dir.len()) == Some(&b'/') + }) { + return false; + } + for entry in self.suppress_paths.iter() { + let rel = entry.key(); + if rel.starts_with(dir.as_str()) + && rel.as_bytes().get(dir.len()) == Some(&b'/') { + return false; + } + if dir.as_str() == rel.as_str() { + return false; + } + } + true + }).collect(); + + if !filtered_scan_dirs.is_empty() { + uploads.retain(|action| { + !filtered_scan_dirs.iter().any(|dir| { + action.relative_path.starts_with(dir) + && action.relative_path.as_bytes().get(dir.len()) == Some(&b'/') + }) + }); + } + + if !uploads.is_empty() || !filtered_scan_dirs.is_empty() { + tracing::info!( + "本地事件收集完成: 上传={}, 目录扫描={:?}, 跳过(未稳定)={}, 跳过(重复上传)={}", + uploads.len(), filtered_scan_dirs, + skipped_unstable, skipped_uploading, + ); + let plan = SyncPlan { + uploads, + scan_dirs: filtered_scan_dirs, + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + } + + // === 提交删除远程任务 (本地删除 → 删除远程) === + // Full 模式:始终同步删除远程 + // MirrorWcf 模式:仅在 wcf_delete_mode == SyncRemote 时删除远程,否则仅删除本地(保留远程以便重新水合) + let wcf_should_delete_remote = matches!(sync_mode, SyncMode::MirrorWcf) + && matches!(wcf_delete_mode, WcfDeleteMode::SyncRemote); + if !delete_paths.is_empty() && (matches!(sync_mode, SyncMode::Full) || wcf_should_delete_remote) { + let mut delete_remote: Vec = Vec::new(); + for relative in &delete_paths { + tracing::info!("检测到本地文件删除: {}", relative); + if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, relative).await { + delete_remote.push(SyncAction { + relative_path: relative.clone(), + local_entry: None, + remote_entry: Some(RemoteFileEntry { + uri: mapping.remote_uri.clone(), + name: String::new(), + size: 0, + mtime_ms: 0, + hash: None, + is_dir: false, + file_id: mapping.remote_file_id.clone(), + path: String::new(), + created_at_ms: 0, + }), + db_mapping: Some(mapping), + }); + let _ = self.db.delete_file_mapping(&root_id, relative).await; + } + } + + if !delete_remote.is_empty() { + tracing::info!("本地删除事件收集完成: 删远程={}", delete_remote.len()); + let plan = SyncPlan { + delete_remote, + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + } + + // UploadOnly 模式下本地删除仅清理 DB mapping,不删除远程 + if !delete_paths.is_empty() && matches!(sync_mode, SyncMode::UploadOnly) { + for relative in &delete_paths { + let _ = self.db.delete_file_mapping(&root_id, relative).await; + tracing::debug!("仅上传模式: 本地删除仅清理映射,不删除远程: {}", relative); + } + } + } +} + +/// 从两个绝对路径生成相对于 local_root 的相对路径对 +fn rel_pair(local_root: &std::path::Path, old_path: &std::path::Path, new_path: &std::path::Path) -> Option<(String, String)> { + let old_rel = old_path.strip_prefix(local_root).ok()? + .to_string_lossy().to_string(); + let new_rel = new_path.strip_prefix(local_root).ok()? + .to_string_lossy().to_string(); + Some((crate::utils::normalize_path(&old_rel), crate::utils::normalize_path(&new_rel))) +} + +fn find_top_level_dirs(dirs: &[String]) -> Vec { + if dirs.is_empty() { return Vec::new(); } + + let mut sorted: Vec<&String> = dirs.iter().collect(); + sorted.sort(); + + let mut top_level = Vec::new(); + for dir in &sorted { + let dominated = top_level.iter().any(|parent: &String| { + dir.starts_with(parent.as_str()) + && dir.as_bytes().get(parent.len()) == Some(&b'/') + }); + if !dominated { + top_level.retain(|existing: &String| { + !existing.starts_with(dir.as_str()) + || existing.as_bytes().get(dir.len()) != Some(&b'/') + }); + top_level.push((*dir).clone()); + } + } + + top_level +} + +/// 检测文件是否稳定(不在被写入中) +async fn is_file_stable(path: &Path) -> bool { + let size1 = match tokio::fs::metadata(path).await { + Ok(m) if !m.is_dir() => m.len(), + _ => return false, + }; + + let path_display = path.display().to_string(); + let can_open = tokio::task::spawn_blocking(move || { + match std::fs::File::open(&path_display) { + Ok(_) => true, + Err(e) => { + tracing::trace!("文件稳定性检测打开失败: {}", e); + false + } + } + }) + .await + .unwrap_or(false); + + if !can_open { + return false; + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let size2 = match tokio::fs::metadata(path).await { + Ok(m) if !m.is_dir() => m.len(), + _ => return false, + }; + + size1 == size2 && size1 > 0 +} diff --git a/native/sync-core/src/sync_engine/mod.rs b/native/sync-core/src/sync_engine/mod.rs new file mode 100644 index 0000000..efcba33 --- /dev/null +++ b/native/sync-core/src/sync_engine/mod.rs @@ -0,0 +1,382 @@ +mod initial_sync; +mod continuous_sync; +mod local_events; +mod remote_events; +mod album; +#[cfg(feature = "windows-cfapi")] +mod wcf; + +// 非 WCF feature 下的 stub 方法,供 remote_events.rs 编译通过 +#[cfg(not(feature = "windows-cfapi"))] +impl SyncEngine { + async fn _create_placeholder_for_remote( + &self, + _relative: &str, + _remote: &RemoteFileEntry, + _local_root: &std::path::Path, + _root_id: &str, + ) { + // MirrorWcf 模式在非 Windows 平台不可用,此方法不应被调用 + } +} + +use crate::api_client::ApiClient; +use crate::conflict_resolver::ConflictResolver; +use crate::errors::Result; +use crate::event_sink::EventSink; +use crate::file_lock::FileLockRegistry; +use crate::models::*; +use crate::sync_db::SyncDb; +use crate::worker::WorkerPool; +use dashmap::DashMap; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +#[cfg(feature = "windows-cfapi")] +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub struct SyncEngine { + state: RwLock, + db: Arc, + api: Arc, + config: RwLock, + conflict: RwLock, + sync_root_id: Option, + shutdown_token: std::sync::Mutex, + /// 同步操作互斥锁:防止 force_sync / run_initial_sync 并发 + sync_lock: tokio::sync::Mutex<()>, + worker_pool: WorkerPool, + #[allow(dead_code)] + file_locks: Arc, + #[allow(dead_code)] + ensured_dirs: Arc>, + event_sink: Arc, + /// 远程操作导致的本地路径变化,抑制本地 debouncer 自触发事件 + suppress_paths: Arc>, + /// WCF 平台适配器(仅 MirrorWcf 模式下初始化) + #[cfg(feature = "windows-cfapi")] + platform_adapter: std::sync::Mutex>>, + /// WCF FETCH_DATA 回调接收端(在适配器初始化时提取) + #[cfg(feature = "windows-cfapi")] + wcf_fetch_rx: std::sync::Mutex>>, + /// WCF 水合缓存:uri → 已下载的完整文件数据,避免同一文件重复下载 + #[cfg(feature = "windows-cfapi")] + hydration_cache: Arc, std::time::Instant)>>, + /// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await) + #[cfg(feature = "windows-cfapi")] + cached_local_root: std::sync::Mutex, +} + +impl SyncEngine { + pub async fn new(config: SyncConfig) -> Result { + let db_path = config.data_dir.join("sync_core").join("datas").join(".sync_db.sqlite3"); + let db_path_clone = db_path.clone(); + let db = Arc::new(tokio::task::spawn_blocking(move || SyncDb::open(&db_path_clone)).await??); + + let api = Arc::new(ApiClient::new(&config.base_url, &config.access_token, &config.refresh_token, &config.client_id)); + + let conflict = ConflictResolver::new(config.conflict_strategy.clone()); + + let sync_root_id = match db.upsert_sync_root(&config).await { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!("写入 sync_root 失败: {}", e); + None + } + }; + + let shutdown_token = CancellationToken::new(); + let file_locks = Arc::new(FileLockRegistry::new()); + let ensured_dirs = Arc::new(DashMap::new()); + let event_sink = Arc::new(EventSink::new()); + let suppress_paths = Arc::new(DashMap::new()); + + let max_workers = config.max_workers; + let client_id = config.client_id.clone(); + let worker_pool = WorkerPool::new( + db.clone(), + api.clone(), + file_locks.clone(), + ensured_dirs.clone(), + event_sink.clone(), + shutdown_token.clone(), + max_workers, + &client_id, + ); + + Ok(Self { + state: RwLock::new(SyncState::Idle), + db, + api, + config: RwLock::new(config), + conflict: RwLock::new(conflict), + sync_root_id, + shutdown_token: std::sync::Mutex::new(shutdown_token), + sync_lock: tokio::sync::Mutex::new(()), + worker_pool, + file_locks, + ensured_dirs, + event_sink, + suppress_paths, + #[cfg(feature = "windows-cfapi")] + platform_adapter: std::sync::Mutex::new(None), + #[cfg(feature = "windows-cfapi")] + wcf_fetch_rx: std::sync::Mutex::new(None), + #[cfg(feature = "windows-cfapi")] + hydration_cache: Arc::new(DashMap::new()), + #[cfg(feature = "windows-cfapi")] + cached_local_root: std::sync::Mutex::new(std::path::PathBuf::new()), + }) + } + + async fn snapshot_worker_config(&self) -> WorkerConfig { + let config = self.config.read().await; + WorkerConfig { + local_root: config.local_root.clone(), + remote_root: config.remote_root.clone(), + max_concurrent_transfers: config.max_concurrent_transfers, + bandwidth_limit: config.bandwidth_limit, + conflict_strategy: config.conflict_strategy.clone(), + wcf_delete_mode: config.wcf_delete_mode.clone(), + sync_root_id: self.sync_root_id.clone().unwrap_or_default(), + sync_mode: config.sync_mode.clone(), + } + } + + /// 确保 shutdown token 未被取消(stop 后重新启动时使用) + pub fn ensure_token_fresh(&self) { + let token = self.shutdown_token.lock().unwrap().clone(); + if token.is_cancelled() { + let new_token = tokio_util::sync::CancellationToken::new(); + self.worker_pool.update_shutdown_token(new_token.clone()); + *self.shutdown_token.lock().unwrap() = new_token; + } + } + + pub async fn stop(&self) -> Result<()> { + self.shutdown_token.lock().unwrap().cancel(); + *self.state.write().await = SyncState::Stopped; + Ok(()) + } + + pub async fn pause(&self) -> Result<()> { + *self.state.write().await = SyncState::Paused; + Ok(()) + } + + pub async fn resume(&self) -> Result<()> { + *self.state.write().await = SyncState::Continuous; + Ok(()) + } + + pub async fn force_sync(&self) -> Result { + // 取消当前所有操作(持续同步 + 正在运行的初始同步) + self.shutdown_token.lock().unwrap().cancel(); + + // 创建新 token,供接下来的 run_initial_sync 使用 + let new_token = tokio_util::sync::CancellationToken::new(); + *self.shutdown_token.lock().unwrap() = new_token.clone(); + self.worker_pool.update_shutdown_token(new_token); + + // run_initial_sync 会等待 sync_lock(旧同步的 worker 检测到取消后快速退出,释放锁) + self.run_initial_sync().await + } + + /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 + pub async fn reset_sync(&self) -> Result<()> { + tracing::info!("开始重置同步..."); + + // 1. 停止同步 + self.stop().await?; + + // 2. 清理 WCF(重置时需要彻底清理) + #[cfg(feature = "windows-cfapi")] + { + self.cleanup_wcf(); + } + + // 3. 终止所有活跃 Worker + + // 2. 终止所有活跃 Worker + self.worker_pool.abort_all_workers().await; + + // 3. 清空 DB 业务数据 + self.db.reset_sync_data().await?; + tracing::info!("同步数据库已清空"); + + // 4. 清空本地同步目录(保留目录本身,只删内容) + let local_root = self.config.read().await.local_root.clone(); + if local_root.exists() { + let entries = std::fs::read_dir(&local_root) + .map_err(|_| crate::errors::SyncError::DiskFull { needed: 0, available: 0 })?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); + } + } + tracing::info!("本地同步目录已清空: {}", local_root.display()); + } + + // 5. 清空内存缓存 + self.ensured_dirs.clear(); + self.suppress_paths.clear(); + + // 6. 重置状态 + *self.state.write().await = SyncState::Idle; + + tracing::info!("同步重置完成,已回到初始状态"); + Ok(()) + } + + pub fn status(&self) -> SyncStatusSnapshot { + let state = self.state.try_read().map(|g| g.clone()).unwrap_or(SyncState::Idle); + + let (synced_files, total_files) = match &state { + SyncState::InitialSync { progress } => { + let done = progress.uploaded + progress.downloaded; + (done, progress.total_to_sync) + } + _ => (0, 0), + }; + + SyncStatusSnapshot { + state, + synced_files, + total_files, + uploading_count: 0, + downloading_count: 0, + conflict_count: 0, + error_count: 0, + last_sync_time: None, + error_message: None, + } + } + + pub fn active_worker_count(&self) -> u32 { + self.worker_pool.active_worker_count() as u32 + } + + pub async fn config(&self) -> SyncConfig { + self.config.read().await.clone() + } + + pub async fn update_config(&self, new_config: SyncConfig) -> Result<()> { + let old_access_token = { + let config = self.config.read().await; + config.access_token.clone() + }; + + *self.conflict.write().await = ConflictResolver::new(new_config.conflict_strategy.clone()); + + if new_config.access_token != old_access_token { + self.api.update_token(new_config.access_token.clone()).await; + } + + let new_bandwidth = new_config.bandwidth_limit; + let new_conflict = format!("{:?}", new_config.conflict_strategy); + let new_wcf_delete = format!("{:?}", new_config.wcf_delete_mode); + let new_mode = format!("{:?}", new_config.sync_mode); + let new_max_concurrent = new_config.max_concurrent_transfers; + *self.config.write().await = new_config; + + if new_bandwidth.is_some() { + tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效"); + } + tracing::info!( + "同步配置已更新: 模式={}, 冲突策略={}, WCF删除={}, 并发={}, 带宽限制={:?}", + new_mode, new_conflict, new_wcf_delete, new_max_concurrent, new_bandwidth + ); + Ok(()) + } + + pub async fn update_access_token(&self, token: String) { + self.api.update_token(token).await; + } + + pub async fn register_event_sink(&self, sink: crate::frb_generated::StreamSink) { + self.event_sink.register(sink).await; + } + + pub async fn get_active_tasks(&self) -> Result> { + self.db.get_active_sync_tasks().await + } + + pub async fn get_recent_tasks(&self, limit: u32) -> Result> { + self.db.get_recent_sync_tasks(limit).await + } + + pub async fn get_task_detail(&self, task_id: &str) -> Result> { + self.db.get_sync_task_items(task_id).await + } + + pub async fn query_task_items(&self, filter: &TaskItemFilter) -> Result> { + self.db.query_task_items(filter).await + } + + pub async fn hydrate_file(&self, local_path: &str) -> Result<()> { + #[cfg(feature = "windows-cfapi")] + { + let path = std::path::PathBuf::from(local_path); + if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() { + adapter.hydrate_file(&path)?; + } + } + let _ = local_path; + Ok(()) + } + + pub async fn shutdown(self) -> Result<()> { + self.stop().await + } + + async fn load_all_mappings(&self) -> Result> { + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => return Ok(HashMap::new()), + }; + + let pool = self.db.read_pool(); + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder + FROM file_mapping WHERE sync_root_id = ?1" + )?; + + let mappings: HashMap = stmt.query_map( + rusqlite::params![root_id], + |row| { + let local_path: String = row.get(2)?; + Ok(( + crate::utils::normalize_path(&local_path), + FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(local_path), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: crate::diff::parse_sync_status_from_str(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }, + )) + }, + )?.filter_map(|r| r.ok()).collect(); + + Ok(mappings) + }).await??; + + Ok(result) + } +} diff --git a/native/sync-core/src/sync_engine/remote_events.rs b/native/sync-core/src/sync_engine/remote_events.rs new file mode 100644 index 0000000..80f51c5 --- /dev/null +++ b/native/sync-core/src/sync_engine/remote_events.rs @@ -0,0 +1,241 @@ +use crate::models::*; + +use super::SyncEngine; + +impl SyncEngine { + /// 处理远程事件 + pub(crate) async fn handle_remote_event( + &self, + event: RemoteFileEvent, + local_root: &std::path::Path, + remote_root: &str, + ) { + // UploadOnly: 忽略所有远程事件 + let sync_mode = { + let config = self.config.read().await; + config.sync_mode.clone() + }; + if matches!(sync_mode, SyncMode::UploadOnly) { + return; + } + + let is_mirror_wcf = matches!(sync_mode, SyncMode::MirrorWcf); + let root_id = self.sync_root_id.clone().unwrap_or_default(); + + match &event { + RemoteFileEvent::Created(remote) | RemoteFileEvent::Modified(remote) => { + let relative = crate::diff::remote_relative_path( + remote_root, + &remote.path, + &remote.name, + remote.is_dir, + ); + tracing::info!("[远程事件] {}/{:?}: {}", event_type_name(&event), remote.file_id, relative); + + let remote_entry = if remote.size == 0 && !remote.is_dir { + match self.api.get_file_info(&remote.uri).await { + Ok(info) => { + tracing::debug!("[远程事件] 获取文件详情成功: {} ({}bytes)", relative, info.size); + info + } + Err(e) => { + tracing::warn!("[远程事件] 获取文件详情失败: {}: {}", relative, e); + remote.clone() + } + } + } else { + remote.clone() + }; + + let now = std::time::Instant::now(); + self.suppress_paths.insert(relative.clone(), now); + if let Some(parent) = std::path::PathBuf::from(&relative).parent() { + let parent_rel = crate::utils::normalize_path(&parent.to_string_lossy()); + if !parent_rel.is_empty() { + self.suppress_paths.insert(parent_rel, now); + } + } + + if is_mirror_wcf { + self._create_placeholder_for_remote( + &relative, &remote_entry, local_root, &root_id, + ).await; + } else { + let plan = SyncPlan { + downloads: vec![SyncAction { + relative_path: relative, + local_entry: None, + remote_entry: Some(remote_entry), + db_mapping: None, + }], + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + } + RemoteFileEvent::Deleted { uri, name } => { + let relative = crate::diff::remote_relative_path( + remote_root, + uri, + name, + false, + ); + tracing::info!("[远程事件] 删除: {}", relative); + + let local_path = local_root.join(&relative); + if local_path.exists() { + if local_path.is_dir() { + let _ = tokio::fs::remove_dir_all(&local_path).await; + } else { + let _ = tokio::fs::remove_file(&local_path).await; + } + tracing::info!("[远程事件] 已删除本地文件: {}", relative); + } + let _ = self.db.delete_file_mapping(&root_id, &relative).await; + self.suppress_paths.insert(relative.clone(), std::time::Instant::now()); + } + RemoteFileEvent::Renamed { old_uri, new_entry } => { + let old_relative = crate::diff::remote_relative_path( + remote_root, + old_uri, + &new_entry.name, + false, + ); + let new_relative = crate::diff::remote_relative_path( + remote_root, + &new_entry.path, + &new_entry.name, + new_entry.is_dir, + ); + tracing::info!("[远程事件] 重命名: {} -> {}", old_relative, new_relative); + + let now = std::time::Instant::now(); + self.suppress_paths.insert(old_relative.clone(), now); + self.suppress_paths.insert(new_relative.clone(), now); + + let old_local_path = local_root.join(&old_relative); + + if old_local_path.exists() { + let plan = SyncPlan { + rename_local: vec![LocalRenameAction { + old_relative_path: old_relative, + new_relative_path: new_relative, + new_remote_uri: new_entry.uri.clone(), + }], + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } else if is_mirror_wcf { + let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; + self._create_placeholder_for_remote( + &new_relative, &remote_entry, local_root, &root_id, + ).await; + } else { + let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; + let plan = SyncPlan { + downloads: vec![SyncAction { + relative_path: new_relative, + local_entry: None, + remote_entry: Some(remote_entry), + db_mapping: None, + }], + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + } + RemoteFileEvent::Moved { old_uri, new_entry } => { + let old_relative = crate::diff::remote_relative_path( + remote_root, + old_uri, + &new_entry.name, + false, + ); + let new_relative = crate::diff::remote_relative_path( + remote_root, + &new_entry.path, + &new_entry.name, + new_entry.is_dir, + ); + tracing::info!("[远程事件] 移动: {} -> {}", old_relative, new_relative); + + let now = std::time::Instant::now(); + self.suppress_paths.insert(old_relative.clone(), now); + self.suppress_paths.insert(new_relative.clone(), now); + + let old_local_path = local_root.join(&old_relative); + + if old_local_path.exists() { + let plan = SyncPlan { + move_local: vec![LocalRenameAction { + old_relative_path: old_relative, + new_relative_path: new_relative, + new_remote_uri: new_entry.uri.clone(), + }], + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } else if is_mirror_wcf { + let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; + self._create_placeholder_for_remote( + &new_relative, &remote_entry, local_root, &root_id, + ).await; + } else { + let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; + let plan = SyncPlan { + downloads: vec![SyncAction { + relative_path: new_relative, + local_entry: None, + remote_entry: Some(remote_entry), + db_mapping: None, + }], + ..Default::default() + }; + let worker_config = self.snapshot_worker_config().await; + let conflict_resolver = self.conflict.read().await.clone(); + self.worker_pool.submit_background( + plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, + ).await; + } + } + } + } + + /// 获取远程文件详情,失败则使用 SSE 数据回退 + pub(crate) async fn get_remote_entry_or_fallback(&self, entry: &RemoteFileEntry) -> RemoteFileEntry { + if entry.size == 0 && !entry.is_dir { + match self.api.get_file_info(&entry.uri).await { + Ok(info) => info, + Err(_) => entry.clone(), + } + } else { + entry.clone() + } + } +} + +fn event_type_name(event: &RemoteFileEvent) -> &'static str { + match event { + RemoteFileEvent::Created(_) => "create", + RemoteFileEvent::Modified(_) => "modify", + RemoteFileEvent::Deleted { .. } => "delete", + RemoteFileEvent::Renamed { .. } => "rename", + RemoteFileEvent::Moved { .. } => "move", + } +} diff --git a/native/sync-core/src/sync_engine/wcf.rs b/native/sync-core/src/sync_engine/wcf.rs new file mode 100644 index 0000000..7353656 --- /dev/null +++ b/native/sync-core/src/sync_engine/wcf.rs @@ -0,0 +1,272 @@ +//! WCF (Windows Cloud Filter API) 相关的 SyncEngine 方法 +//! 仅在 windows-cfapi feature 启用时编译 + +use crate::models::*; + +use super::SyncEngine; + +#[cfg(feature = "windows-cfapi")] +impl SyncEngine { + /// MirrorWcf: 处理 CFApi FETCH_DATA 回调(按需水合) + pub(crate) async fn handle_wcf_fetch( + &self, + request: sync_windows::FetchDataRequest, + _local_root: &std::path::Path, + ) { + let identity: serde_json::Value = match serde_json::from_slice(&request.file_identity) { + Ok(v) => v, + Err(e) => { + tracing::error!("WCF 水合: FileIdentity 反序列化失败: {}", e); + let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( + request.connection_key, request.transfer_key, + ); + return; + } + }; + + let remote_uri = identity["uri"].as_str().unwrap_or("").to_string(); + let remote_size = identity["size"].as_u64().unwrap_or(0); + let remote_hash = identity["hash"].as_str().unwrap_or("").to_string(); + + if remote_uri.is_empty() { + tracing::error!("WCF 水合: FileIdentity 中 uri 为空"); + let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( + request.connection_key, request.transfer_key, + ); + return; + } + + tracing::debug!("WCF 水合请求: uri={}, size={}, offset={}, length={}", + remote_uri, remote_size, request.required_offset, request.required_length); + + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( + request.connection_key, request.transfer_key, + ); + return; + } + }; + + // 清理过期缓存(超过 5 分钟) + let now = std::time::Instant::now(); + self.hydration_cache.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 300); + + // 尝试从缓存获取已下载的数据 + let data = if let Some(cached) = self.hydration_cache.get(&remote_uri) { + tracing::debug!("WCF 水合缓存命中: {}", remote_uri); + cached.0.clone() + } else { + tracing::info!("WCF 水合下载: {} ({}bytes)", remote_uri, remote_size); + let config = self.snapshot_worker_config().await; + + let download_result = async { + let urls = self.api.get_download_url(&[&remote_uri]).await; + let urls = match urls { + Ok(u) => u, + Err(crate::errors::SyncError::Auth(_)) => { + tracing::info!("WCF 水合: token 过期,尝试刷新后重试"); + self.api.refresh_access_token().await?; + self.api.get_download_url(&[&remote_uri]).await? + } + Err(e) => return Err(e), + }; + let download_url = urls.into_iter().next() + .ok_or_else(|| crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()))?; + + let data = crate::downloader::download_to_buffer( + &self.api, + &download_url, + config.bandwidth_limit, + ).await?; + + Ok::, crate::errors::SyncError>(data) + }.await; + + match download_result { + Ok(data) => { + self.hydration_cache.insert(remote_uri.clone(), (data.clone(), std::time::Instant::now())); + data + } + Err(e) => { + tracing::error!("WCF 水合下载失败: {}: {}", remote_uri, e); + let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( + request.connection_key, request.transfer_key, + ); + return; + } + } + }; + + // 计算实际需要传输的范围 + let offset = request.required_offset.max(0) as usize; + let end = if request.required_length < 0 { + data.len() + } else { + (offset + request.required_length as usize).min(data.len()) + }; + + let transfer_data = if offset < data.len() && offset < end { + &data[offset..end] + } else { + &data[..] + }; + + match crate::platform::wcf::WcfPlatformAdapter::fulfill_fetch_data( + request.connection_key, + request.transfer_key, + transfer_data, + offset as i64, + ) { + Ok(_) => { + tracing::debug!("WCF 水合数据推送: {} offset={} len={}", remote_uri, offset, transfer_data.len()); + + if let Ok(Some(mapping)) = self.db.find_mapping_by_remote_uri(&root_id, &remote_uri).await { + self.suppress_paths.insert(mapping.local_path.to_string_lossy().into_owned(), std::time::Instant::now()); + let _ = self.db.upsert_file_mapping(&FileMapping { + id: mapping.id, + sync_root_id: mapping.sync_root_id, + local_path: mapping.local_path.clone(), + remote_uri: mapping.remote_uri.clone(), + remote_file_id: mapping.remote_file_id.clone(), + local_hash: None, + remote_hash: if remote_hash.is_empty() { mapping.remote_hash.clone() } else { Some(remote_hash.clone()) }, + local_mtime: mapping.local_mtime, + remote_mtime: mapping.remote_mtime, + local_size: mapping.local_size, + remote_size: Some(remote_size), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }).await; + } + } + Err(e) => { + tracing::error!("WCF CfExecute 传输数据失败: {}: {}", remote_uri, e); + } + } + } + + /// MirrorWcf: 为远程文件创建占位符(持续同步时远程新建/修改文件调用) + pub(crate) async fn _create_placeholder_for_remote( + &self, + relative: &str, + remote: &RemoteFileEntry, + local_root: &std::path::Path, + root_id: &str, + ) { + let local_path = local_root.join(relative); + + if let Some(parent) = local_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + if remote.is_dir { + let _ = tokio::fs::create_dir_all(&local_path).await; + } else { + #[cfg(feature = "windows-cfapi")] + { + if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() { + match adapter.create_placeholder_for_remote( + local_path.parent().unwrap_or(local_root), + local_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str(), + remote.size, + &remote.uri, + remote.hash.as_deref(), + remote.mtime_ms, + ) { + Ok(_) => tracing::info!("[WCF] 创建占位符: {}", relative), + Err(e) => tracing::warn!("[WCF] 创建占位符失败 {}: {}", relative, e), + } + } + } + + let _ = self.db.upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.to_string(), + local_path: std::path::PathBuf::from(relative), + remote_uri: remote.uri.clone(), + remote_file_id: remote.file_id.clone(), + local_hash: None, + remote_hash: remote.hash.clone(), + local_mtime: None, + remote_mtime: Some(remote.mtime_ms), + local_size: None, + remote_size: Some(remote.size), + sync_status: SyncFileStatus::Placeholder, + is_placeholder: true, + }).await; + } + } + + /// WCF 清理(同步,可安全在 exit 前调用) + pub(crate) fn cleanup_wcf(&self) { + let adapter_opt = self.platform_adapter.lock().unwrap().take(); + if let Some(adapter) = adapter_opt { + if let Err(e) = adapter.disconnect() { + tracing::warn!("WCF 断开连接失败: {}", e); + } + + let local_root = self.cached_local_root.lock().unwrap().clone(); + if !local_root.as_os_str().is_empty() { + unsafe { + use std::os::windows::ffi::OsStrExt; + let path_w: Vec = std::ffi::OsStr::new(&local_root) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let _ = windows::Win32::Storage::CloudFilters::CfUnregisterSyncRoot( + windows::core::PCWSTR(path_w.as_ptr()), + ); + } + tracing::info!("WCF sync root 已注销: {}", local_root.display()); + } + + let root_id = self.sync_root_id.clone().unwrap_or_default(); + if let Ok(mappings) = self.list_placeholders_sync(&root_id) { + for mapping in &mappings { + let local_path = local_root.join(&mapping.local_path); + if local_path.exists() { + let _ = std::fs::remove_file(&local_path); + } + } + if !mappings.is_empty() { + tracing::info!("已清理 {} 个占位符文件", mappings.len()); + } + } + } + } + + /// 同步查询占位符映射(避免 await,可在 exit 前安全调用) + fn list_placeholders_sync(&self, sync_root_id: &str) -> anyhow::Result> { + let pool = self.db.read_pool(); + let conn = pool.get().map_err(|e| anyhow::anyhow!("{}", e))?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + local_hash, remote_hash, local_mtime, remote_mtime, + local_size, remote_size, sync_status, is_placeholder + FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", + ).map_err(|e| anyhow::anyhow!("{}", e))?; + + let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) + }).map_err(|e| anyhow::anyhow!("{}", e))? + .filter_map(|m| m.ok()).collect(); + + Ok(mappings) + } +} diff --git a/native/sync-core/src/transfer.rs b/native/sync-core/src/transfer.rs new file mode 100644 index 0000000..07ac701 --- /dev/null +++ b/native/sync-core/src/transfer.rs @@ -0,0 +1,2 @@ +// 保留 TransferConfig / TransferTask / TransferDirection / TransferStatus 模型 +// TransferManager 的 download/upload/retry_with_backoff 已迁移到 uploader.rs / downloader.rs / worker.rs diff --git a/native/sync-core/src/uploader.rs b/native/sync-core/src/uploader.rs new file mode 100644 index 0000000..4f2e9e9 --- /dev/null +++ b/native/sync-core/src/uploader.rs @@ -0,0 +1,299 @@ +use crate::api_client::ApiClient; +use crate::errors::{Result, SyncError}; +use crate::file_lock::FileLockRegistry; +use crate::models::*; +use crate::sync_db::SyncDb; +use dashmap::DashMap; +use std::path::Path; +use tokio::sync::Semaphore; + +/// 上传单个文件(含重试),受并发信号量控制 +/// 逐块读取文件,避免全量加载到内存 +#[allow(clippy::too_many_arguments)] +pub async fn upload_file( + task_id: &str, + action: &SyncAction, + config: &WorkerConfig, + api: &ApiClient, + db: &SyncDb, + file_locks: &FileLockRegistry, + ensured_dirs: &DashMap, + semaphore: &Semaphore, + root_id: &str, +) -> Result<()> { + let local = action.local_entry.as_ref().ok_or_else(|| { + SyncError::Internal("上传操作缺少本地文件信息".into()) + })?; + + let _lock = file_locks.acquire(&action.relative_path).await; + + if local.is_dir { + let remote_uri = format!("{}/{}", config.remote_root, action.relative_path); + ensure_remote_dirs(task_id, config.remote_root.as_str(), &action.relative_path, api, ensured_dirs).await?; + db.upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.to_string(), + local_path: local.relative_path.clone(), + remote_uri, + remote_file_id: None, + local_hash: Some(local.quick_hash.clone()), + remote_hash: None, + local_mtime: Some(local.mtime_ms), + remote_mtime: None, + local_size: Some(local.size), + remote_size: None, + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }).await?; + return Ok(()); + } + + let local_path = config.local_root.join(&local.relative_path); + let file_uri = format!("{}/{}", config.remote_root, action.relative_path); + + let _permit = semaphore.acquire().await + .map_err(|e| SyncError::Internal(format!("获取传输信号量失败: {}", e)))?; + + // 信号量获取后标记为 Running(实际开始传输) + let _ = db + .update_task_item_status_by_path( + task_id, + &action.relative_path, + "upload", + &TaskItemStatus::Running, + None, + ) + .await; + + let max_retries = 3u32; + + // 确保远程父目录链存在 + if let Some(parent) = Path::new(&action.relative_path).parent() { + let parent_str = parent.to_string_lossy().to_string(); + if !parent_str.is_empty() { + if let Err(e) = ensure_remote_dirs(task_id, &config.remote_root, &parent_str, api, ensured_dirs).await { + tracing::warn!("[{}] 确保远程父目录失败 {}: {}", task_id, parent_str, e); + } + } + } + + let overwrite = action.db_mapping.is_some(); + + // 打开文件(加重试,处理文件被占用的情况) + let file = { + let mut read_retries = 0u32; + loop { + match tokio::fs::File::open(&local_path).await { + Ok(f) => break f, + Err(e) if e.raw_os_error() == Some(32) && read_retries < 5 => { + read_retries += 1; + let delay = read_retries * 1000; + tracing::warn!("[{}] 文件被占用,{}ms后重试 ({}): {}", task_id, delay, read_retries, local_path.display()); + tokio::time::sleep(std::time::Duration::from_millis(delay as u64)).await; + } + Err(e) => return Err(e.into()), + } + } + }; + + let last_modified = if local.mtime_ms > 0 { Some(local.mtime_ms) } else { None }; + let mime_type_str = local.mime_type.as_deref(); + let session = retry_upload_session( + task_id, &file_uri, local.size, max_retries, overwrite, last_modified, mime_type_str, None, api, + ).await?; + let chunk_size = session.chunk_size as usize; + + tracing::info!("[{}] chunk_size={}bytes, 开始上传: {} ({}bytes)", task_id, chunk_size, action.relative_path, local.size); + + // 逐块读取 + 上传,内存占用仅为一个 chunk + // 分片上传要求:每个分片(除最后一个)必须恰好 chunk_size 字节 + // 因此需要循环 read 直到读满 buffer 或到达 EOF,才能作为一个分片上传 + let mut reader = tokio::io::BufReader::new(file); + let mut buf = vec![0u8; chunk_size]; + let mut index: u32 = 0; + + loop { + use tokio::io::AsyncReadExt; + let mut filled = 0usize; + loop { + let n = reader.read(&mut buf[filled..]).await?; + if n == 0 { break; } + filled += n; + if filled >= chunk_size { break; } + } + if filled == 0 { break; } + + let chunk = &buf[..filled]; + let mut chunk_retries = 0u32; + loop { + match api.upload_chunk(&session.session_id, index, chunk).await { + Ok(_) => break, + Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())), + Err(e) if chunk_retries < max_retries => { + chunk_retries += 1; + let delay = crate::utils::retry_delay_ms(chunk_retries, 1000, 30000); + tracing::warn!("[{}] 上传重试 ({}/{}): {}: {}", task_id, chunk_retries, max_retries, action.relative_path, e); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + Err(e) => return Err(e), + } + } + index += 1; + } + + // 上传完成后获取远程文件信息 + let remote_uri = file_uri.clone(); + let (remote_file_id, remote_hash) = match api.get_file_info(&remote_uri).await { + Ok(info) => (info.file_id.clone(), info.hash.clone()), + Err(e) => { + tracing::warn!("[{}] 上传后获取文件信息失败: {}", task_id, e); + (None, None) + } + }; + + db.upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.to_string(), + local_path: local.relative_path.clone(), + remote_uri, + remote_file_id, + local_hash: Some(local.quick_hash.clone()), + remote_hash: remote_hash.or(Some(local.quick_hash.clone())), + local_mtime: Some(local.mtime_ms), + remote_mtime: Some(local.mtime_ms), + local_size: Some(local.size), + remote_size: Some(local.size), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }).await?; + + tracing::info!("[{}] 上传完成: {}", task_id, action.relative_path); + Ok(()) +} + +/// 带重试的创建上传会话(遇到锁冲突自动强制解锁) +#[allow(clippy::too_many_arguments)] +pub async fn retry_upload_session( + task_id: &str, + file_uri: &str, + file_size: u64, + max_retries: u32, + overwrite: bool, + last_modified: Option, + mime_type: Option<&str>, + policy_id: Option<&str>, + api: &ApiClient, +) -> Result { + let mut attempt = 0u32; + let mut tried_overwrite = overwrite; + loop { + attempt += 1; + match api.create_upload_session(file_uri, file_size, tried_overwrite, last_modified, mime_type, policy_id).await { + Ok(session) => return Ok(session), + Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())), + Err(SyncError::ObjectExisted) if !tried_overwrite => { + tracing::info!("[{}] 远程文件已存在,切换为覆盖上传", task_id); + tried_overwrite = true; + continue; + } + Err(SyncError::LockConflict { tokens }) => { + tracing::warn!("[{}] 文件锁定冲突,强制解锁 {} 个锁后重试", task_id, tokens.len()); + if let Err(e) = api.force_unlock_files(&tokens).await { + tracing::error!("[{}] 强制解锁失败: {}", task_id, e); + } + if attempt <= max_retries { + continue; + } + return Err(SyncError::LockConflict { tokens }); + } + Err(e) if attempt <= max_retries => { + let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000); + tracing::warn!("[{}] 创建上传会话失败,{}ms后重试 ({}): {}", task_id, delay, attempt, e); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + Err(e) => return Err(e), + } + } +} + +/// 递归确保远程目录链存在(带缓存) +pub async fn ensure_remote_dirs( + task_id: &str, + remote_root: &str, + relative_path: &str, + api: &ApiClient, + ensured_dirs: &DashMap, +) -> Result<()> { + let parts: Vec<&str> = relative_path.split('/').filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + return Ok(()); + } + + let mut current = remote_root.to_string(); + let mut dirs_to_create: Vec<(String, String)> = Vec::new(); + + for part in &parts { + let next_uri = format!("{}/{}", current, part); + if ensured_dirs.contains_key(&next_uri) { + current = next_uri; + continue; + } + dirs_to_create.push((current.clone(), part.to_string())); + current = next_uri; + } + + if dirs_to_create.is_empty() { + return Ok(()); + } + + for (parent_uri, dir_name) in &dirs_to_create { + let uri = format!("{}/{}", parent_uri, dir_name); + match api.create_directory(parent_uri, dir_name).await { + Ok(_) => { + tracing::debug!("[{}] 创建远程目录: {}", task_id, uri); + ensured_dirs.insert(uri.clone(), ()); + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("exist") || msg.contains("already") || msg.contains("40004") { + ensured_dirs.insert(uri.clone(), ()); + } else { + tracing::warn!("[{}] 创建远程目录失败 {}: {}", task_id, uri, e); + } + } + } + } + + Ok(()) +} + +/// 逐块读取文件并上传分片(用于相册同步等场景,避免全量加载到内存) +/// 分片上传要求:每个分片(除最后一个)必须恰好 chunk_size 字节 +pub async fn upload_file_chunked( + api: &ApiClient, + local_path: &Path, + session: &UploadSession, +) -> Result<()> { + let chunk_size = session.chunk_size as usize; + let file = tokio::fs::File::open(local_path).await?; + let mut reader = tokio::io::BufReader::new(file); + let mut buf = vec![0u8; chunk_size]; + let mut index: u32 = 0; + + loop { + use tokio::io::AsyncReadExt; + let mut filled = 0usize; + loop { + let n = reader.read(&mut buf[filled..]).await?; + if n == 0 { break; } + filled += n; + if filled >= chunk_size { break; } + } + if filled == 0 { break; } + + api.upload_chunk(&session.session_id, index, &buf[..filled]).await?; + index += 1; + } + + Ok(()) +} diff --git a/native/sync-core/src/utils.rs b/native/sync-core/src/utils.rs new file mode 100644 index 0000000..f7e7f68 --- /dev/null +++ b/native/sync-core/src/utils.rs @@ -0,0 +1,151 @@ +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +use crate::errors::{Result, SyncError}; + +/// 增量哈希:前 8KB + 文件大小(快速判断文件是否变更) +pub async fn quick_hash(path: &Path, size: u64) -> Result { + if size == 0 { + return Ok("0:0:0".to_string()); + } + let file = tokio::fs::File::open(path).await?; + let mut reader = tokio::io::BufReader::new(file); + let mut head = vec![0u8; 8192.min(size as usize)]; + let n = tokio::io::AsyncReadExt::read(&mut reader, &mut head).await?; + head.truncate(n); + let mut hasher = Sha256::new(); + hasher.update(&head); + let hash = hasher.finalize(); + Ok(format!("{}:{}:{}", hex::encode(hash), n, size)) +} + +/// 验证路径在同步根目录下,防止路径遍历攻击 +pub fn validate_path(root: &Path, path: &Path) -> Result<()> { + let canonical_root = root.canonicalize().map_err(|e| SyncError::FileSystem( + format!("无法解析根目录: {}", e) + ))?; + + let canonical_path = path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()); + + let relative = canonical_path.strip_prefix(&canonical_root) + .map_err(|_| SyncError::PathTraversal { + path: path.to_string_lossy().to_string(), + root: canonical_root.to_string_lossy().to_string(), + })?; + + if relative.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return Err(SyncError::PathTraversal { + path: path.to_string_lossy().to_string(), + root: canonical_root.to_string_lossy().to_string(), + }); + } + + Ok(()) +} + +/// 路径规范化:统一使用正斜杠,去除尾部斜杠 +pub fn normalize_path(path: &str) -> String { + let p = path.replace('\\', "/"); + p.trim_end_matches('/').to_string() +} + +/// 冲突文件标记前缀 +pub const CONFLICT_MARKER: &str = "sync-conflict"; + +/// 判断文件名是否为冲突副本 +pub fn is_conflict_file(name: &str) -> bool { + name.contains(CONFLICT_MARKER) +} + +/// 生成冲突副本名称 +pub fn generate_conflict_name(original: &str) -> String { + let path = PathBuf::from(original); + let stem = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let ext = path.extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let date = chrono::Local::now().format("%Y-%m-%d"); + if ext.is_empty() { + format!("{}.{}.{}", stem, CONFLICT_MARKER, date) + } else { + format!("{}.{}.{}.{}", stem, CONFLICT_MARKER, date, ext) + } +} + +/// 计算重试延迟(指数退避) +pub fn retry_delay_ms(attempt: u32, base_ms: u64, max_ms: u64) -> u64 { + let delay = base_ms * 2u64.saturating_pow(attempt); + delay.min(max_ms) +} + +/// 判断路径是否为同步临时文件 +pub fn is_temp_file(path: &Path) -> bool { + path.extension() + .map(|ext| ext == "sync_tmp" || ext == "sync_temp") + .unwrap_or(false) +} + +/// 清理目录下残留的 .sync_tmp 文件 +pub fn cleanup_temp_files<'a>(dir: &'a Path) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + let mut cleaned = 0u32; + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(e) => e, + Err(_) => return 0, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + cleaned += cleanup_temp_files(&path).await; + } else if is_temp_file(&path) && tokio::fs::remove_file(&path).await.is_ok() { + tracing::debug!("清理临时文件: {}", path.display()); + cleaned += 1; + } + } + + cleaned + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_path() { + assert_eq!(normalize_path("foo\\bar\\baz"), "foo/bar/baz"); + assert_eq!(normalize_path("foo/bar/"), "foo/bar"); + assert_eq!(normalize_path("/foo/bar"), "/foo/bar"); + } + + #[test] + fn test_generate_conflict_name() { + let name = generate_conflict_name("photo.jpg"); + assert!(name.starts_with("photo.sync-conflict.")); + assert!(name.ends_with(".jpg")); + + let name_no_ext = generate_conflict_name("README"); + assert!(name_no_ext.starts_with("README.sync-conflict.")); + } + + #[test] + fn test_is_conflict_file() { + assert!(is_conflict_file("photo.sync-conflict.2026-05-14.jpg")); + assert!(is_conflict_file("README.sync-conflict.2026-05-14")); + assert!(!is_conflict_file("photo.jpg")); + assert!(!is_conflict_file("normal_file.txt")); + } + + #[test] + fn test_retry_delay() { + assert_eq!(retry_delay_ms(0, 1000, 60000), 1000); + assert_eq!(retry_delay_ms(1, 1000, 60000), 2000); + assert_eq!(retry_delay_ms(5, 1000, 60000), 32000); + assert_eq!(retry_delay_ms(10, 1000, 60000), 60000); + } +} diff --git a/native/sync-core/src/worker/mod.rs b/native/sync-core/src/worker/mod.rs new file mode 100644 index 0000000..c93aa12 --- /dev/null +++ b/native/sync-core/src/worker/mod.rs @@ -0,0 +1,7 @@ +mod worker_impl; +mod pool; + +pub use worker_impl::Worker; +#[cfg(feature = "windows-cfapi")] +pub use worker_impl::PlaceholderCreator; +pub use pool::WorkerPool; diff --git a/native/sync-core/src/worker/pool.rs b/native/sync-core/src/worker/pool.rs new file mode 100644 index 0000000..a5f7d76 --- /dev/null +++ b/native/sync-core/src/worker/pool.rs @@ -0,0 +1,472 @@ +use crate::api_client::ApiClient; +use crate::conflict_resolver::ConflictResolver; +use crate::errors::{Result, SyncError}; +use crate::file_lock::FileLockRegistry; +use crate::models::*; +use crate::sync_db::SyncDb; +use super::worker_impl::Worker; +#[cfg(feature = "windows-cfapi")] +use super::worker_impl::PlaceholderCreator; +use dashmap::DashMap; +use dashmap::DashSet; +use std::sync::Arc; +use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; + +/// WorkerPool — 全局 Worker 并发控制 +pub struct WorkerPool { + worker_semaphore: Arc, + active_workers: Arc>>, + /// 当前正在上传的相对路径集合,用于去重 + active_upload_paths: Arc>, + /// 活跃 worker 总数(包括 submit 阻塞型 + submit_background 后台型) + active_count: Arc, + db: Arc, + api: Arc, + file_locks: Arc, + ensured_dirs: Arc>, + event_sink: Arc, + shutdown_token: std::sync::Mutex, + #[cfg(feature = "windows-cfapi")] + platform_adapter: std::sync::Mutex>>, +} + +impl WorkerPool { + #[allow(clippy::too_many_arguments)] + pub fn new( + db: Arc, + api: Arc, + file_locks: Arc, + ensured_dirs: Arc>, + event_sink: Arc, + shutdown_token: CancellationToken, + max_workers_override: usize, + client_id: &str, + ) -> Self { + let cpu_count = num_cpus(); + let max_workers = if max_workers_override > 0 { + max_workers_override.min(cpu_count * 2).max(1) + } else { + cpu_count.clamp(1, 32) + }; + tracing::info!( + "WorkerPool 初始化: 最大并发 Worker 数={} (cpu={}, override={})", + max_workers, + cpu_count, + max_workers_override + ); + tracing::info!("Client ID: {}", client_id); + + Self { + worker_semaphore: Arc::new(Semaphore::new(max_workers)), + active_workers: Arc::new(DashMap::new()), + active_upload_paths: Arc::new(DashSet::new()), + active_count: Arc::new(std::sync::atomic::AtomicU32::new(0)), + db, + api, + file_locks, + ensured_dirs, + event_sink, + shutdown_token: std::sync::Mutex::new(shutdown_token), + #[cfg(feature = "windows-cfapi")] + platform_adapter: std::sync::Mutex::new(None), + } + } + + /// 提交 Worker(阻塞等待结果) + pub async fn submit( + &self, + plan: SyncPlan, + config: WorkerConfig, + trigger: WorkerTrigger, + conflict_resolver: ConflictResolver, + ) -> Result { + if !plan.has_work() { + tracing::debug!("SyncPlan 无操作,跳过 Worker 创建"); + return Ok(SyncSummary::default()); + } + + let task_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let total_count = plan.uploads.len() as u32 + + plan.downloads.len() as u32 + + plan.delete_local.len() as u32 + + plan.delete_remote.len() as u32 + + plan.rename_remote.len() as u32 + + plan.move_remote.len() as u32 + + plan.rename_local.len() as u32 + + plan.move_local.len() as u32 + + plan.conflicts.len() as u32; + + let task = SyncTask { + id: task_id.clone(), + trigger: trigger.clone(), + total_count, + completed_count: 0, + failed_count: 0, + status: WorkerStatus::Pending, + created_at: now.clone(), + updated_at: now, + finished_at: None, + }; + self.db.create_sync_task(&task).await?; + + let now_for_items = chrono::Utc::now().to_rfc3339(); + self.create_task_items(&task_id, &plan, &now_for_items, &config.sync_mode) + .await?; + + let _permit = self + .worker_semaphore + .acquire() + .await + .map_err(|e| SyncError::Internal(format!("获取 Worker 信号量失败: {}", e)))?; + + let worker = Worker::new( + task_id.clone(), + trigger, + config, + plan, + self.db.clone(), + self.api.clone(), + self.file_locks.clone(), + self.ensured_dirs.clone(), + conflict_resolver, + self.event_sink.clone(), + self.shutdown_token.lock().unwrap().clone(), + #[cfg(feature = "windows-cfapi")] + self.platform_adapter.lock().unwrap().clone(), + ); + + let _ = self + .event_sink + .emit(crate::api::ffi_types::SyncEventFfi::WorkerStarted { + task_id: task_id.clone(), + trigger: task.trigger.as_str().to_string(), + upload_count: task.total_count, + download_count: 0, + }) + .await; + + self.active_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let result = worker.run().await; + self.active_count + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + result + } + + /// 提交 Worker(后台运行) + pub async fn submit_background( + &self, + plan: SyncPlan, + config: WorkerConfig, + trigger: WorkerTrigger, + conflict_resolver: ConflictResolver, + ) -> Option { + if !plan.has_work() { + return None; + } + + let task_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let total_count = plan.uploads.len() as u32 + + plan.downloads.len() as u32 + + plan.delete_local.len() as u32 + + plan.delete_remote.len() as u32 + + plan.rename_remote.len() as u32 + + plan.move_remote.len() as u32 + + plan.rename_local.len() as u32 + + plan.move_local.len() as u32 + + plan.conflicts.len() as u32; + + let task = SyncTask { + id: task_id.clone(), + trigger: trigger.clone(), + total_count, + completed_count: 0, + failed_count: 0, + status: WorkerStatus::Pending, + created_at: now.clone(), + updated_at: now, + finished_at: None, + }; + if let Err(e) = self.db.create_sync_task(&task).await { + tracing::error!("创建同步任务记录失败: {}", e); + } + + let now_for_items = chrono::Utc::now().to_rfc3339(); + if let Err(e) = self + .create_task_items(&task_id, &plan, &now_for_items, &config.sync_mode) + .await + { + tracing::warn!("创建任务项记录失败: {}", e); + } + + let tid = task_id.clone(); + let trigger_str = trigger.as_str().to_string(); + let upload_count = plan.uploads.len() as u32; + let download_count = plan.downloads.len() as u32; + + let _ = self + .event_sink + .emit(crate::api::ffi_types::SyncEventFfi::WorkerStarted { + task_id: tid.clone(), + trigger: trigger_str, + upload_count, + download_count, + }) + .await; + + self.register_upload_paths(&plan); + let upload_paths_for_cleanup: Vec = plan + .uploads + .iter() + .map(|a| a.relative_path.clone()) + .collect(); + + let sem = self.worker_semaphore.clone(); + let db = self.db.clone(); + let api = self.api.clone(); + let file_locks = self.file_locks.clone(); + let ensured_dirs = self.ensured_dirs.clone(); + let event_sink = self.event_sink.clone(); + let shutdown_token = self.shutdown_token.lock().unwrap().clone(); + let active_workers = self.active_workers.clone(); + let active_upload_paths = self.active_upload_paths.clone(); + let active_count = self.active_count.clone(); + #[cfg(feature = "windows-cfapi")] + let platform_adapter = self.platform_adapter.lock().unwrap().clone(); + + self.active_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + let worker = Worker::new( + task_id.clone(), + trigger, + config, + plan, + db, + api, + file_locks, + ensured_dirs, + conflict_resolver, + event_sink, + shutdown_token, + #[cfg(feature = "windows-cfapi")] + platform_adapter, + ); + if let Err(e) = worker.run().await { + tracing::error!("[{}] Worker后台执行失败: {}", task_id, e); + } + active_workers.remove(&task_id); + for path in &upload_paths_for_cleanup { + active_upload_paths.remove(path); + } + active_count.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + }); + + self.active_workers.insert(tid.clone(), handle); + Some(tid) + } + + #[cfg(feature = "windows-cfapi")] + pub fn set_platform_adapter(&self, adapter: Arc) { + *self.platform_adapter.lock().unwrap() = Some(adapter); + } + + /// 当前活跃 Worker 数(含阻塞型和后台型) + pub fn active_worker_count(&self) -> usize { + self.active_count.load(std::sync::atomic::Ordering::Relaxed) as usize + } + + /// 检查指定相对路径是否正在上传中 + pub fn is_uploading(&self, relative_path: &str) -> bool { + self.active_upload_paths.contains(relative_path) + } + + /// 注册上传路径到去重集合 + fn register_upload_paths(&self, plan: &SyncPlan) { + for action in &plan.uploads { + self.active_upload_paths + .insert(action.relative_path.clone()); + } + } + + /// 更新 shutdown token(引擎重启时调用) + pub fn update_shutdown_token(&self, token: CancellationToken) { + *self.shutdown_token.lock().unwrap() = token; + } + + /// 终止所有活跃 Worker 并等待退出 + pub async fn abort_all_workers(&self) { + let ids: Vec = self + .active_workers + .iter() + .map(|e| e.key().clone()) + .collect(); + for id in ids { + if let Some((_, handle)) = self.active_workers.remove(&id) { + handle.abort(); + let _ = handle.await; + } + } + self.active_upload_paths.clear(); + self.active_count + .store(0, std::sync::atomic::Ordering::Relaxed); + } + + /// 创建 task_item 记录(批量插入,单次持锁) + async fn create_task_items( + &self, + task_id: &str, + plan: &SyncPlan, + now: &str, + sync_mode: &SyncMode, + ) -> Result<()> { + let mut items: Vec = Vec::new(); + + for action in &plan.uploads { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: action.relative_path.clone(), + action_type: TaskActionType::Upload, + status: TaskItemStatus::Pending, + file_size: action.local_entry.as_ref().map(|l| l.size).unwrap_or(0), + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for action in &plan.downloads { + let action_type = if matches!(sync_mode, SyncMode::MirrorWcf) { + TaskActionType::CreatePlaceholder + } else { + TaskActionType::Download + }; + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: action.relative_path.clone(), + action_type, + status: TaskItemStatus::Pending, + file_size: action.remote_entry.as_ref().map(|r| r.size).unwrap_or(0), + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for action in &plan.delete_local { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: action.relative_path.clone(), + action_type: TaskActionType::DeleteLocal, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for action in &plan.delete_remote { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: action.relative_path.clone(), + action_type: TaskActionType::DeleteRemote, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for rename in &plan.rename_remote { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: format!( + "{} -> {}", + rename.old_relative_path, rename.new_relative_path + ), + action_type: TaskActionType::Rename, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for mov in &plan.move_remote { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: format!("{} -> {}", mov.old_relative_path, mov.new_relative_path), + action_type: TaskActionType::Move, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for conflict in &plan.conflicts { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: conflict.relative_path.clone(), + action_type: TaskActionType::ConflictResolve, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for action in &plan.rename_local { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: format!( + "{} -> {}", + action.old_relative_path, action.new_relative_path + ), + action_type: TaskActionType::Rename, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + for action in &plan.move_local { + items.push(SyncTaskItem { + id: 0, + task_id: task_id.to_string(), + relative_path: format!( + "{} -> {}", + action.old_relative_path, action.new_relative_path + ), + action_type: TaskActionType::Move, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + + self.db.create_sync_task_items_batch(&items).await + } +} + +fn num_cpus() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) +} diff --git a/native/sync-core/src/worker/worker_impl.rs b/native/sync-core/src/worker/worker_impl.rs new file mode 100644 index 0000000..8f415b8 --- /dev/null +++ b/native/sync-core/src/worker/worker_impl.rs @@ -0,0 +1,1273 @@ +use crate::api_client::ApiClient; +use crate::conflict_resolver::ConflictResolver; +use crate::errors::Result; +use crate::file_lock::FileLockRegistry; +use crate::models::*; +use crate::sync_db::SyncDb; +use dashmap::DashMap; +#[cfg(feature = "windows-cfapi")] +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; + +/// 占位符创建接口(由 platform::wcf 模块实现) +#[cfg(feature = "windows-cfapi")] +#[async_trait::async_trait] +pub trait PlaceholderCreator: Send + Sync { + async fn create_placeholder_file( + &self, + base_dir: &Path, + file_name: String, + file_size: u64, + file_identity: &[u8], + ) -> Result<()>; +} + +/// Worker — 最小调度单元,拥有独立 UUID 和配置快照 +pub struct Worker { + pub task_id: String, + trigger: WorkerTrigger, + config: WorkerConfig, + plan: SyncPlan, + db: Arc, + api: Arc, + file_locks: Arc, + ensured_dirs: Arc>, + conflict_resolver: ConflictResolver, + event_sink: Arc, + shutdown_token: CancellationToken, + #[cfg(feature = "windows-cfapi")] + platform_adapter: Option>, +} + +impl Worker { + #[allow(clippy::too_many_arguments)] + pub fn new( + task_id: String, + trigger: WorkerTrigger, + config: WorkerConfig, + plan: SyncPlan, + db: Arc, + api: Arc, + file_locks: Arc, + ensured_dirs: Arc>, + conflict_resolver: ConflictResolver, + event_sink: Arc, + shutdown_token: CancellationToken, + #[cfg(feature = "windows-cfapi")] platform_adapter: Option>, + ) -> Self { + Self { + task_id, + trigger, + config, + plan, + db, + api, + file_locks, + ensured_dirs, + conflict_resolver, + event_sink, + shutdown_token, + #[cfg(feature = "windows-cfapi")] + platform_adapter, + } + } + + /// 执行 Worker 任务(编排器,调用各步骤方法) + pub async fn run(mut self) -> Result { + let start = Instant::now(); + let tid = self.task_id.clone(); + let trigger_str = self.trigger.as_str(); + + tracing::info!( + "[{}] Worker启动: trigger={}, 上传={}, 下载={}, 删本地={}, 删远程={}, 重命名={}, 移动={}, 冲突={}", + tid, trigger_str, + self.plan.uploads.len(), + self.plan.downloads.len(), + self.plan.delete_local.len(), + self.plan.delete_remote.len(), + self.plan.rename_remote.len(), + self.plan.move_remote.len(), + self.plan.conflicts.len(), + ); + + let _ = self + .db + .update_sync_task_status(&tid, &WorkerStatus::Running) + .await; + + // 全局传输并发信号量,冲突/上传/下载共享 + let transfer_semaphore = + Arc::new(Semaphore::new(self.config.max_concurrent_transfers.max(1))); + + let mut summary = SyncSummary::default(); + + self.step_create_remote_dirs().await; + self.step_create_local_dirs().await; + self.step_rename_remote(&mut summary).await; + self.step_move_remote(&mut summary).await; + self.step_scan_new_dirs(&mut summary).await; + self.step_resolve_conflicts(&mut summary, &transfer_semaphore).await; + self.step_execute_uploads(&mut summary, &transfer_semaphore).await; + self.step_execute_downloads_or_placeholders(&mut summary, &transfer_semaphore).await; + self.step_delete_local(&mut summary).await; + self.step_rename_local(&mut summary).await; + self.step_move_local(&mut summary).await; + self.step_delete_remote(&mut summary).await; + + let duration_ms = start.elapsed().as_millis() as u64; + let final_status = if self.shutdown_token.is_cancelled() { + WorkerStatus::Cancelled + } else if summary.failed > 0 + && summary.uploaded + summary.downloaded + summary.renamed + summary.moved == 0 + { + WorkerStatus::Failed + } else { + WorkerStatus::Completed + }; + + let completed_count = summary.uploaded + + summary.downloaded + + summary.renamed + + summary.moved + + summary.deleted_local + + summary.deleted_remote; + let _ = self + .db + .finish_sync_task(&tid, &final_status, completed_count, summary.failed) + .await; + + tracing::info!( + "[{}] Worker完成: 上传={}, 下载={}, 失败={}, 跳过={}, 重命名={}, 移动={}, 删本地={}, 删远程={}, 冲突={}, 耗时={}ms", + tid, summary.uploaded, summary.downloaded, summary.failed, summary.skipped, + summary.renamed, summary.moved, summary.deleted_local, summary.deleted_remote, + summary.conflicts, duration_ms, + ); + + summary.duration_ms = duration_ms; + + let _ = self + .event_sink + .emit(crate::api::ffi_types::SyncEventFfi::WorkerCompleted { + task_id: tid.clone(), + uploaded: summary.uploaded, + downloaded: summary.downloaded, + renamed: summary.renamed, + moved: summary.moved, + failed: summary.failed, + duration_ms, + }) + .await; + + Ok(summary) + } + + // ===== 步骤方法 ===== + + /// 1. 创建远程目录结构(UploadOnly / Full / MirrorWcf) + async fn step_create_remote_dirs(&self) { + if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + return; + } + let tid = &self.task_id; + for dir_path in &self.plan.mkdirs_remote { + if self.shutdown_token.is_cancelled() { + break; + } + match crate::uploader::ensure_remote_dirs( + tid, + &self.config.remote_root, + dir_path, + &self.api, + &self.ensured_dirs, + ) + .await + { + Ok(_) => tracing::debug!("[{}] 创建远程目录: {}", tid, dir_path), + Err(e) => tracing::warn!("[{}] 创建远程目录失败 {}: {}", tid, dir_path, e), + } + } + } + + /// 2. 创建本地目录结构(DownloadOnly / Full / MirrorWcf) + async fn step_create_local_dirs(&self) { + if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + return; + } + let tid = &self.task_id; + for dir_path in &self.plan.mkdirs_local { + if self.shutdown_token.is_cancelled() { + break; + } + let local_path = self.config.local_root.join(dir_path); + if let Err(e) = tokio::fs::create_dir_all(&local_path).await { + tracing::warn!("[{}] 创建本地目录失败 {}: {}", tid, dir_path, e); + } + } + } + + /// 2.1 执行远程重命名(UploadOnly / Full / MirrorWcf) + async fn step_rename_remote(&self, summary: &mut SyncSummary) { + if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for rename in &self.plan.rename_remote { + if self.shutdown_token.is_cancelled() { + break; + } + let rel_path = format!( + "{} -> {}", + rename.old_relative_path, rename.new_relative_path + ); + match self + .api + .rename_file(&rename.remote_uri, &rename.new_name) + .await + { + Ok(_) => { + tracing::info!( + "[{}] 远程重命名: {} -> {}", + tid, + rename.old_relative_path, + rename.new_relative_path + ); + summary.renamed += 1; + let _ = self.db.increment_task_completed(tid).await; + let new_remote_uri = { + let uri = &rename.remote_uri; + let last_slash = uri.trim_end_matches('/').rfind('/').unwrap_or(0); + format!("{}/{}", &uri[..last_slash], rename.new_name) + }; + let _ = self + .db + .update_file_mapping_path( + root_id, + &rename.old_relative_path, + &rename.new_relative_path, + &new_remote_uri, + ) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "rename", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Err(e) => { + tracing::error!( + "[{}] 远程重命名失败: {} -> {}: {}", + tid, + rename.old_relative_path, + rename.new_relative_path, + e + ); + summary.failed += 1; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "rename", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + + /// 2.2 执行远程移动(本地触发) + async fn step_move_remote(&self, summary: &mut SyncSummary) { + if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for mov in &self.plan.move_remote { + if self.shutdown_token.is_cancelled() { + break; + } + let rel_path = format!("{} -> {}", mov.old_relative_path, mov.new_relative_path); + match self + .api + .move_files(&[&mov.remote_uri], &mov.dst_remote_dir_uri, false) + .await + { + Ok(_) => { + tracing::info!( + "[{}] 远程移动: {} -> {}", + tid, + mov.old_relative_path, + mov.new_relative_path + ); + summary.moved += 1; + let _ = self.db.increment_task_completed(tid).await; + let _ = self + .db + .update_file_mapping_path( + root_id, + &mov.old_relative_path, + &mov.new_relative_path, + &mov.dst_remote_dir_uri, + ) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "move", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Err(e) => { + tracing::error!( + "[{}] 远程移动失败: {} -> {}: {}", + tid, + mov.old_relative_path, + mov.new_relative_path, + e + ); + summary.failed += 1; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "move", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + + /// 2.5 递归扫描 scan_dirs 中的目录,将文件加入 uploads + async fn step_scan_new_dirs(&mut self, _summary: &mut SyncSummary) { + if self.plan.scan_dirs.is_empty() { + return; + } + let tid = &self.task_id; + let root_id = self.config.sync_root_id.clone(); + + tracing::info!( + "[{}] 开始递归扫描 {} 个目录", + tid, + self.plan.scan_dirs.len() + ); + let scanner = crate::fs_scanner::FsScanner::new(); + for dir_rel in &self.plan.scan_dirs { + if self.shutdown_token.is_cancelled() { + break; + } + let dir_path = self.config.local_root.join(dir_rel); + if !dir_path.is_dir() { + tracing::warn!("[{}] 目录不存在,跳过: {}", tid, dir_path.display()); + continue; + } + let compute_hash = !matches!(self.config.sync_mode, SyncMode::MirrorWcf); + match scanner.scan(&dir_path, 50, false, compute_hash).await { + Ok(entries) => { + for entry in entries { + if !entry.is_dir && entry.size == 0 { + tracing::debug!( + "[{}] 跳过空文件: {}", + tid, + entry.relative_path.display() + ); + continue; + } + let full_relative = if dir_rel.is_empty() { + entry.relative_path.to_string_lossy().to_string() + } else { + format!("{}/{}", dir_rel, entry.relative_path.to_string_lossy()) + }; + let full_relative = crate::utils::normalize_path(&full_relative); + + let root_id_c = root_id.clone(); + let db_mapping = self + .db + .get_file_mapping(&root_id_c, &full_relative) + .await + .ok() + .flatten(); + if let Some(ref mapping) = db_mapping { + if mapping.local_hash.as_deref() == Some(&entry.quick_hash) { + continue; + } + } + + if entry.is_dir { + self.plan.mkdirs_remote.push(full_relative.clone()); + } else { + let mut local_entry = entry.clone(); + local_entry.relative_path = + std::path::PathBuf::from(&full_relative); + self.plan.uploads.push(SyncAction { + relative_path: full_relative, + local_entry: Some(local_entry), + remote_entry: None, + db_mapping, + }); + } + } + tracing::info!("[{}] 目录扫描完成: {}", tid, dir_rel); + } + Err(e) => { + tracing::error!("[{}] 扫描目录失败 {}: {}", tid, dir_rel, e); + } + } + } + + // 为扫描目录新增的 uploads 和 mkdirs_remote 创建 task_item 记录 + let now = chrono::Utc::now().to_rfc3339(); + for action in &self.plan.uploads { + let is_from_scan = self.plan.scan_dirs.iter().any(|dir| { + action.relative_path.starts_with(dir.as_str()) + && action.relative_path.as_bytes().get(dir.len()) == Some(&b'/') + }); + if is_from_scan { + let item = SyncTaskItem { + id: 0, + task_id: tid.clone(), + relative_path: action.relative_path.clone(), + action_type: TaskActionType::Upload, + status: TaskItemStatus::Pending, + file_size: action.local_entry.as_ref().map(|l| l.size).unwrap_or(0), + error_message: None, + created_at: now.clone(), + updated_at: now.clone(), + }; + let _ = self.db.create_sync_task_item(&item).await; + } + } + for dir_path in &self.plan.mkdirs_remote { + let is_from_scan = self.plan.scan_dirs.iter().any(|dir| { + dir_path.starts_with(dir.as_str()) + && dir_path.as_bytes().get(dir.len()) == Some(&b'/') + }); + if is_from_scan { + let item = SyncTaskItem { + id: 0, + task_id: tid.clone(), + relative_path: dir_path.clone(), + action_type: TaskActionType::MkdirRemote, + status: TaskItemStatus::Pending, + file_size: 0, + error_message: None, + created_at: now.clone(), + updated_at: now.clone(), + }; + let _ = self.db.create_sync_task_item(&item).await; + } + } + + let new_total = self.plan.uploads.len() as u32 + + self.plan.downloads.len() as u32 + + self.plan.delete_local.len() as u32 + + self.plan.delete_remote.len() as u32 + + self.plan.conflicts.len() as u32; + let _ = self.db.update_sync_task_total_count(tid, new_total).await; + } + + /// 3. 处理冲突(串行) + async fn step_resolve_conflicts(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for conflict in &self.plan.conflicts { + if self.shutdown_token.is_cancelled() { + break; + } + let local_mtime = conflict + .local_entry + .as_ref() + .map(|l| l.mtime_ms) + .unwrap_or(0); + let remote_mtime = conflict + .remote_entry + .as_ref() + .map(|r| r.mtime_ms) + .unwrap_or(0); + let local_size = conflict.local_entry.as_ref().map(|l| l.size).unwrap_or(0); + let remote_size = conflict.remote_entry.as_ref().map(|r| r.size).unwrap_or(0); + let local_name = conflict + .local_entry + .as_ref() + .map(|l| { + l.relative_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let resolution = self.conflict_resolver.resolve( + conflict.conflict_type.clone(), + local_mtime, + remote_mtime, + local_size, + remote_size, + &local_name, + ); + + tracing::info!( + "[{}] 冲突解决: {} → {:?}", + tid, + conflict.relative_path, + resolution + ); + + let mut conflict_ok = false; + match resolution { + ConflictResolution::UploadLocal => { + if let Some(ref local) = conflict.local_entry { + let action = SyncAction { + relative_path: conflict.relative_path.clone(), + local_entry: Some(local.clone()), + remote_entry: conflict.remote_entry.clone(), + db_mapping: conflict.db_mapping.clone(), + }; + match crate::uploader::upload_file( + tid, + &action, + &self.config, + &self.api, + &self.db, + &self.file_locks, + &self.ensured_dirs, + transfer_semaphore, + root_id, + ) + .await + { + Ok(_) => { + summary.uploaded += 1; + conflict_ok = true; + } + Err(e) => { + tracing::error!( + "[{}] 冲突上传失败: {}: {}", + tid, + conflict.relative_path, + e + ); + summary.conflicts += 1; + } + } + } + } + ConflictResolution::DownloadRemote => { + if let Some(ref remote) = conflict.remote_entry { + let action = SyncAction { + relative_path: conflict.relative_path.clone(), + local_entry: conflict.local_entry.clone(), + remote_entry: Some(remote.clone()), + db_mapping: conflict.db_mapping.clone(), + }; + match crate::downloader::download_file( + tid, + &action, + &self.config, + &self.api, + &self.db, + &self.file_locks, + transfer_semaphore, + root_id, + ) + .await + { + Ok(_) => { + summary.downloaded += 1; + conflict_ok = true; + } + Err(e) => { + tracing::error!( + "[{}] 冲突下载失败: {}: {}", + tid, + conflict.relative_path, + e + ); + summary.conflicts += 1; + } + } + } + } + ConflictResolution::RenameLocal { ref new_name } => { + if let Some(ref local) = conflict.local_entry { + let old_path = self.config.local_root.join(&local.relative_path); + let mut new_rel_path = local.relative_path.clone(); + new_rel_path.pop(); + new_rel_path.push(new_name); + let new_rel = crate::utils::normalize_path(&new_rel_path.to_string_lossy()); + let new_path = self.config.local_root.join(&new_rel_path); + let mut rename_retries = 0u32; + let renamed = loop { + match tokio::fs::rename(&old_path, &new_path).await { + Ok(_) => break true, + Err(e) if e.raw_os_error() == Some(5) && rename_retries < 10 => { + rename_retries += 1; + let delay = rename_retries as u64 * 1000; + tracing::warn!( + "[{}] 重命名文件被占用,{}ms后重试 ({})", + tid, + delay, + rename_retries + ); + tokio::time::sleep(std::time::Duration::from_millis(delay)) + .await; + } + Err(e) => { + tracing::warn!("[{}] 重命名冲突文件失败: {}", tid, e); + break false; + } + } + }; + if renamed { + conflict_ok = true; + tracing::info!("[{}] 冲突文件已重命名保留在本地: {}", tid, new_rel); + if let Some(ref remote) = conflict.remote_entry { + let action = SyncAction { + relative_path: conflict.relative_path.clone(), + local_entry: None, + remote_entry: Some(remote.clone()), + db_mapping: None, + }; + if let Err(e) = crate::downloader::download_file( + tid, + &action, + &self.config, + &self.api, + &self.db, + &self.file_locks, + transfer_semaphore, + root_id, + ) + .await + { + tracing::warn!("[{}] 下载远程冲突版本失败: {}", tid, e); + } else { + summary.downloaded += 1; + } + } + } + } + } + ConflictResolution::DeleteLocal => { + if let Some(ref local) = conflict.local_entry { + let local_path = self.config.local_root.join(&local.relative_path); + let _ = tokio::fs::remove_file(&local_path).await; + summary.deleted_local += 1; + conflict_ok = true; + tracing::info!("[{}] 删除本地(冲突解决): {}", tid, conflict.relative_path); + } + } + ConflictResolution::DeleteRemote => { + if let Some(ref remote) = conflict.remote_entry { + let _ = self.api.delete_files(&[&remote.uri]).await; + summary.deleted_remote += 1; + conflict_ok = true; + tracing::info!("[{}] 删除远程(冲突解决): {}", tid, conflict.relative_path); + } + } + ConflictResolution::MarkManual => { + summary.conflicts += 1; + tracing::warn!("[{}] 手动解决冲突: {}", tid, conflict.relative_path); + } + } + let item_status = if conflict_ok { + TaskItemStatus::Completed + } else { + TaskItemStatus::Failed + }; + if conflict_ok { + let _ = self.db.increment_task_completed(tid).await; + } + let _ = self + .db + .update_task_item_status_by_path( + tid, + &conflict.relative_path, + "conflict_resolve", + &item_status, + None, + ) + .await; + } + } + + /// 4. 并发上传(UploadOnly / Full / MirrorWcf) + async fn step_execute_uploads(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { + if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + return; + } + let tid = &self.task_id; + let root_id = self.config.sync_root_id.clone(); + + let mut upload_handles: Vec<(String, tokio::task::JoinHandle>)> = Vec::new(); + for action in &self.plan.uploads { + if self.shutdown_token.is_cancelled() { + break; + } + let action = action.clone(); + let tid_c = tid.clone(); + let config = self.config.clone(); + let api = self.api.clone(); + let db = self.db.clone(); + let file_locks = self.file_locks.clone(); + let ensured_dirs = self.ensured_dirs.clone(); + let sem = transfer_semaphore.clone(); + let root_id_c = root_id.clone(); + let rel_path = action.relative_path.clone(); + + let handle = tokio::spawn(async move { + crate::uploader::upload_file( + &tid_c, + &action, + &config, + &api, + &db, + &file_locks, + &ensured_dirs, + &sem, + &root_id_c, + ) + .await + }); + upload_handles.push((rel_path, handle)); + } + + for (rel_path, handle) in upload_handles { + match handle.await { + Ok(Ok(_)) => { + summary.uploaded += 1; + let _ = self.db.increment_task_completed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "upload", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Ok(Err(e)) => { + tracing::error!("[{}] 上传失败: {}: {}", tid, rel_path, e); + summary.failed += 1; + let _ = self.db.increment_task_failed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "upload", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + Err(e) => { + tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e); + summary.failed += 1; + let _ = self.db.increment_task_failed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "upload", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + + /// 5. 并发下载(DownloadOnly / Full)或 创建占位符(MirrorWcf) + async fn step_execute_downloads_or_placeholders(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { + let tid = &self.task_id; + let root_id = self.config.sync_root_id.clone(); + + if matches!(self.config.sync_mode, SyncMode::MirrorWcf) { + // MirrorWcf: 为每个下载项创建占位符,而非实际下载 + for action in &self.plan.downloads { + if self.shutdown_token.is_cancelled() { + break; + } + let relative = &action.relative_path; + let local_path = self.config.local_root.join(relative); + + if let Some(parent) = local_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + if let Some(ref remote) = action.remote_entry { + if remote.is_dir { + let _ = tokio::fs::create_dir_all(&local_path).await; + } else { + #[cfg(feature = "windows-cfapi")] + { + let file_identity = serde_json::to_vec(&serde_json::json!({ + "uri": remote.uri, + "size": remote.size, + "hash": remote.hash, + "mtime_ms": remote.mtime_ms, + })) + .unwrap_or_default(); + + if let Some(ref adapter) = self.platform_adapter { + match adapter + .create_placeholder_file( + local_path + .parent() + .unwrap_or(self.config.local_root.as_path()), + local_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + remote.size, + &file_identity, + ) + .await + { + Ok(_) => tracing::debug!("[{}] 创建占位符: {}", tid, relative), + Err(e) => { + tracing::warn!( + "[{}] 创建占位符失败,降级空文件 {}: {}", + tid, + relative, + e + ); + let _ = tokio::fs::write(&local_path, []).await; + } + } + } else { + tracing::error!( + "[{}] platform_adapter 未初始化,降级空文件: {}", + tid, + relative + ); + let _ = tokio::fs::write(&local_path, []).await; + } + } + #[cfg(not(feature = "windows-cfapi"))] + { + let _ = tokio::fs::write(&local_path, []).await; + tracing::warn!( + "[{}] MirrorWcf 不可用,创建空文件降级: {}", + tid, + relative + ); + } + } + + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.clone(), + local_path: std::path::PathBuf::from(relative), + remote_uri: remote.uri.clone(), + remote_file_id: remote.file_id.clone(), + local_hash: None, + remote_hash: remote.hash.clone(), + local_mtime: None, + remote_mtime: Some(remote.mtime_ms), + local_size: None, + remote_size: Some(remote.size), + sync_status: SyncFileStatus::Placeholder, + is_placeholder: true, + }) + .await; + } + + let _ = self.db.increment_task_completed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + relative, + "create_placeholder", + &TaskItemStatus::Completed, + None, + ) + .await; + } + } else if !matches!(self.config.sync_mode, SyncMode::UploadOnly) { + let mut download_handles: Vec<(String, tokio::task::JoinHandle>)> = + Vec::new(); + for action in &self.plan.downloads { + if self.shutdown_token.is_cancelled() { + break; + } + let action = action.clone(); + let tid_c = tid.clone(); + let config = self.config.clone(); + let api = self.api.clone(); + let db = self.db.clone(); + let file_locks = self.file_locks.clone(); + let sem = transfer_semaphore.clone(); + let root_id_c = root_id.clone(); + let rel_path = action.relative_path.clone(); + + let handle = tokio::spawn(async move { + crate::downloader::download_file( + &tid_c, + &action, + &config, + &api, + &db, + &file_locks, + &sem, + &root_id_c, + ) + .await + }); + download_handles.push((rel_path, handle)); + } + + for (rel_path, handle) in download_handles { + match handle.await { + Ok(Ok(_)) => { + summary.downloaded += 1; + let _ = self.db.increment_task_completed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "download", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Ok(Err(e)) => { + tracing::error!("[{}] 下载失败: {}: {}", tid, rel_path, e); + summary.failed += 1; + let _ = self.db.increment_task_failed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "download", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + Err(e) => { + tracing::error!("[{}] 下载任务异常: {}: {}", tid, rel_path, e); + summary.failed += 1; + let _ = self.db.increment_task_failed(tid).await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "download", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + } + + /// 6. 删除本地文件(DownloadOnly / Full / MirrorWcf — 远程删除触发的本地删除) + async fn step_delete_local(&self, summary: &mut SyncSummary) { + if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for action in &self.plan.delete_local { + if self.shutdown_token.is_cancelled() { + break; + } + if let Some(ref local) = action.local_entry { + let local_path = self.config.local_root.join(&local.relative_path); + match tokio::fs::remove_file(&local_path).await { + Ok(_) => { + summary.deleted_local += 1; + let _ = self.db.increment_task_completed(tid).await; + tracing::info!("[{}] 删除本地: {}", tid, action.relative_path); + let _ = self + .db + .delete_file_mapping(root_id, &action.relative_path) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &action.relative_path, + "delete_local", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Err(e) => { + tracing::warn!( + "[{}] 删除本地文件失败 {}: {}", + tid, + action.relative_path, + e + ); + let _ = self + .db + .update_task_item_status_by_path( + tid, + &action.relative_path, + "delete_local", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + } + + /// 6.5 本地重命名(远程触发 → 本地执行 rename) + async fn step_rename_local(&self, summary: &mut SyncSummary) { + if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for action in &self.plan.rename_local { + if self.shutdown_token.is_cancelled() { + break; + } + let old_path = self.config.local_root.join(&action.old_relative_path); + let new_path = self.config.local_root.join(&action.new_relative_path); + let rel_path = format!( + "{} -> {}", + action.old_relative_path, action.new_relative_path + ); + + if let Some(parent) = new_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + match tokio::fs::rename(&old_path, &new_path).await { + Ok(_) => { + summary.renamed += 1; + let _ = self.db.increment_task_completed(tid).await; + tracing::info!( + "[{}] 本地重命名: {} -> {}", + tid, + action.old_relative_path, + action.new_relative_path + ); + let _ = self + .db + .update_file_mapping_path( + root_id, + &action.old_relative_path, + &action.new_relative_path, + &action.new_remote_uri, + ) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "rename", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Err(e) => { + summary.failed += 1; + tracing::warn!( + "[{}] 本地重命名失败: {} -> {}: {}", + tid, + action.old_relative_path, + action.new_relative_path, + e + ); + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "rename", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + + /// 6.6 本地移动(远程触发 → 本地执行 move) + async fn step_move_local(&self, summary: &mut SyncSummary) { + if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + for action in &self.plan.move_local { + if self.shutdown_token.is_cancelled() { + break; + } + let old_path = self.config.local_root.join(&action.old_relative_path); + let new_path = self.config.local_root.join(&action.new_relative_path); + let rel_path = format!( + "{} -> {}", + action.old_relative_path, action.new_relative_path + ); + + if let Some(parent) = new_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + match tokio::fs::rename(&old_path, &new_path).await { + Ok(_) => { + summary.moved += 1; + let _ = self.db.increment_task_completed(tid).await; + tracing::info!( + "[{}] 本地移动: {} -> {}", + tid, + action.old_relative_path, + action.new_relative_path + ); + let _ = self + .db + .update_file_mapping_path( + root_id, + &action.old_relative_path, + &action.new_relative_path, + &action.new_remote_uri, + ) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "move", + &TaskItemStatus::Completed, + None, + ) + .await; + } + Err(e) => { + summary.failed += 1; + tracing::warn!( + "[{}] 本地移动失败: {} -> {}: {}", + tid, + action.old_relative_path, + action.new_relative_path, + e + ); + let _ = self + .db + .update_task_item_status_by_path( + tid, + &rel_path, + "move", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + + /// 7. 删除远程文件(Full 始终删除;MirrorWcf 仅在 WcfDeleteMode::SyncRemote 时删除) + async fn step_delete_remote(&self, summary: &mut SyncSummary) { + let should_delete = matches!(self.config.sync_mode, SyncMode::Full) + || (matches!(self.config.sync_mode, SyncMode::MirrorWcf) + && matches!(self.config.wcf_delete_mode, WcfDeleteMode::SyncRemote)); + if !should_delete { + return; + } + let tid = &self.task_id; + let root_id = &self.config.sync_root_id; + + let remote_uris: Vec<&str> = self + .plan + .delete_remote + .iter() + .filter_map(|a| a.remote_entry.as_ref().map(|r| r.uri.as_str())) + .collect(); + if !remote_uris.is_empty() { + match self.api.delete_files(&remote_uris).await { + Ok(_) => { + summary.deleted_remote += remote_uris.len() as u32; + for _ in &remote_uris { + let _ = self.db.increment_task_completed(tid).await; + } + for uri in &remote_uris { + tracing::info!("[{}] 删除远程: {}", tid, uri); + } + for action in &self.plan.delete_remote { + let _ = self + .db + .delete_file_mapping(root_id, &action.relative_path) + .await; + let _ = self + .db + .update_task_item_status_by_path( + tid, + &action.relative_path, + "delete_remote", + &TaskItemStatus::Completed, + None, + ) + .await; + } + } + Err(e) => { + tracing::error!("[{}] 批量删除远程文件失败: {}", tid, e); + for action in &self.plan.delete_remote { + let _ = self + .db + .update_task_item_status_by_path( + tid, + &action.relative_path, + "delete_remote", + &TaskItemStatus::Failed, + Some(&e.to_string()), + ) + .await; + } + } + } + } + } +} diff --git a/native/sync-linux/Cargo.toml b/native/sync-linux/Cargo.toml new file mode 100644 index 0000000..56b1cfa --- /dev/null +++ b/native/sync-linux/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sync-linux" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib"] + +[dependencies] +# 不依赖 sync-core,避免循环依赖 +notify = { version = "7", features = ["macos_fsevent"] } +walkdir = "2" +thiserror = { workspace = true } +tracing = "0.1" +anyhow = { workspace = true } +tokio = { workspace = true } +async-trait = "0.1" diff --git a/native/sync-linux/src/lib.rs b/native/sync-linux/src/lib.rs new file mode 100644 index 0000000..b855806 --- /dev/null +++ b/native/sync-linux/src/lib.rs @@ -0,0 +1,80 @@ +#![cfg(target_os = "linux")] + +//! Linux 平台适配层 +//! +//! 提供文件系统监听 (notify/inotify) 用于: +//! - 实时检测本地文件变更 +//! - inotify 限制检测与配置 +//! - 混合模式(事件+轮询)切换 + +use std::path::Path; + +pub struct LinuxAdapter { + watcher: Option, +} + +impl Default for LinuxAdapter { + fn default() -> Self { + Self::new() + } +} + +impl LinuxAdapter { + pub fn new() -> Self { + Self { watcher: None } + } + + /// 启动文件监听 (Phase 3) + pub fn start_watching(&mut self, _root: &Path) -> anyhow::Result<()> { + // Phase 3: 实现 notify 监听 + Ok(()) + } + + /// 停止监听 + pub fn stop_watching(&mut self) -> anyhow::Result<()> { + if let Some(w) = self.watcher.take() { + drop(w); + } + Ok(()) + } +} + +/// inotify 限制检测 +pub struct InotifyConfig; + +impl InotifyConfig { + pub fn check_limits() -> InotifyStatus { + let max_watches = std::fs::read_to_string("/proc/sys/fs/inotify/max_user_watches") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(8192); + + let max_user_instances = std::fs::read_to_string("/proc/sys/fs/inotify/max_user_instances") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(128); + + InotifyStatus { + max_watches, + max_user_instances, + recommendation: if max_watches < 524288 { + Some("建议执行: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p".to_string()) + } else { + None + }, + } + } +} + +pub struct InotifyStatus { + pub max_watches: u64, + pub max_user_instances: u64, + pub recommendation: Option, +} + +/// 占位符信息 (Linux 不使用,保持接口一致) +pub struct PlaceholderEntry { + pub relative_path: String, + pub file_size: u64, + pub is_dir: bool, +} diff --git a/native/sync-windows/Cargo.toml b/native/sync-windows/Cargo.toml new file mode 100644 index 0000000..f3efce7 --- /dev/null +++ b/native/sync-windows/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "sync-windows" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib"] + +[dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_CloudFilters", + "Win32_Storage_FileSystem", + "Win32_System_Registry", + "Win32_System_CorrelationVector", + "Win32_System_IO", +] } +thiserror = { workspace = true } +tracing = "0.1" +anyhow = { workspace = true } +tokio = { workspace = true } +async-trait = "0.1" +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } + +[features] +default = ["cfapi"] +cfapi = [] diff --git a/native/sync-windows/src/lib.rs b/native/sync-windows/src/lib.rs new file mode 100644 index 0000000..3aefefc --- /dev/null +++ b/native/sync-windows/src/lib.rs @@ -0,0 +1,455 @@ +#![cfg(target_os = "windows")] + +//! Windows 平台适配层 - Cloud Filter API (CFApi) 集成 + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use tokio::sync::mpsc; + +use windows::core::*; +use windows::Win32::Foundation::*; +use windows::Win32::Storage::CloudFilters::*; +use windows::Win32::Storage::FileSystem::*; + +/// 全局回调通道发送端(CFApi 回调 → 异步运行时) +/// 使用 Mutex 包装以支持重连时替换 +static CALLBACK_SENDER: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +/// CFApi FETCH_DATA 回调请求数据 +#[derive(Debug, Clone)] +pub struct FetchDataRequest { + pub connection_key: i64, + pub transfer_key: i64, + pub file_identity: Vec, + pub required_offset: i64, + pub required_length: i64, +} + +pub struct WindowsAdapter { + connection_key: Option, + callback_rx: Option>, +} + +/// 占位符创建信息 +#[derive(Debug, Clone)] +pub struct PlaceholderEntry { + pub relative_path: String, + pub file_size: u64, + pub is_dir: bool, + pub file_identity: Vec, +} + +impl Default for WindowsAdapter { + fn default() -> Self { + Self::new() + } +} + +impl WindowsAdapter { + pub fn new() -> Self { + Self { + connection_key: None, + callback_rx: None, + } + } + + /// 注册同步根目录(先清理残留注册) + pub fn register_sync_root( + &self, + root_path: &Path, + provider_name: &str, + provider_version: &str, + ) -> anyhow::Result<()> { + unsafe { + let sync_root_path = to_pcwstr(root_path); + + // 先尝试注销残留的同步根(忽略未注册的错误) + let unregister_result = CfUnregisterSyncRoot(PCWSTR(sync_root_path.as_ptr())); + if let Err(e) = unregister_result { + tracing::debug!("注销旧同步根(可忽略): {}", e); + } + + let provider_name_w = to_pcwstr_str(provider_name); + let provider_version_w = to_pcwstr_str(provider_version); + + let provider_name_pcw = PCWSTR(provider_name_w.as_ptr()); + let provider_version_pcw = PCWSTR(provider_version_w.as_ptr()); + + let provider_id = GUID::from_values( + 0x4E_5F_8C_D7, 0xA2_B1, 0x43_9F, [0x8C, 0x12, 0xD5, 0xE7, 0xF3, 0x21, 0x9A, 0xB4], + ); + + let root_identity = b"cloudreve-sync-root".to_vec(); + + let registration = CF_SYNC_REGISTRATION { + StructSize: std::mem::size_of::() as u32, + ProviderName: provider_name_pcw, + ProviderVersion: provider_version_pcw, + SyncRootIdentity: root_identity.as_ptr() as *const std::os::raw::c_void, + SyncRootIdentityLength: root_identity.len() as u32, + FileIdentity: std::ptr::null(), + FileIdentityLength: 0, + ProviderId: provider_id, + }; + + let policies = CF_SYNC_POLICIES { + StructSize: std::mem::size_of::() as u32, + Hydration: CF_HYDRATION_POLICY { + Primary: CF_HYDRATION_POLICY_PARTIAL, + Modifier: CF_HYDRATION_POLICY_MODIFIER_NONE, + }, + Population: CF_POPULATION_POLICY { + Primary: CF_POPULATION_POLICY_ALWAYS_FULL, + Modifier: CF_POPULATION_POLICY_MODIFIER_NONE, + }, + InSync: CF_INSYNC_POLICY_NONE, + HardLink: CF_HARDLINK_POLICY_NONE, + PlaceholderManagement: CF_PLACEHOLDER_MANAGEMENT_POLICY_DEFAULT, + }; + + CfRegisterSyncRoot( + PCWSTR(sync_root_path.as_ptr()), + ®istration, + &policies, + CF_REGISTER_FLAG_UPDATE, + )?; + + tracing::info!("同步根已注册: {}", root_path.display()); + Ok(()) + } + } + + /// 连接同步根,注册回调(先断开残留连接) + pub fn connect_sync_root(&mut self, root_path: &Path) -> anyhow::Result<()> { + // 先断开残留的旧连接 + if let Some(key) = self.connection_key.take() { + unsafe { + let _ = CfDisconnectSyncRoot(key); + } + tracing::debug!("已断开旧的同步根连接"); + } + + let (tx, rx) = mpsc::channel(256); + // 替换全局回调发送端 + { + let mut guard = CALLBACK_SENDER.lock().unwrap(); + *guard = Some(tx); + } + + unsafe { + let sync_root_path = to_pcwstr(root_path); + + let callbacks = [ + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_FETCH_DATA, + Callback: Some(cf_fetch_data_callback), + }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_CANCEL_FETCH_DATA, + Callback: Some(cf_cancel_fetch_callback), + }, + CF_CALLBACK_REGISTRATION { + Type: CF_CALLBACK_TYPE_NONE, + Callback: None, + }, + ]; + + let connection_key = CfConnectSyncRoot( + PCWSTR(sync_root_path.as_ptr()), + callbacks.as_ptr(), + None, + CF_CONNECT_FLAG_NONE, + )?; + + self.connection_key = Some(connection_key); + self.callback_rx = Some(rx); + + tracing::info!("同步根已连接: {}", root_path.display()); + } + + Ok(()) + } + + /// 取走回调接收端(供 sync-core 消费) + pub fn take_callback_receiver(&mut self) -> Option> { + self.callback_rx.take() + } + + /// 创建占位符文件(批量) + pub fn create_placeholders( + &self, + base_dir: &Path, + entries: &[PlaceholderEntry], + ) -> anyhow::Result { + if entries.is_empty() { + return Ok(0); + } + + unsafe { + let base_path = to_pcwstr(base_dir); + + let mut placeholders: Vec = entries + .iter() + .map(|entry| { + let relative_name_w = to_pcwstr_str(&entry.relative_path); + let file_attributes = if entry.is_dir { + FILE_ATTRIBUTE_DIRECTORY.0 + } else { + FILE_ATTRIBUTE_NORMAL.0 + }; + + CF_PLACEHOLDER_CREATE_INFO { + RelativeFileName: PCWSTR(relative_name_w.as_ptr()), + FsMetadata: CF_FS_METADATA { + BasicInfo: FILE_BASIC_INFO { + CreationTime: 0, + LastAccessTime: 0, + LastWriteTime: 0, + ChangeTime: 0, + FileAttributes: file_attributes, + }, + FileSize: entry.file_size as i64, + }, + FileIdentity: entry.file_identity.as_ptr() as *const std::os::raw::c_void, + FileIdentityLength: entry.file_identity.len() as u32, + Flags: CF_PLACEHOLDER_CREATE_FLAG_NONE, + Result: S_OK, + CreateUsn: 0, + } + }) + .collect(); + + let mut entries_processed: u32 = 0; + + CfCreatePlaceholders( + PCWSTR(base_path.as_ptr()), + &mut placeholders, + CF_CREATE_FLAG_NONE, + Some(&mut entries_processed), + )?; + + tracing::debug!("创建占位符: {} 个", entries_processed); + Ok(entries_processed) + } + } + + /// 创建单个占位符文件 + pub fn create_single_placeholder( + &self, + base_dir: &Path, + file_name: &str, + file_size: u64, + file_identity: &[u8], + ) -> anyhow::Result<()> { + let entry = PlaceholderEntry { + relative_path: file_name.to_string(), + file_size, + is_dir: false, + file_identity: file_identity.to_vec(), + }; + self.create_placeholders(base_dir, &[entry])?; + Ok(()) + } + + /// 水合文件 + pub fn hydrate_placeholder(&self, file_path: &Path) -> anyhow::Result<()> { + unsafe { + let file = std::fs::File::open(file_path)?; + let handle = HANDLE(file.as_raw_handle() as *mut _); + + CfHydratePlaceholder( + handle, + 0, + -1i64, + CF_HYDRATE_FLAG_NONE, + None, + )?; + + tracing::debug!("水合完成: {}", file_path.display()); + Ok(()) + } + } + + /// 脱水文件 + pub fn dehydrate_placeholder(&self, file_path: &Path) -> anyhow::Result<()> { + unsafe { + let file = std::fs::File::open(file_path)?; + let handle = HANDLE(file.as_raw_handle() as *mut _); + + CfDehydratePlaceholder( + handle, + 0, + -1i64, + CF_DEHYDRATE_FLAG_NONE, + None, + )?; + + tracing::debug!("脱水完成: {}", file_path.display()); + Ok(()) + } + } + + /// 断开同步根连接 + pub fn disconnect(&mut self) -> anyhow::Result<()> { + if let Some(key) = self.connection_key.take() { + unsafe { + CfDisconnectSyncRoot(key)?; + } + tracing::info!("同步根已断开"); + } + self.callback_rx = None; + Ok(()) + } + + /// 通过 CfExecute 将数据推送回 CFApi 完成水合 + /// 这是由内核层写入文件,绕过应用锁 + pub fn fulfill_fetch_data( + connection_key: i64, + transfer_key: i64, + data: &[u8], + offset: i64, + ) -> anyhow::Result<()> { + unsafe { + let op_info = CF_OPERATION_INFO { + StructSize: std::mem::size_of::() as u32, + Type: CF_OPERATION_TYPE_TRANSFER_DATA, + ConnectionKey: CF_CONNECTION_KEY(connection_key), + TransferKey: transfer_key, + CorrelationVector: std::ptr::null(), + SyncStatus: std::ptr::null(), + RequestKey: 0, + }; + + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferData: CF_OPERATION_PARAMETERS_0_6 { + Flags: CF_OPERATION_TRANSFER_DATA_FLAG_NONE, + CompletionStatus: NTSTATUS(0), // STATUS_SUCCESS + Buffer: data.as_ptr() as *const core::ffi::c_void, + Offset: offset, + Length: data.len() as i64, + }, + }, + }; + + CfExecute(&op_info, &mut op_params)?; + tracing::debug!("CfExecute TRANSFER_DATA 成功: offset={}, len={}", offset, data.len()); + Ok(()) + } + } + + /// 通过 CfExecute 报告水合失败 + pub fn reject_fetch_data( + connection_key: i64, + transfer_key: i64, + ) -> anyhow::Result<()> { + unsafe { + let op_info = CF_OPERATION_INFO { + StructSize: std::mem::size_of::() as u32, + Type: CF_OPERATION_TYPE_TRANSFER_DATA, + ConnectionKey: CF_CONNECTION_KEY(connection_key), + TransferKey: transfer_key, + CorrelationVector: std::ptr::null(), + SyncStatus: std::ptr::null(), + RequestKey: 0, + }; + + let mut op_params = CF_OPERATION_PARAMETERS { + ParamSize: std::mem::size_of::() as u32, + Anonymous: CF_OPERATION_PARAMETERS_0 { + TransferData: CF_OPERATION_PARAMETERS_0_6 { + Flags: CF_OPERATION_TRANSFER_DATA_FLAG_NONE, + CompletionStatus: NTSTATUS(0xC000_0001_u32 as i32), // STATUS_UNSUCCESSFUL + Buffer: std::ptr::null(), + Offset: 0, + Length: 0, + }, + }, + }; + + CfExecute(&op_info, &mut op_params)?; + Ok(()) + } + } +} + +/// CFApi 回调:水合请求 — 将请求发送到全局通道 +unsafe extern "system" fn cf_fetch_data_callback( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, +) { + if callback_info.is_null() { + return; + } + let info = &*callback_info; + + // 提取 FileIdentity + let file_identity = if info.FileIdentityLength > 0 && !info.FileIdentity.is_null() { + std::slice::from_raw_parts( + info.FileIdentity as *const u8, + info.FileIdentityLength as usize, + ).to_vec() + } else { + Vec::new() + }; + + // 提取请求范围 + let (required_offset, required_length) = if callback_parameters.is_null() { + (0i64, -1i64) + } else { + let params = &*callback_parameters; + let fetch = ¶ms.Anonymous.FetchData; + (fetch.RequiredFileOffset, fetch.RequiredLength) + }; + + let request = FetchDataRequest { + connection_key: info.ConnectionKey.0, + transfer_key: info.TransferKey, + file_identity, + required_offset, + required_length, + }; + + tracing::debug!( + "CFApi 水合请求: transfer_key={}, identity_len={}, offset={}, length={}", + info.TransferKey, + info.FileIdentityLength, + required_offset, + required_length, + ); + + if let Ok(guard) = CALLBACK_SENDER.lock() { + if let Some(ref sender) = *guard { + let _ = sender.blocking_send(request); + } + } +} + +/// CFApi 回调:取消水合 +unsafe extern "system" fn cf_cancel_fetch_callback( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, +) { + if callback_info.is_null() { + return; + } + tracing::debug!("CFApi 取消水合请求: transfer_key={}", (*callback_info).TransferKey); +} + +/// Path → wide null-terminated Vec +fn to_pcwstr(path: &Path) -> Vec { + let mut wide: Vec = OsStr::new(path).encode_wide().collect(); + wide.push(0); + wide +} + +/// &str → wide null-terminated Vec +fn to_pcwstr_str(s: &str) -> Vec { + let mut wide: Vec = s.encode_utf16().collect(); + wide.push(0); + wide +} diff --git a/pubspec.lock b/pubspec.lock index 3629865..6524d33 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "10.0.1" animations: dependency: "direct main" description: @@ -69,18 +69,26 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.6" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -89,30 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" + version: "2.15.0" built_collection: dependency: transitive description: @@ -193,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" code_text_field: dependency: "direct main" description: @@ -253,10 +237,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "3.1.7" dbus: dependency: transitive description: @@ -435,6 +419,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.34" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + url: "https://pub.dev" + source: hosted + version: "2.12.0" flutter_single_instance: dependency: "direct main" description: @@ -461,22 +453,22 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" glob: dependency: transitive description: @@ -549,14 +541,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" hooks: dependency: transitive description: @@ -701,14 +685,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -721,10 +697,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.11.4" leak_tracker: dependency: transitive description: @@ -1446,18 +1422,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.12" source_span: dependency: transitive description: @@ -1562,14 +1538,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" tray_manager: dependency: "direct main" description: @@ -1667,7 +1635,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" @@ -1827,7 +1795,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 3c2ac7e..b5f8903 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.1+2 +version: 1.3.2+3 environment: sdk: ^3.11.4 @@ -93,6 +93,7 @@ dependencies: image_picker: ^1.2.2 qr_flutter: ^4.1.0 webview_flutter: ^4.13.0 + xml: ^6.6.1 # 桌面端 window_manager: ^0.5.0 @@ -103,6 +104,9 @@ dependencies: crypto: ^3.0.7 desktop_drop: ^0.7.1 cross_file: ^0.3.5+2 + freezed_annotation: ^3.1.0 + flutter_rust_bridge: ^2.12.0 + uuid: ^4.5.3 dev_dependencies: flutter_test: @@ -116,8 +120,8 @@ dev_dependencies: flutter_lints: ^6.0.0 build_runner: ^2.4.7 json_serializable: ^6.7.1 - hive_generator: ^2.0.1 flutter_launcher_icons: ^0.13.1 + freezed: ^3.1.0 flutter_launcher_icons: android: "launcher_icon" diff --git a/test/widget_test.dart b/test/widget_test.dart index e69de29..ab73b3a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 2bb0ebd..1c9505f 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -53,6 +53,61 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) add_subdirectory("runner") +# media_kit_libs_windows_video downloads its native archives with CMake's +# file(DOWNLOAD). In some Windows network/proxy setups that leaves 0-byte files +# without surfacing the download failure, so prefetch the exact archives with +# PowerShell and let the plugin's own CMake verify/use them. +function(ENSURE_WINDOWS_ARCHIVE URL MD5 OUTPUT_PATH) + string(TOLOWER "${MD5}" EXPECTED_MD5) + if(EXISTS "${OUTPUT_PATH}") + file(MD5 "${OUTPUT_PATH}" CURRENT_MD5) + string(TOLOWER "${CURRENT_MD5}" CURRENT_MD5) + if(EXPECTED_MD5 STREQUAL CURRENT_MD5) + return() + endif() + file(REMOVE "${OUTPUT_PATH}") + endif() + + find_program(POWERSHELL_EXE powershell.exe) + if(NOT POWERSHELL_EXE) + find_program(POWERSHELL_EXE pwsh) + endif() + if(NOT POWERSHELL_EXE) + message(FATAL_ERROR "PowerShell is required to download ${URL}") + endif() + + get_filename_component(OUTPUT_DIR "${OUTPUT_PATH}" DIRECTORY) + file(MAKE_DIRECTORY "${OUTPUT_DIR}") + + message(STATUS "Downloading ${URL}") + execute_process( + COMMAND "${POWERSHELL_EXE}" -NoProfile -ExecutionPolicy Bypass -Command + "$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '${URL}' -OutFile '${OUTPUT_PATH}' -UseBasicParsing" + RESULT_VARIABLE DOWNLOAD_RESULT + ) + if(NOT DOWNLOAD_RESULT EQUAL 0) + message(FATAL_ERROR "Failed to download ${URL}") + endif() + + file(MD5 "${OUTPUT_PATH}" CURRENT_MD5) + string(TOLOWER "${CURRENT_MD5}" CURRENT_MD5) + if(NOT EXPECTED_MD5 STREQUAL CURRENT_MD5) + file(REMOVE "${OUTPUT_PATH}") + message(FATAL_ERROR "Downloaded ${OUTPUT_PATH} failed MD5 check") + endif() +endfunction() + +ENSURE_WINDOWS_ARCHIVE( + "https://github.com/media-kit/libmpv-win32-video-build/releases/download/2023-09-24/mpv-dev-x86_64-20230924-git-652a1dd.7z" + "a832ef24b3a6ff97cd2560b5b9d04cd8" + "${CMAKE_BINARY_DIR}/mpv-dev-x86_64-20230924-git-652a1dd.7z" +) +ENSURE_WINDOWS_ARCHIVE( + "https://github.com/alexmercerind/flutter-windows-ANGLE-OpenGL-ES/releases/download/v1.0.1/ANGLE.7z" + "e866f13e8d552348058afaafe869b1ed" + "${CMAKE_BINARY_DIR}/ANGLE.7z" +) + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -107,3 +162,61 @@ install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) + +# === Rust Sync Engine === +find_program(CARGO_CMD cargo + PATHS + "$ENV{USERPROFILE}\\.cargo\\bin" + "$ENV{HOME}\\.cargo\\bin" + "$ENV{CARGO_HOME}\\bin" + NO_DEFAULT_PATH +) +if(NOT CARGO_CMD) + find_program(CARGO_CMD cargo) +endif() + +if(NOT CARGO_CMD) + message(WARNING "cargo not found, skipping Rust sync engine build") +else() + set(SYNC_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../native") + set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_target") + + # 1. 编译时间戳文件 + set(RUST_BUILD_STAMP "${CMAKE_CURRENT_BINARY_DIR}/sync_core_build.stamp") + + # 2. 核心修复 + # 不管是 Debug 还是 Release,我们都同时通知 Cargo 去检查这两个配置。 + # 这样没有任何动态变量,参数数量是死死固定的,Cargo 100% 不会报错。 + add_custom_command( + OUTPUT "${RUST_BUILD_STAMP}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${RUST_TARGET_DIR}" + + # 无论如何,都去编译 Debug(供 flutter run 使用) + COMMAND ${CARGO_CMD} build + --manifest-path "${SYNC_CORE_DIR}/Cargo.toml" + --target-dir "${RUST_TARGET_DIR}" + --features sync-core/windows-cfapi + + # 无论如何,都去编译 Release(供 flutter build 使用) + COMMAND ${CARGO_CMD} build --release + --manifest-path "${SYNC_CORE_DIR}/Cargo.toml" + --target-dir "${RUST_TARGET_DIR}" + --features sync-core/windows-cfapi + + COMMAND ${CMAKE_COMMAND} -E touch "${RUST_BUILD_STAMP}" + COMMENT "Building Rust sync engine (Debug & Release)..." + VERBATIM + ) + + # 3. 让目标依赖时间戳 + add_custom_target(sync_core_build DEPENDS "${RUST_BUILD_STAMP}") + add_dependencies(${BINARY_NAME} sync_core_build) + + # 4. 动态选择文件夹名称表达式(这个表达式只在安装阶段求值,安全可靠) + set(CONFIG_DIR_EXPR "$,$>,release,debug>") + + # 5. 动态选择 dll 路径进行安装复制 + install(FILES "${RUST_TARGET_DIR}/${CONFIG_DIR_EXPR}/sync_core.dll" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 394917c..12055b4 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -38,3 +38,4 @@ target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) + diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index d0d3475..d048069 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.limo" "\0" + VALUE "CompanyName", "saont.net" "\0" VALUE "FileDescription", "公云存储" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "公允存储" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.limo. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 saont.net. All rights reserved." "\0" VALUE "OriginalFilename", "cloudreve4_flutter.exe" "\0" VALUE "ProductName", "公云存储" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"