3 Commits

Author SHA1 Message Date
gongyun 17673b2862 1.3.21.3.2
Android APK Release / Build Android APK (push) Successful in 55m29s
2026-05-26 16:32:18 +08:00
gongyun 546cef5ba6 Complete the readme.
Android APK Release / Build Android APK (push) Failing after 54m14s
2026-05-25 22:42:18 +08:00
gongyun 39d8361080 Fix some known issues and add some new functions
Android APK Release / Build Android APK (push) Failing after 52m53s
2026-05-25 20:03:59 +08:00
89 changed files with 24769 additions and 380 deletions
+9
View File
@@ -28,6 +28,10 @@ DESIGN_OVERVIEW.md
DESIGN.md DESIGN.md
refactory.md refactory.md
DESIGN-copy.md DESIGN-copy.md
todo.md
feat-2315.md
*.txt
*.json
*.jsonl *.jsonl
example.dart example.dart
install.sh install.sh
@@ -58,6 +62,11 @@ lib/firebase_options.dart
*secret*.json *secret*.json
*secrets*.json *secrets*.json
*.local.json *.local.json
native/target
native/logs
sync_refactory.md
tools/*
*.diff
# The .vscode folder contains launch configuration and tasks you configure in # 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 # VS Code which you may wish to be included in version control, so this line
+198 -1
View File
@@ -1,3 +1,200 @@
# app # app
🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验 [![Flutter](https://img.shields.io/badge/Flutter-3.41.6-blue?logo=flutter)](https://flutter.dev)
[![Dart](https://img.shields.io/badge/Dart-3.11.4-blue?logo=dart)](https://dart.dev)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
> 🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验
---
## 📖 项目简介
功能丰富的云存储客户端,支持文件上传、下载、管理和分享。针对 公云存储 API 进行了基本完整适配和优化。
### ✨ 特性亮点
- 🎯 现支持的功能基本完整适配 公云存储 API
- 📱 跨平台支持(Android / Linux / Windows / Web
- 🎨 现代化 Material Design 3 界面
- ⚡ 支持断点续传和下载进度监听
- 🔐 安全的 Token 认证机制
---
## 🛠️ 技术栈
| 组件 | 版本 |
|------|------|
| Flutter | 3.41.6 |
| Dart | 3.11.4 |
| 后端 API | 公云存储 |
**开发环境详情:**
```
Debia
Flutter 3.41.6 • channel stable
Framework • revision db50e20168 • 2026-03-25
Dart 3.11.4 • DevTools 2.54.2
```
**构建环境详情:**
```
Android: compileAPI: 36, targetAPI: 36, miniAPI: 34
Windows: 11
Linux: Debian 12
```
---
## 📋 功能状态
### ✅ 基础功能
| 功能模块 | 状态 | 说明 |
|--------|------|----------------------------------------|
| 用户登录 | ✅ | 自定义服务器, Token 认证、持久化 |
| 文件列表 | ✅ | 列表/网格双视图 |
| 刷新列表 | ✅ | 增量更新 |
| 全屏手势 | ✅ | 右侧左滑返回上级目录, 根目录提示退出 |
| 文件下载 | ✅ | 原生/浏览器双实现、进度监听、断点续传、后台下载 |
| 文件上传 | ✅ | 进度展示、分片上传<服务端需要开启> Windows/Linux支持拖拽上传 |
| 删除文件 | ✅ | 删除文件 |
| 重命名 | ✅ | 重命名文件 |
| 移动复制 | ✅ | 移动/复制文件, 添加文件夹选择器对话框 |
| 我的分享 | ✅ | 完整的分享功能, 包括创建,删除,管理列表,编辑等 |
| 找回密码 | ✅ | 使用邮箱找回密码 (依赖控制台STMP可用性) |
| 用户注册 | ✅ | 使用邮箱注册新用户 (依赖控制台允许注册新用户) |
| 回收站 | ✅ | 文件恢复/彻底删除 |
| WebDav | ✅ | 增删改查(硬编码查50条) |
| 文件搜索 | ✅ | 全局搜索功能, 点击跳转到对应目录 |
| 设置页面 | ✅ | 增加多个实用的设置项 |
| 离线下载 | ✅ | 离线下载(依赖服务端aria2可用) |
| 缩略图 | ✅ | 网格布局缩略图懒加载支持 |
> 文件下载
> 原生/浏览器双实现原因:
>
> > ~~选型使用了 `flutter_downloader` 来实现Android后台下载, 避免切换后台下载异常, 但这玩意儿不支持跨平台, 所以PC端就实现了获取文件url地址在浏览器打开进行下载; 正常应该选用 `background_downloader`~~
>
> 文件上传
>
> > 看后端接口文档, 必须要按分片顺序上传, 看着是不支持多分片并发上传, 差点意思, 效率不高.
------
### ✅ 设置页面
| 功能模块 | 状态 | 说明 |
|------|----|-------------------------------------|
| 个人资料 | ✅ | 修改昵称和头像 |
| 安全设置 | ✅ | 修改密码/2FA等 |
| 快捷入口 | ✅ | 概览页快捷入口设置, 默认4个, 支持新增修改和调整顺序 |
| 文件偏好 | ✅ | 历史版本开关, 视图同步, 个人主页分享链接可见性 |
| 应用设置 | ✅ | 深色模式/主题/语言/gravatar镜像/下载设置/缓存/日志管理等 |
| 关于 | ✅ | APP信息 |
-----
### ✅ 预览模块
| 功能模块 | 状态 | 说明 |
|----------|------|------|
| 图片预览 | ✅ | 全平台支持, win/linux 支持CTRL+鼠标滚轮缩放,双击恢复,平滑动画 |
| PDF预览 | ✅ | 全平台支持, 支持缩放, 选中文字复制等 |
| 音频预览 | ✅ | 全平台支持流式播放, 算好看的播放器UI, 进度条, 暂停, 快进/退10秒 |
| 视频预览 | ✅ | 全平台支持流式播放, 暂停, 调整音量, 全屏, 增加倍速支持 |
| 文本预览 | ✅ | 全平台支持, 189中语言代码高亮, SourceCodePro等宽字体, 一键复制 |
| MD预览 | ✅ | 全平台支持, 类github风格, TOC, 暗色模式支持.(dark缺陷) |
> 音视频预览库底层是 mpv 提供编解码能力, 理论上 mpv 支持的格式均支持, 具体没有实测;
>
> (待改进)视频预览进度条内嵌: MaterialVideoControlsTheme -> MaterialVideoControls
>
> 文本预览现在是一次性渲染, 大文件会有性能问题, 如果借用listview来优化, 会丢失代码高亮, 暂时保持现阶段的情况, 另外应该也没啥大文本文件预览的场景
### 🚧 开发中
| 功能模块 | 进度 | 说明 |
|---------------|------|-----------------------------|
| ~~文件预览~~ | ✅ | 图片/文档/视频等预览(核心功能基本完成) |
| ~~设置页面~~ | ✅ | 用户信息, 2FA等 |
| ~~桌面端托盘~~ | ✅ | 桌面端托盘 |
| ~~桌面端原生下载~~ | ✅ | 统一为 `background_downloader` |
| ~~我的页面~~ | ✅ | 我的页面 |
| ~~批量C&M~~ | ✅ | 批量移动/复制 |
| ~~桌面端支持拖拽上传~~ | ✅ | windows/linux 支持拖拽上传到当前文件夹 |
### 📝 待优化
- [x] SnackBar 样式美化 (`oktoast`)
- [x] 错误/提示优化 (重构所有SnackBar为okToast)
- [x] 重构所有开发过程中的`debugPrint``logger`
- [x] Windows/Linux平台使用`background_downloader`替代`flutter_downloader`下载, 增加下载速度显示
- [x] ListView 似乎还是完整重绘, 上传插值似乎也还是在完整重绘
### 🚧 待重构
| 功能模块 | 进度 | 说明 |
|-------------------------|----|---------------------------------------------------------------------------|
| UI | ✅ | windows/linux/Android phone&pad 完整ui重构 |
| `background_downloader` | ✅ | flutter_downloader -> background_downloader 全平台统一下载管理, 支持后台, 断点续传, 自动恢复等 |
| 搜索 | ✅ | 移除旧搜索, 实现新版本支持实时搜索, 搜索历史, 搜索防抖; 优化搜索结果点击跳转 |
| 拖拽上传 | ✅ | Windows/Linux支持拖拽上传 |
---
## 🚀 快速开始
### 环境要求
- Flutter SDK >= 3.41.6
- Dart SDK >= 3.11.4
- 公云存储 后端服务
### 安装依赖
```bash
flutter pub get
```
### 运行项目
```bash
flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,自行解决网络问题
```
### 构建发布
```bash
# Android
flutter build apk --release
# Linux
flutter build -d linux --release
# windows
flutter build -d windows --release
```
---
## 📬 联系方式
- 📧 问题反馈:提交 Issue
- 💬 讨论交流:无
---
## ⚖️ 开源协议 (License)
本项目采用 **AGPL-3.0 (GNU Affero General Public License v3.0)** 协议开源。
### 核心约束:
1. **传染性**:如果你修改了本项目代码并重新发布,你的项目也必须以 AGPL-3.0 协议开源。
2. **云端公开声明**:如果你在服务器/云真机等上运行本项目并向公众提供网络服务(网盘服务),你必须向用户公开你所使用的源代码(包括任何修改)。
3. **禁止闭源商业化**:未经授权,禁止将本项目代码闭源后作为商业产品销售。
详情请参阅项目根目录下的 [LICENSE](./LICENSE) 文件。
---
+8
View File
@@ -27,4 +27,12 @@ class StorageKeys {
// 搜索历史 // 搜索历史
static const String searchHistory = 'search_history'; 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';
} }
+27
View File
@@ -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';
}
+29 -1
View File
@@ -10,6 +10,10 @@ class AppLogger {
static Logger? _logger; static Logger? _logger;
static File? _logFile; static File? _logFile;
/// 当前日志级别(默认 info,debug 模式下也是 info 避免刷屏)
static Level _level = Level.info;
static Level get level => _level;
/// 初始化日志,必须在 main 中 await /// 初始化日志,必须在 main 中 await
static Future<void> init() async { static Future<void> init() async {
if (_logger != null) return; if (_logger != null) return;
@@ -22,6 +26,10 @@ class AppLogger {
} }
_logFile = File(p.join(logDir.path, 'log.txt')); _logFile = File(p.join(logDir.path, 'log.txt'));
_createLogger();
}
static void _createLogger() {
// 2. 配置多路输出:同时输出到控制台和文件 // 2. 配置多路输出:同时输出到控制台和文件
_logger = Logger( _logger = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
@@ -38,10 +46,16 @@ class AppLogger {
file: _logFile!, file: _logFile!,
), ),
]), ]),
filter: ProductionFilter(), filter: _LevelFilter(_level),
); );
} }
/// 运行时切换日志级别
static void setLevel(Level level) {
_level = level;
_createLogger();
}
// 使用 getter 确保 logger 已初始化,防止空指针 // 使用 getter 确保 logger 已初始化,防止空指针
static Logger get _instance { static Logger get _instance {
_logger ??= Logger( _logger ??= Logger(
@@ -67,6 +81,9 @@ class AppLogger {
/// Error 级别日志 /// Error 级别日志
static void e(String message) => _instance.e(message); static void e(String message) => _instance.e(message);
/// Trace 级别日志(高频轮询/查询使用,仅 trace 级别可见)
static void t(String message) => _instance.t(message);
/// Debug 级别日志(支持格式化) /// Debug 级别日志(支持格式化)
static void df(String message, List<Object> args) => _instance.d(message, error: args); static void df(String message, List<Object> 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 自带版本不支持追加 /// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加
class CustomFileOutput extends LogOutput { class CustomFileOutput extends LogOutput {
final File file; final File file;
+92 -32
View File
@@ -1,90 +1,151 @@
/// 文件工具类
class FileUtils { class FileUtils {
/// 将路径转换为 Cloudreve URI 格式 static String safeDecodePathSegment(String value, {int maxPasses = 5}) {
/// "/" → "cloudreve://my", "/subfolder" → "cloudreve://my/subfolder" 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) { static String toCloudreveUri(String path) {
if (path.startsWith('cloudreve://')) return path; if (path.startsWith('cloudreve://')) return path;
if (path == '/' || path.isEmpty) return 'cloudreve://my'; if (path == '/' || path.isEmpty) return 'cloudreve://my';
final cleanPath = path.startsWith('/') ? path.substring(1) : path; final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return 'cloudreve://my/$cleanPath'; return 'cloudreve://my/$cleanPath';
} }
/// 获取文件扩展名
static String toCloudreveUriWithQuery(
String path,
Map<String, String> queryParameters,
) {
final uri = Uri.parse(toCloudreveUri(path));
return uri.replace(queryParameters: queryParameters).toString();
}
static String getFileExtension(String fileName) { static String getFileExtension(String fileName) {
final dotIndex = fileName.lastIndexOf('.'); final dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) return ''; if (dotIndex == -1) return '';
return fileName.substring(dotIndex + 1).toLowerCase(); return fileName.substring(dotIndex + 1).toLowerCase();
} }
/// 判断是否为图片文件
static bool isImageFile(String fileName) { static bool isImageFile(String fileName) {
const imageExtensions = [ const imageExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic' 'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
'heic',
]; ];
return imageExtensions.contains(getFileExtension(fileName)); return imageExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为视频文件
static bool isVideoFile(String fileName) { static bool isVideoFile(String fileName) {
const videoExtensions = [ const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'];
'mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'
];
return videoExtensions.contains(getFileExtension(fileName)); return videoExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为音频文件
static bool isAudioFile(String fileName) { static bool isAudioFile(String fileName) {
const audioExtensions = [ const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'
];
return audioExtensions.contains(getFileExtension(fileName)); return audioExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为PDF文件
static bool isPdfFile(String fileName) { static bool isPdfFile(String fileName) {
return getFileExtension(fileName) == 'pdf'; return getFileExtension(fileName) == 'pdf';
} }
/// 判断是否为文本文件
static bool isTextFile(String fileName) { static bool isTextFile(String fileName) {
const textExtensions = [ const textExtensions = [
'txt', 'md', 'json', 'xml', 'yaml', 'yml', 'ini', 'conf' 'txt',
'md',
'json',
'xml',
'yaml',
'yml',
'ini',
'conf',
]; ];
return textExtensions.contains(getFileExtension(fileName)); return textExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为代码文件
static bool isCodeFile(String fileName) { static bool isCodeFile(String fileName) {
const codeExtensions = [ const codeExtensions = [
'js', 'ts', 'tsx', 'jsx', 'dart', 'java', 'py', 'c', 'cpp', 'js',
'h', 'hpp', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt', 'ts',
'html', 'css', 'scss', 'less', 'sql', 'sh', 'bat' 'tsx',
'jsx',
'dart',
'java',
'py',
'c',
'cpp',
'h',
'hpp',
'cs',
'php',
'rb',
'go',
'rs',
'swift',
'kt',
'html',
'css',
'scss',
'less',
'sql',
'sh',
'bat',
]; ];
return codeExtensions.contains(getFileExtension(fileName)); return codeExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为压缩文件
static bool isArchiveFile(String fileName) { static bool isArchiveFile(String fileName) {
const archiveExtensions = [ const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
'zip', 'rar', '7z', 'tar', 'gz', 'bz2'
];
return archiveExtensions.contains(getFileExtension(fileName)); return archiveExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为文档文件
static bool isDocumentFile(String fileName) { static bool isDocumentFile(String fileName) {
const docExtensions = [ const docExtensions = [
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp' 'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'odt',
'ods',
'odp',
]; ];
return docExtensions.contains(getFileExtension(fileName)); return docExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否可预览
static bool isPreviewable(String fileName) { static bool isPreviewable(String fileName) {
return isImageFile(fileName) || isVideoFile(fileName) || return isImageFile(fileName) ||
isPdfFile(fileName) || isTextFile(fileName) || isCodeFile(fileName); isVideoFile(fileName) ||
isPdfFile(fileName) ||
isTextFile(fileName) ||
isCodeFile(fileName);
} }
/// 获取MIME类型
static String getMimeType(String fileName) { static String getMimeType(String fileName) {
final ext = getFileExtension(fileName); final ext = getFileExtension(fileName);
final mimeTypes = { final mimeTypes = {
@@ -112,7 +173,6 @@ class FileUtils {
return mimeTypes[ext] ?? 'application/octet-stream'; return mimeTypes[ext] ?? 'application/octet-stream';
} }
/// 获取文件图标
static String getFileIcon(String fileName) { static String getFileIcon(String fileName) {
if (isImageFile(fileName)) return 'assets/icons/image.svg'; if (isImageFile(fileName)) return 'assets/icons/image.svg';
if (isVideoFile(fileName)) return 'assets/icons/video.svg'; if (isVideoFile(fileName)) return 'assets/icons/video.svg';
+19
View File
@@ -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,
});
}
+114
View File
@@ -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<String> 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<String>? 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,
);
}
}
+92
View File
@@ -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(),
);
}
}
+48
View File
@@ -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,
);
}
}
+96
View File
@@ -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;
}
}
+36 -5
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; 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:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -11,6 +12,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:oktoast/oktoast.dart'; import 'package:oktoast/oktoast.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'config/app_config.dart'; import 'config/app_config.dart';
import 'core/constants/storage_keys.dart';
import 'presentation/providers/auth_provider.dart'; import 'presentation/providers/auth_provider.dart';
import 'presentation/providers/file_manager_provider.dart'; import 'presentation/providers/file_manager_provider.dart';
import 'presentation/providers/navigation_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/user_setting_provider.dart';
import 'presentation/providers/admin_provider.dart'; import 'presentation/providers/admin_provider.dart';
import 'presentation/providers/quick_access_provider.dart'; import 'presentation/providers/quick_access_provider.dart';
import 'presentation/providers/sync_provider.dart';
import 'presentation/providers/theme_provider.dart'; import 'presentation/providers/theme_provider.dart';
import 'services/upload_service.dart'; import 'services/upload_service.dart';
import 'services/api_service.dart'; import 'services/api_service.dart';
import 'services/storage_service.dart';
import 'services/server_service.dart'; import 'services/server_service.dart';
import 'services/cache_manager_service.dart'; import 'services/cache_manager_service.dart';
import 'services/avatar_cache_service.dart'; import 'services/avatar_cache_service.dart';
@@ -29,12 +33,42 @@ import 'core/utils/video_fullscreen.dart';
import 'services/desktop_service.dart'; import 'services/desktop_service.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
import 'presentation/widgets/toast_helper.dart'; import 'presentation/widgets/toast_helper.dart';
import 'presentation/widgets/update_prompt.dart';
import 'src/rust/frb_generated.dart' show RustSyncApi;
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>(); final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); 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 上删除缓存文件时的文件占用异常 // 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常
// 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略 // 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略
FlutterError.onError = (details) { FlutterError.onError = (details) {
@@ -51,10 +85,6 @@ void main() async {
return false; return false;
}; };
// 初始化日志
await AppLogger.init();
AppLogger.i("应用启动,日志系统就绪");
// 桌面端初始化窗口管理和系统托盘 // 桌面端初始化窗口管理和系统托盘
if (Platform.isWindows || Platform.isLinux) { if (Platform.isWindows || Platform.isLinux) {
// 实例化 FlutterSingleInstance 获取单实例句柄 // 实例化 FlutterSingleInstance 获取单实例句柄
@@ -156,6 +186,7 @@ class CloudreveApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => UserSettingProvider()), ChangeNotifierProvider(create: (_) => UserSettingProvider()),
ChangeNotifierProvider(create: (_) => AdminProvider()), ChangeNotifierProvider(create: (_) => AdminProvider()),
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()), ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
ChangeNotifierProvider(create: (_) => SyncProvider()),
], ],
child: const AppView(), child: const AppView(),
); );
@@ -215,7 +246,7 @@ class AppView extends StatelessWidget {
currentWidget = FilterQualityWidget(child: currentWidget); currentWidget = FilterQualityWidget(child: currentWidget);
} }
// 添加全局错误处理 // 添加全局错误处理
return ErrorHandler(child: currentWidget); return UpdatePrompt(child: ErrorHandler(child: currentWidget));
}, },
), ),
); );
+237 -48
View File
@@ -56,7 +56,10 @@ class _FilesPageState extends State<FilesPage> {
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) { if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) { if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid); fileManager.setViewType(FileViewType.grid);
@@ -67,7 +70,10 @@ class _FilesPageState extends State<FilesPage> {
fileManager.loadFiles(); fileManager.loadFiles();
_isFirstLoad = false; _isFirstLoad = false;
} }
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false); final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
downloadManager.initialize(); downloadManager.initialize();
} }
}); });
@@ -75,8 +81,13 @@ class _FilesPageState extends State<FilesPage> {
// 上传完成 → 自动刷新当前目录文件列表 // 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) { UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return; if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath); context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
if (targetPath == normalizedCurrent) { if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/') final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName' ? '$targetPath$fileName'
@@ -168,8 +179,7 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.currentPath == '/') { if (fileManager.currentPath == '/') {
return const Text('文件'); return const Text('文件');
} }
final segments = fileManager.currentPath.split('/').where((s) => s.isNotEmpty).toList(); return _buildDesktopBreadcrumb(context, fileManager);
return Text(segments.isNotEmpty ? segments.last : '文件');
} }
return _buildMobileBreadcrumb(context, fileManager); return _buildMobileBreadcrumb(context, fileManager);
}, },
@@ -178,7 +188,73 @@ class _FilesPageState extends State<FilesPage> {
); );
} }
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) { 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 theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/'); final pathParts = fileManager.currentPath.split('/');
@@ -194,22 +270,30 @@ class _FilesPageState extends State<FilesPage> {
label: '文件', label: '文件',
icon: LucideIcons.home, icon: LucideIcons.home,
color: colorScheme.primary, color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null, onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
), ),
for (int i = 0; i < pathParts.length; i++) ...[ for (int i = 0; i < pathParts.length; i++) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)), child: Icon(
LucideIcons.chevronRight,
size: 14,
color: theme.hintColor.withValues(alpha: 0.5),
),
), ),
_buildBreadcrumbChip( _buildBreadcrumbChip(
context, context,
label: pathParts[i], label: _decodePathSegment(pathParts[i]),
icon: null, icon: null,
color: colorScheme.primary, color: colorScheme.primary,
isLast: i == pathParts.length - 1, isLast: i == pathParts.length - 1,
onTap: () { onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}'; final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath); if (targetPath != fileManager.currentPath) {
fileManager.enterFolder(targetPath);
}
}, },
), ),
], ],
@@ -233,7 +317,9 @@ class _FilesPageState extends State<FilesPage> {
height: 28, height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06), color: isLast
? color.withValues(alpha: 0.15)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: Row( child: Row(
@@ -267,7 +353,9 @@ class _FilesPageState extends State<FilesPage> {
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, child) { builder: (context, fileManager, child) {
return IconButton( return IconButton(
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh), icon: Icon(
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
),
onPressed: () => fileManager.refreshFiles(), onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新', tooltip: '刷新',
); );
@@ -287,14 +375,19 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list, : FileViewType.list,
); );
}, },
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图', tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
); );
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
onPressed: () { onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
FileOperationDialogs.showCreateDialog(context, fileManager); FileOperationDialogs.showCreateDialog(context, fileManager);
}, },
tooltip: '新建', tooltip: '新建',
@@ -306,7 +399,8 @@ class _FilesPageState extends State<FilesPage> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download), icon: const Icon(Icons.cloud_download),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2), onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载', tooltip: '下载',
), ),
]; ];
@@ -328,7 +422,9 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list, : FileViewType.list,
); );
}, },
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图', tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
); );
}, },
), ),
@@ -380,8 +476,16 @@ class _FilesPageState extends State<FilesPage> {
isDark: isDark, isDark: isDark,
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager)); context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
}, },
), ),
_buildFabSubItem( _buildFabSubItem(
@@ -391,7 +495,10 @@ class _FilesPageState extends State<FilesPage> {
label: '离线下载', label: '离线下载',
isDark: isDark, isDark: isDark,
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)), onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
), ),
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, _) { builder: (context, fileManager, _) {
@@ -477,7 +584,10 @@ class _FilesPageState extends State<FilesPage> {
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color: isDark
? Colors.white.withValues(alpha: 0.12) ? Colors.white.withValues(alpha: 0.12)
@@ -558,7 +668,8 @@ class _FilesPageState extends State<FilesPage> {
final isDesktop = MediaQuery.of(context).size.width >= 1000; final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context); final child = _buildFileList(context);
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) { if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child; return child;
} }
@@ -582,13 +693,19 @@ class _FilesPageState extends State<FilesPage> {
strokeAlign: BorderSide.strokeAlignOutside, strokeAlign: BorderSide.strokeAlignOutside,
), ),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85), color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.85),
), ),
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary), Icon(
LucideIcons.upload,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'释放文件以上传到当前目录', '释放文件以上传到当前目录',
@@ -618,8 +735,14 @@ class _FilesPageState extends State<FilesPage> {
} }
if (files.isEmpty) return; if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false); final uploadManager = Provider.of<UploadManagerProvider>(
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog(); uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath); uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列'); ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
@@ -649,12 +772,19 @@ class _FilesPageState extends State<FilesPage> {
); );
} }
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) { Widget _buildErrorView(
BuildContext context,
FileManagerProvider fileManager,
) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error), Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
fileManager.errorMessage!, fileManager.errorMessage!,
@@ -705,7 +835,9 @@ class _FilesPageState extends State<FilesPage> {
itemCount: fileManager.files.length, itemCount: fileManager.files.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final file = fileManager.files[index]; final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path); final isSelected = fileManager.selectedFiles.contains(
file.path,
);
return FileListItem( return FileListItem(
key: ValueKey('file_${file.id}'), key: ValueKey('file_${file.id}'),
@@ -727,13 +859,37 @@ class _FilesPageState extends State<FilesPage> {
} }
}, },
onSelect: () => fileManager.toggleSelection(file.path), onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, onDownload: !file.isFolder
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, ? () => _downloadFile(context, fileManager, file)
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), : null,
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), onOpenInBrowser: !file.isFolder
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), ? () => _openInBrowser(context, file)
onShare: () => FileOperationDialogs.showShareDialog(context, file), : null,
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () =>
FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file), onInfo: () => _showFileInfo(file),
); );
}, },
@@ -762,7 +918,8 @@ class _FilesPageState extends State<FilesPage> {
crossAxisCount = 5; crossAxisCount = 5;
} }
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount; final itemWidth =
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160; final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection; final showCheckbox = fileManager.hasSelection;
@@ -802,13 +959,36 @@ class _FilesPageState extends State<FilesPage> {
} }
}, },
onSelect: () => fileManager.toggleSelection(file.path), onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, onDownload: !file.isFolder
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, ? () => _downloadFile(context, fileManager, file)
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), : null,
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), onOpenInBrowser: !file.isFolder
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), ? () => _openInBrowser(context, file)
onShare: () => FileOperationDialogs.showShareDialog(context, file), : null,
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file), onInfo: () => _showFileInfo(file),
); );
}, },
@@ -876,11 +1056,17 @@ class _FilesPageState extends State<FilesPage> {
} else if (FileTypeUtils.isAudio(file.name)) { } else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file); Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) { } else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file); Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) { } else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file); Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else { } else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}'); ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
} }
} }
@@ -889,7 +1075,10 @@ class _FilesPageState extends State<FilesPage> {
FileManagerProvider fileManager, FileManagerProvider fileManager,
FileModel file, FileModel file,
) async { ) async {
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false); final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final task = await downloadManager.addDownloadTask( final task = await downloadManager.addDownloadTask(
fileName: file.name, fileName: file.name,
fileUri: file.relativePath, fileUri: file.relativePath,
@@ -71,6 +71,7 @@ class QuickAccessGrid extends StatelessWidget {
child: _QuickAccessButton( child: _QuickAccessButton(
item: items[index], item: items[index],
onTap: () => _onTap(context, items[index]), onTap: () => _onTap(context, items[index]),
fillHeight: fillHeight,
), ),
), ),
); );
@@ -150,8 +151,13 @@ class QuickAccessGrid extends StatelessWidget {
class _QuickAccessButton extends StatelessWidget { class _QuickAccessButton extends StatelessWidget {
final QuickAccessConfig item; final QuickAccessConfig item;
final VoidCallback onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -179,6 +185,8 @@ class _QuickAccessButton extends StatelessWidget {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: SizedBox(
height: fillHeight ? double.infinity : null,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -201,6 +209,7 @@ class _QuickAccessButton extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }
@@ -1,28 +1,67 @@
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/router/app_router.dart'; import 'package:cloudreve4_flutter/router/app_router.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class _QuickFunction { class _QuickFunction {
final IconData icon; final IconData icon;
final String label; final String label;
final String route; final String? route;
final void Function(BuildContext context)? onTap;
const _QuickFunction({ const _QuickFunction({
required this.icon, required this.icon,
required this.label, required this.label,
required this.route, this.route,
this.onTap,
}); });
} }
class QuickFunctionsSection extends StatelessWidget { class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key}); const QuickFunctionsSection({super.key});
static const _functions = [ static final _functions = [
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share), _QuickFunction(
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav), icon: LucideIcons.share2,
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload), label: '我的分享',
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin), route: RouteNames.share,
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings), ),
_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<NavigationProvider>().setIndex(3);
},
),
_QuickFunction(
icon: LucideIcons.settings,
label: '设置',
route: RouteNames.settings,
),
]; ];
static const double _spacing = 12; static const double _spacing = 12;
@@ -43,9 +82,12 @@ class QuickFunctionsSection extends StatelessWidget {
children: [ children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary), Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('快捷功能', Text(
style: theme.textTheme.titleMedium '快捷功能',
?.copyWith(fontWeight: FontWeight.w600)), style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
], ],
), ),
), ),
@@ -59,7 +101,8 @@ class QuickFunctionsSection extends StatelessWidget {
if (itemWidth < _minItemWidth) break; if (itemWidth < _minItemWidth) break;
perRow = next; perRow = next;
} }
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow; final itemWidth =
(availableWidth - _spacing * (perRow - 1)) / perRow;
return Wrap( return Wrap(
spacing: _spacing, spacing: _spacing,
@@ -70,7 +113,13 @@ class QuickFunctionsSection extends StatelessWidget {
child: _QuickFunctionCard( child: _QuickFunctionCard(
icon: fn.icon, icon: fn.icon,
label: fn.label, 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(), }).toList(),
@@ -106,9 +155,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Card( return Card(
color: _hovered color: _hovered ? colorScheme.surfaceContainerHighest : null,
? colorScheme.surfaceContainerHighest
: null,
child: InkWell( child: InkWell(
onTap: widget.onTap, onTap: widget.onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
import 'package:logger/logger.dart' show Level;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../core/constants/storage_keys.dart'; import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/app_logger.dart'; import '../../../core/utils/app_logger.dart';
@@ -38,6 +39,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
String _logFilePath = ''; String _logFilePath = '';
int? _logFileSize; int? _logFileSize;
String _cacheDirPath = ''; String _cacheDirPath = '';
Level _logLevel = Level.info;
@override @override
void initState() { void initState() {
@@ -46,6 +48,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
_loadWifiOnlySetting(); _loadWifiOnlySetting();
_loadGravatarMirrorSetting(); _loadGravatarMirrorSetting();
_loadLogInfo(); _loadLogInfo();
_loadLogLevel();
} }
Future<void> _loadCacheSettings() async { Future<void> _loadCacheSettings() async {
@@ -127,6 +130,15 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
} }
} }
Future<void> _loadLogLevel() async {
final saved = await StorageService.instance.getString(StorageKeys.logLevel);
if (saved != null && mounted) {
setState(() {
_logLevel = _parseLogLevel(saved);
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -295,6 +307,13 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
_buildSection( _buildSection(
title: '日志管理', title: '日志管理',
children: [ children: [
ListTile(
leading: const Icon(Icons.tune),
title: const Text('日志级别'),
subtitle: Text(_logLevelLabel(_logLevel)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pickLogLevel(),
),
ListTile( ListTile(
title: const Text('日志文件路径'), title: const Text('日志文件路径'),
subtitle: Text( subtitle: Text(
@@ -883,4 +902,70 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
if (mounted) ToastHelper.success('日志已清空'); 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<void> _pickLogLevel() async {
final levels = [
(Level.error, 'Error — 仅错误'),
(Level.warning, 'Warning — 错误 + 警告'),
(Level.info, 'Info — 常规信息'),
(Level.debug, 'Debug — 调试信息(含FFI交互)'),
(Level.trace, 'Trace — 全量追踪'),
];
final result = await showDialog<Level>(
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)}');
}
}
} }
@@ -16,6 +16,7 @@ import 'file_preferences_page.dart';
import 'app_settings_page.dart'; import 'app_settings_page.dart';
import 'credit_history_page.dart'; import 'credit_history_page.dart';
import 'quick_access_settings_page.dart'; import 'quick_access_settings_page.dart';
import '../../../router/app_router.dart';
/// 设置主页 /// 设置主页
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@@ -105,6 +106,12 @@ class _SettingsPageState extends State<SettingsPage> {
_buildSection( _buildSection(
title: '偏好', title: '偏好',
children: [ children: [
_SettingsTile(
icon: Icons.sync_outlined,
title: '文件同步',
subtitle: '本地与云端文件自动同步',
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
),
_SettingsTile( _SettingsTile(
icon: Icons.apps_outlined, icon: Icons.apps_outlined,
title: '快捷入口', title: '快捷入口',
+211 -42
View File
@@ -1,21 +1,44 @@
import 'package:animations/animations.dart';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.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/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/file_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/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/providers/upload_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.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/glassmorphism_container.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart'; import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../router/app_router.dart'; import '../../../router/app_router.dart';
import '../files/files_page.dart'; import '../files/files_page.dart';
import '../overview/overview_page.dart'; import '../overview/overview_page.dart';
import '../sync/sync_page.dart';
import '../tasks/tasks_page.dart'; import '../tasks/tasks_page.dart';
import '../profile/profile_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 { class AppShell extends StatefulWidget {
const AppShell({super.key}); const AppShell({super.key});
@@ -23,7 +46,52 @@ class AppShell extends StatefulWidget {
State<AppShell> createState() => _AppShellState(); State<AppShell> createState() => _AppShellState();
} }
class _AppShellState extends State<AppShell> with GestureHandlerMixin { class _AppShellState extends State<AppShell>
with GestureHandlerMixin, TickerProviderStateMixin {
final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController;
bool get _showSyncTab =>
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
List<Widget> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -33,12 +101,19 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
if (!didPop) { if (!didPop) {
final navProvider = Provider.of<NavigationProvider>(context, listen: false); final navProvider = Provider.of<NavigationProvider>(
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') { if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') {
await fileManager.goBack(); await fileManager.goBack();
} else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) { } else if (navProvider.currentIndex != 0 &&
navProvider.currentIndex != 1) {
navProvider.setIndex(0); navProvider.setIndex(0);
} else { } else {
await checkExitApp(context); await checkExitApp(context);
@@ -57,42 +132,74 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
} }
Widget _buildPageContent(BuildContext context, int currentIndex) { Widget _buildPageContent(BuildContext context, int currentIndex) {
const pages = [ final pages = _pages;
OverviewPage(), final visibleIndex = _clampedIndex(currentIndex);
FilesPage(), _visitedPageIndexes.add(visibleIndex);
TasksPage(),
ProfilePage(),
];
return PageTransitionSwitcher( return RepaintBoundary(
duration: const Duration(milliseconds: 300), child: IndexedStack(
transitionBuilder: (child, animation, secondaryAnimation) { index: visibleIndex,
return SharedAxisTransition( children: List.generate(pages.length, (index) {
animation: animation, if (!_visitedPageIndexes.contains(index)) {
secondaryAnimation: secondaryAnimation, return const SizedBox.shrink();
transitionType: SharedAxisTransitionType.horizontal, }
child: child, return _ShellPageSlot(child: pages[index]);
); }),
},
child: KeyedSubtree(
key: ValueKey(currentIndex),
child: pages[currentIndex],
), ),
); );
} }
Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) { Widget _buildSyncIcon({required bool isSelected, required double size}) {
return Consumer<SyncProvider>(
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( return Scaffold(
body: _buildPageContent(context, navProvider.currentIndex), body: _buildPageContent(context, navProvider.currentIndex),
bottomNavigationBar: GlassmorphismContainer( bottomNavigationBar: GlassmorphismContainer(
borderRadius: 0, borderRadius: 0,
child: Consumer2<UploadManagerProvider, DownloadManagerProvider>( child: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) { builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; final activeCount =
uploadManager.activeTasks.length +
downloadManager.downloadingCount;
return NavigationBar( return NavigationBar(
height: 64, height: 64,
selectedIndex: navProvider.currentIndex, selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i), onDestinationSelected: (i) => navProvider.setIndex(i),
destinations: [ destinations: [
const NavigationDestination( const NavigationDestination(
@@ -118,6 +225,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
), ),
label: '任务', label: '任务',
), ),
if (_showSyncTab)
NavigationDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: true, size: 24),
);
},
),
label: '同步',
),
const NavigationDestination( const NavigationDestination(
icon: Icon(LucideIcons.user), icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700), selectedIcon: Icon(LucideIcons.user, weight: 700),
@@ -131,7 +262,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
); );
} }
Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) { Widget _buildDesktopLayout(
BuildContext context,
NavigationProvider navProvider,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final authProvider = context.watch<AuthProvider>(); final authProvider = context.watch<AuthProvider>();
final user = authProvider.user; final user = authProvider.user;
@@ -141,16 +275,16 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
body: Row( body: Row(
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: navProvider.currentIndex, selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i), onDestinationSelected: (i) => navProvider.setIndex(i),
leading: Padding( leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: GestureDetector( child: GestureDetector(
onTap: () => navProvider.setIndex(3), onTap: () => navProvider.setIndex(_pages.length - 1),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: navProvider.currentIndex == 3 border: navProvider.currentIndex == _pages.length - 1
? Border.all( ? Border.all(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
width: 2.5, width: 2.5,
@@ -180,7 +314,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
NavigationRailDestination( NavigationRailDestination(
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>( icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) { builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount; final activeCount =
uploadManager.activeTasks.length +
downloadManager.downloadingCount;
return Badge( return Badge(
isLabelVisible: activeCount > 0, isLabelVisible: activeCount > 0,
label: Text('$activeCount'), label: Text('$activeCount'),
@@ -191,6 +327,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700), selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
label: const Text('任务'), label: const Text('任务'),
), ),
if (_showSyncTab)
NavigationRailDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
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( const NavigationRailDestination(
icon: Icon(LucideIcons.user), icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700), selectedIcon: Icon(LucideIcons.user, weight: 700),
@@ -206,32 +366,38 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
context, context,
icon: LucideIcons.share2, icon: LucideIcons.share2,
label: '我的分享', label: '我的分享',
onTap: () => Navigator.of(context).pushNamed(RouteNames.share), onTap: () =>
Navigator.of(context).pushNamed(RouteNames.share),
), ),
_buildSecondaryNavItem( _buildSecondaryNavItem(
context, context,
icon: LucideIcons.cloud, icon: LucideIcons.cloud,
label: 'WebDAV', label: 'WebDAV',
onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav), onTap: () =>
Navigator.of(context).pushNamed(RouteNames.webdav),
), ),
_buildSecondaryNavItem( _buildSecondaryNavItem(
context, context,
icon: LucideIcons.download, icon: LucideIcons.download,
label: '离线下载', label: '离线下载',
onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload), onTap: () => Navigator.of(
context,
).pushNamed(RouteNames.remoteDownload),
), ),
_buildSecondaryNavItem( _buildSecondaryNavItem(
context, context,
icon: LucideIcons.trash2, icon: LucideIcons.trash2,
label: '回收站', label: '回收站',
onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin), onTap: () =>
Navigator.of(context).pushNamed(RouteNames.recycleBin),
), ),
const Divider(indent: 12, endIndent: 12), const Divider(indent: 12, endIndent: 12),
_buildSecondaryNavItem( _buildSecondaryNavItem(
context, context,
icon: LucideIcons.settings, icon: LucideIcons.settings,
label: '设置', label: '设置',
onTap: () => Navigator.of(context).pushNamed(RouteNames.settings), onTap: () =>
Navigator.of(context).pushNamed(RouteNames.settings),
), ),
_buildSecondaryNavItem( _buildSecondaryNavItem(
context, context,
@@ -245,9 +411,7 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
), ),
), ),
const VerticalDivider(thickness: 1, width: 1), const VerticalDivider(thickness: 1, width: 1),
Expanded( Expanded(child: _buildPageContent(context, navProvider.currentIndex)),
child: _buildPageContent(context, navProvider.currentIndex),
),
], ],
), ),
); );
@@ -278,7 +442,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
Future<void> _handleLogout(BuildContext context) async { Future<void> _handleLogout(BuildContext context) async {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -302,7 +469,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
await authProvider.logout(); await authProvider.logout();
fileManager.clearFiles(); fileManager.clearFiles();
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false); Navigator.of(
context,
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
} }
} }
} }
@@ -5,6 +5,7 @@ import '../../../services/api_service.dart';
import '../../../services/server_service.dart'; import '../../../services/server_service.dart';
import '../../../services/storage_service.dart'; import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/sync_provider.dart';
/// 启动页 /// 启动页
class SplashPage extends StatefulWidget { class SplashPage extends StatefulWidget {
@@ -45,6 +46,16 @@ class _SplashPageState extends State<SplashPage> {
if (!mounted) return; if (!mounted) return;
if (authProvider.isAuthenticated) { if (authProvider.isAuthenticated) {
// 自动恢复同步(如果之前处于同步状态)
if (!mounted) return;
final syncProvider = Provider.of<SyncProvider>(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); Navigator.of(context).pushReplacementNamed(RouteNames.home);
} else { } else {
Navigator.of(context).pushReplacementNamed(RouteNames.login); Navigator.of(context).pushReplacementNamed(RouteNames.login);
@@ -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<SyncLogViewerPage> createState() => _SyncLogViewerPageState();
}
class _SyncLogViewerPageState extends State<SyncLogViewerPage> {
String _logContent = '';
bool _isLoading = true;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadLog();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<String> _getSyncLogPath() async {
final appDir = await getApplicationSupportDirectory();
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
}
Future<void> _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],
),
),
),
),
),
);
}
}
+604
View File
@@ -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<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends State<SyncPage> {
// 展开的任务 ID
final Set<String> _expandedTasks = {};
// 正在加载详情的任务 ID
final Set<String> _loadingDetails = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SyncProvider>().loadRecentTasks();
});
}
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncProvider>();
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<SyncProvider>();
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<SyncTaskModel> 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<SyncProvider>();
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<SyncProvider>();
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<SyncProvider>();
sync.loadMoreTaskDetail(taskId);
}
Future<void> _stopSync(SyncProvider sync) async {
final confirmed = await showDialog<bool>(
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();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<SyncEventModel>? _eventSub;
Timer? _pollTimer;
int _pollIntervalSeconds = 1; // 动态调节:活跃1s,空闲3s
// 任务列表
List<SyncTaskModel> _activeTasks = [];
List<SyncTaskModel> _recentTasks = [];
int _activeWorkerCount = 0;
// 任务详情缓存: taskId -> items
final Map<String, List<SyncTaskItemModel>> _taskDetailCache = {};
// 任务详情是否有更多: taskId -> hasMore
final Map<String, bool> _taskDetailHasMore = {};
// 需要实时刷新详情的任务ID(UI 展开中的任务)
final Set<String> _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<SyncTaskModel> get activeTasks => _activeTasks;
List<SyncTaskModel> 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<void> 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<void> _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<void> _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<void> 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<void> 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<void> updateConfig(SyncConfigModel config) async {
await _persistConfig(config);
// 引擎已初始化时(无论是否运行中),推送配置到 Rust
try {
await SyncService.instance.updateConfig(config);
AppLogger.i('同步配置已更新到引擎: 模式=${config.syncMode}');
} catch (e) {
AppLogger.e('更新配置到引擎失败: $e');
}
}
/// 热修改日志级别(立即生效,无需重启引擎)
Future<void> 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<void> _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<void> loadRecentTasks() async {
if (_recentTasksLoaded) return;
await _refreshRecentTasks();
if (_pollTimer == null) {
_startPolling();
}
}
Future<void> _refreshRecentTasks() async {
try {
final tasks = await SyncService.instance.getRecentTasksTyped(limit: 50);
if (!_taskListEqual(tasks, _recentTasks)) {
_recentTasks = tasks;
}
_recentTasksLoaded = true;
} catch (_) {}
}
bool _taskListEqual(List<SyncTaskModel> a, List<SyncTaskModel> 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<void> _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<SyncTaskItemModel> a, List<SyncTaskItemModel> 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<List<SyncTaskItemModel>> 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<List<SyncTaskItemModel>> 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<SyncTaskItemModel>? 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<void> pause() async {
await SyncService.instance.pause();
_state = SyncState.paused;
await _persistState(SyncState.paused);
notifyListeners();
}
/// 恢复同步
Future<void> resume() async {
await SyncService.instance.resume();
_state = SyncState.continuous;
await _persistState(SyncState.continuous);
notifyListeners();
}
/// 停止同步
Future<void> stop() async {
await SyncService.instance.stop();
_state = SyncState.stopped;
await _persistState(SyncState.stopped);
notifyListeners();
_adjustPollInterval();
}
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
Future<void> 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<void> 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();
}
}
+3 -13
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/file_utils.dart';
/// 面包屑导航组件 /// 面包屑导航组件
class FileBreadcrumb extends StatelessWidget { class FileBreadcrumb extends StatelessWidget {
final String currentPath; final String currentPath;
@@ -69,19 +71,7 @@ class FileBreadcrumb extends StatelessWidget {
} }
String _decodePathSegment(String value) { String _decodePathSegment(String value) {
var decoded = value; return FileUtils.safeDecodePathSegment(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;
} }
Widget _buildBreadcrumbItem( Widget _buildBreadcrumbItem(
+123 -36
View File
@@ -5,6 +5,7 @@ import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart' as date_utils; import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart'; import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_type_utils.dart'; import '../../core/utils/file_type_utils.dart';
import '../../core/utils/file_utils.dart';
import '../../services/file_service.dart'; import '../../services/file_service.dart';
import '../../router/app_router.dart'; import '../../router/app_router.dart';
import 'toast_helper.dart'; import 'toast_helper.dart';
@@ -101,7 +102,9 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)), bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.2),
),
), ),
), ),
child: Row( child: Row(
@@ -147,13 +150,21 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error), Icon(
LucideIcons.alertCircle,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('加载失败', style: theme.textTheme.titleSmall), Text('加载失败', style: theme.textTheme.titleSmall),
const SizedBox(height: 4), const SizedBox(height: 4),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(_error ?? '', style: TextStyle(color: theme.hintColor, fontSize: 12), textAlign: TextAlign.center), child: Text(
_error ?? '',
style: TextStyle(color: theme.hintColor, fontSize: 12),
textAlign: TextAlign.center,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')), FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
@@ -168,10 +179,8 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
? '文件夹' ? '文件夹'
: FileIconUtils.getFileTypeLabel(file.name); : FileIconUtils.getFileTypeLabel(file.name);
final extendedInfo = _fileInfo?.extendedInfo; final extendedInfo = _fileInfo?.extendedInfo;
final versionEntities = extendedInfo?.entities final versionEntities =
?.where((e) => e.type == 0) extendedInfo?.entities?.where((e) => e.type == 0).toList() ?? [];
.toList() ??
[];
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -198,15 +207,27 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(height: 16), const SizedBox(height: 16),
// 基本信息 // 基本信息
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)), _buildInfoRow(
LucideIcons.folderOpen,
'位置',
FileUtils.safeDecodePathSegment(file.relativePath),
),
if (file.isFile) if (file.isFile)
_buildInfoRow( _buildInfoRow(
LucideIcons.hardDrive, LucideIcons.hardDrive,
'大小', '大小',
date_utils.DateUtils.formatFileSize(file.size), date_utils.DateUtils.formatFileSize(file.size),
), ),
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(file.createdAt)), _buildInfoRow(
_buildInfoRow(LucideIcons.calendar, '修改时间', date_utils.DateUtils.formatDateTime(file.updatedAt)), LucideIcons.calendarPlus,
'创建时间',
date_utils.DateUtils.formatDateTime(file.createdAt),
),
_buildInfoRow(
LucideIcons.calendar,
'修改时间',
date_utils.DateUtils.formatDateTime(file.updatedAt),
),
if (file.owned != null) if (file.owned != null)
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''), _buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''),
@@ -215,7 +236,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)), Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('扩展信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), Text(
'扩展信息',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id), _buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
if (file.primaryEntity != null) if (file.primaryEntity != null)
@@ -247,7 +273,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)), Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('文件夹信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), Text(
'文件夹信息',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildFolderSummary(theme, colorScheme), _buildFolderSummary(theme, colorScheme),
], ],
@@ -270,7 +301,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
children: [ children: [
Row( Row(
children: [ children: [
Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), Text(
'版本历史',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
@@ -318,7 +354,9 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
required bool isPreviewable, required bool isPreviewable,
required FileModel file, required FileModel file,
}) { }) {
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id; final shortId = entity.id.length > 6
? entity.id.substring(0, 6)
: entity.id;
final createdBy = entity.createdBy?.nickname ?? '未知'; final createdBy = entity.createdBy?.nickname ?? '未知';
return Container( return Container(
@@ -340,24 +378,32 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
_buildVersionActionButton( _buildVersionActionButton(
icon: LucideIcons.externalLink, icon: LucideIcons.externalLink,
tooltip: '打开', tooltip: '打开',
onPressed: _isVersionLoading ? null : () => _openVersion(entity), onPressed: _isVersionLoading
? null
: () => _openVersion(entity),
), ),
_buildVersionActionButton( _buildVersionActionButton(
icon: LucideIcons.download, icon: LucideIcons.download,
tooltip: '下载', tooltip: '下载',
onPressed: _isVersionLoading ? null : () => _downloadVersion(entity), onPressed: _isVersionLoading
? null
: () => _downloadVersion(entity),
), ),
if (!isCurrent) ...[ if (!isCurrent) ...[
_buildVersionActionButton( _buildVersionActionButton(
icon: LucideIcons.pin, icon: LucideIcons.pin,
tooltip: '设为当前版本', tooltip: '设为当前版本',
onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity), onPressed: _isVersionLoading
? null
: () => _setCurrentVersion(entity),
), ),
_buildVersionActionButton( _buildVersionActionButton(
icon: LucideIcons.trash2, icon: LucideIcons.trash2,
tooltip: '删除', tooltip: '删除',
color: colorScheme.error, color: colorScheme.error,
onPressed: _isVersionLoading ? null : () => _deleteVersion(entity), onPressed: _isVersionLoading
? null
: () => _deleteVersion(entity),
), ),
], ],
]; ];
@@ -403,8 +449,10 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(height: 1), const SizedBox(height: 1),
Text( Text(
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy', '${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
style: const TextStyle(fontSize: 11, color: null) style: const TextStyle(
.copyWith(color: theme.hintColor), fontSize: 11,
color: null,
).copyWith(color: theme.hintColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -449,14 +497,26 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoRow(LucideIcons.hash, 'ID', entity.id), _buildInfoRow(LucideIcons.hash, 'ID', entity.id),
_buildInfoRow(LucideIcons.hardDrive, '大小', date_utils.DateUtils.formatFileSize(entity.size)), _buildInfoRow(
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(entity.createdAt)), LucideIcons.hardDrive,
'大小',
date_utils.DateUtils.formatFileSize(entity.size),
),
_buildInfoRow(
LucideIcons.calendarPlus,
'创建时间',
date_utils.DateUtils.formatDateTime(entity.createdAt),
),
if (createdBy != null) ...[ if (createdBy != null) ...[
_buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname), _buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname),
_buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id), _buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id),
], ],
if (entity.storagePolicy != null) if (entity.storagePolicy != null)
_buildInfoRow(LucideIcons.server, '存储策略', '${entity.storagePolicy!.name} (${entity.storagePolicy!.type})'), _buildInfoRow(
LucideIcons.server,
'存储策略',
'${entity.storagePolicy!.name} (${entity.storagePolicy!.type})',
),
if (entity.encryptedWith != null) if (entity.encryptedWith != null)
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!), _buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
], ],
@@ -508,9 +568,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
} else if (FileTypeUtils.isAudio(file.name)) { } else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args); Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args);
} else if (FileTypeUtils.isMarkdown(file.name)) { } else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: args); Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: args);
} else if (FileTypeUtils.isTextCode(file.name)) { } else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args); Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: args);
} }
} }
@@ -561,12 +625,16 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
Future<void> _deleteVersion(EntityModel entity) async { Future<void> _deleteVersion(EntityModel entity) async {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id; final shortId = entity.id.length > 6
? entity.id.substring(0, 6)
: entity.id;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('删除版本'), title: const Text('删除版本'),
content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'), content: Text(
'确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
@@ -611,15 +679,29 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
children: [ children: [
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'), _buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'), _buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
_buildInfoRow(LucideIcons.hardDrive, '总大小', date_utils.DateUtils.formatFileSize(summary.size)), _buildInfoRow(
LucideIcons.hardDrive,
'总大小',
date_utils.DateUtils.formatFileSize(summary.size),
),
if (!summary.completed) if (!summary.completed)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.alertCircle, size: 14, color: theme.colorScheme.error), Icon(
LucideIcons.alertCircle,
size: 14,
color: theme.colorScheme.error,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)), Text(
'计算未完成,结果可能不完整',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.error,
),
),
], ],
), ),
), ),
@@ -633,7 +715,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
} }
return _isCalculatingFolder return _isCalculatingFolder
? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator())) ? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
)
: SizedBox( : SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -655,13 +742,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 72, width: 72,
child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)), child: Text(
label,
style: TextStyle(fontSize: 13, color: theme.hintColor),
),
), ),
Expanded( Expanded(
child: SelectableText( child: SelectableText(value, style: const TextStyle(fontSize: 13)),
value,
style: const TextStyle(fontSize: 13),
),
), ),
], ],
), ),
+36 -22
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../../core/utils/date_utils.dart' as date_utils; import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart'; import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_utils.dart';
import '../../data/models/file_model.dart'; import '../../data/models/file_model.dart';
import '../../services/file_service.dart'; import '../../services/file_service.dart';
import '../../services/storage_service.dart'; import '../../services/storage_service.dart';
@@ -38,7 +39,8 @@ class SearchDialog extends StatefulWidget {
child: FadeTransition(opacity: fadeAnim, child: child), child: FadeTransition(opacity: fadeAnim, child: child),
); );
}, },
pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(), pageBuilder: (context, animation, secondaryAnimation) =>
const SearchDialog(),
); );
} }
@@ -145,8 +147,10 @@ class _SearchDialogState extends State<SearchDialog> {
// 在 async gap 之前获取所有引用 // 在 async gap 之前获取所有引用
final navProvider = Provider.of<NavigationProvider>(context, listen: false); final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = final fileManager = Provider.of<FileManagerProvider>(
Provider.of<FileManagerProvider>(context, listen: false); context,
listen: false,
);
final parentPath = _extractParentPath(file.path); final parentPath = _extractParentPath(file.path);
final filePath = file.path; final filePath = file.path;
@@ -286,16 +290,20 @@ class _SearchDialogState extends State<SearchDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.alertCircle, Icon(
size: 40, color: colorScheme.error.withValues(alpha: 0.7)), LucideIcons.alertCircle,
size: 40,
color: colorScheme.error.withValues(alpha: 0.7),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(_errorMessage!, Text(
_errorMessage!,
style: TextStyle(color: theme.hintColor), style: TextStyle(color: theme.hintColor),
textAlign: TextAlign.center), textAlign: TextAlign.center,
),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => onPressed: () => _performSearch(_searchController.text.trim()),
_performSearch(_searchController.text.trim()),
child: const Text('重试'), child: const Text('重试'),
), ),
], ],
@@ -316,12 +324,13 @@ class _SearchDialogState extends State<SearchDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.searchX, Icon(
LucideIcons.searchX,
size: 40, size: 40,
color: theme.hintColor.withValues(alpha: 0.5)), color: theme.hintColor.withValues(alpha: 0.5),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('未找到匹配文件', Text('未找到匹配文件', style: TextStyle(color: theme.hintColor)),
style: TextStyle(color: theme.hintColor)),
], ],
), ),
), ),
@@ -330,8 +339,7 @@ class _SearchDialogState extends State<SearchDialog> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),
child: Center( child: Center(
child: Text('输入关键词搜索文件', child: Text('输入关键词搜索文件', style: TextStyle(color: theme.hintColor)),
style: TextStyle(color: theme.hintColor)),
), ),
); );
} }
@@ -364,17 +372,21 @@ class _SearchDialogState extends State<SearchDialog> {
children: [ children: [
Icon(LucideIcons.history, size: 16, color: theme.hintColor), Icon(LucideIcons.history, size: 16, color: theme.hintColor),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('搜索历史', Text(
'搜索历史',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: theme.hintColor)), color: theme.hintColor,
),
),
const Spacer(), const Spacer(),
GestureDetector( GestureDetector(
onTap: _clearHistory, onTap: _clearHistory,
child: Text('清除', child: Text(
style: '清除',
TextStyle(fontSize: 12, color: colorScheme.primary)), style: TextStyle(fontSize: 12, color: colorScheme.primary),
),
), ),
], ],
), ),
@@ -416,13 +428,15 @@ class _SearchDialogState extends State<SearchDialog> {
Text( Text(
file.name, file.name,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 14), fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
Uri.decodeComponent(file.relativePath), FileUtils.safeDecodePathSegment(file.relativePath),
style: TextStyle(fontSize: 12, color: theme.hintColor), style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
+143
View File
@@ -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<UpdatePrompt> createState() => _UpdatePromptState();
}
class _UpdatePromptState extends State<UpdatePrompt> {
static bool _checkedThisRun = false;
bool _dialogVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdate());
}
Future<void> _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<void> _showUpdateDialog(AppUpdateInfo update) async {
var downloading = false;
var progress = 0.0;
String? status;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
final canDownload =
update.downloadUrl != null &&
(Platform.isAndroid || Platform.isWindows);
Future<void> 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;
}
+8
View File
@@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart';
import '../presentation/pages/webdav/webdav_page.dart'; import '../presentation/pages/webdav/webdav_page.dart';
import '../presentation/pages/remote_download/remote_download_page.dart'; import '../presentation/pages/remote_download/remote_download_page.dart';
import '../presentation/pages/settings/settings_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/image_preview_page.dart';
import '../presentation/pages/preview/pdf_preview_page.dart'; import '../presentation/pages/preview/pdf_preview_page.dart';
import '../presentation/pages/preview/video_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 documentPreview = '/document-preview';
static const String markdownPreview = '/markdown-preview'; static const String markdownPreview = '/markdown-preview';
static const String categoryFiles = '/category-files'; 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: default:
return MaterialPageRoute( return MaterialPageRoute(
settings: settings, settings: settings,
+6
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../services/storage_service.dart';
import '../core/exceptions/app_exception.dart'; import '../core/exceptions/app_exception.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
@@ -120,6 +121,11 @@ class ApiService {
options.headers['Authorization'] = 'Bearer $token'; 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); return handler.next(options);
}, },
); );
+18
View File
@@ -8,6 +8,8 @@ import 'package:tray_manager/tray_manager.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../presentation/providers/theme_provider.dart'; import '../presentation/providers/theme_provider.dart';
import 'sync_service.dart';
import '../src/rust/api/ffi.dart' as ffi;
/// 桌面端服务(窗口管理 + 系统托盘) /// 桌面端服务(窗口管理 + 系统托盘)
class DesktopService with TrayListener, WindowListener { class DesktopService with TrayListener, WindowListener {
@@ -188,6 +190,22 @@ class DesktopService with TrayListener, WindowListener {
try { try {
AppLogger.d('DesktopService: Cleaning up before exit...'); 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); windowManager.removeListener(this);
trayManager.removeListener(this); trayManager.removeListener(this);
+52
View File
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../core/constants/storage_keys.dart'; import '../core/constants/storage_keys.dart';
import '../data/models/server_model.dart'; import '../data/models/server_model.dart';
@@ -149,4 +150,55 @@ class StorageService {
Future<void> clearSearchHistory() async { Future<void> clearSearchHistory() async {
await remove(StorageKeys.searchHistory); await remove(StorageKeys.searchHistory);
} }
// ===== 同步配置持久化 =====
/// 保存同步配置
Future<bool> setSyncConfig(Map<String, dynamic> config) async {
try {
final json = jsonEncode(config);
return await setString(StorageKeys.syncConfig, json);
} catch (_) {
return false;
}
}
/// 读取同步配置
Future<Map<String, dynamic>?> getSyncConfig() async {
final json = await getString(StorageKeys.syncConfig);
if (json == null || json.isEmpty) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
/// 保存同步状态
Future<bool> setSyncState(String state) async {
return await setString(StorageKeys.syncState, state);
}
/// 读取同步状态
Future<String?> getSyncState() async {
return await getString(StorageKeys.syncState);
}
/// 清除同步配置和状态
Future<void> clearSyncData() async {
await remove(StorageKeys.syncConfig);
await remove(StorageKeys.syncState);
}
// ===== client_id 持久化 =====
/// 获取 client_id,首次调用自动生成并持久化
Future<String> getOrCreateClientId() async {
var id = await getString(StorageKeys.clientId);
if (id == null || id.isEmpty) {
id = const Uuid().v4();
await setString(StorageKeys.clientId, id);
}
return id;
}
} }
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import '../core/utils/app_logger.dart';
import '../data/models/sync_config_model.dart';
import '../data/models/sync_event_model.dart';
import '../data/models/sync_status_model.dart';
import '../data/models/sync_task_model.dart';
import '../src/rust/api/ffi.dart' as ffi;
import '../src/rust/api/ffi_types.dart' as ffi_types;
/// 同步服务单例 - 桥接 Flutter UI 和 Rust 同步引擎
class SyncService {
static final SyncService instance = SyncService._();
SyncService._();
bool _initialized = false;
/// 事件流,供 SyncProvider 订阅
final _eventController = StreamController<SyncEventModel>.broadcast();
Stream<SyncEventModel> get events => _eventController.stream;
/// 初始化同步引擎(已初始化时更新配置)
Future<void> init(SyncConfigModel config) async {
if (_initialized) {
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok');
return;
}
AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}');
await ffi.initSyncEngine(config: config.toFfi());
_initialized = true;
AppLogger.d('[FFI] ← initSyncEngine: ok');
}
/// 执行初始全量同步
Future<SyncSummaryModel> startInitialSync() async {
AppLogger.d('[FFI] → startInitialSync');
final summary = await ffi.startInitialSync();
AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
return SyncSummaryModel.fromFfi(summary);
}
/// 启动持续同步
Future<void> startContinuousSync() async {
AppLogger.d('[FFI] → startContinuousSync');
await ffi.startContinuousSync();
AppLogger.d('[FFI] ← startContinuousSync: spawned');
}
/// 停止同步
Future<void> stop() async {
AppLogger.d('[FFI] → stopSync');
await ffi.stopSync();
AppLogger.d('[FFI] ← stopSync: ok');
}
/// 暂停同步
Future<void> pause() async {
AppLogger.d('[FFI] → pauseSync');
await ffi.pauseSync();
AppLogger.d('[FFI] ← pauseSync: ok');
}
/// 恢复同步
Future<void> resume() async {
AppLogger.d('[FFI] → resumeSync');
await ffi.resumeSync();
AppLogger.d('[FFI] ← resumeSync: ok');
}
/// 强制重新同步
Future<SyncSummaryModel> forceSync() async {
AppLogger.d('[FFI] → forceSync');
final summary = await ffi.forceSync();
AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
return SyncSummaryModel.fromFfi(summary);
}
/// Token 变更时推送给 Rust
Future<void> updateTokens(String accessToken) async {
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
await ffi.updateTokens(accessToken: accessToken);
AppLogger.d('[FFI] ← updateTokens: ok');
}
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
Future<void> updateConfig(SyncConfigModel config) async {
if (!_initialized) return;
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
try {
await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok');
} catch (e) {
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
}
}
// ========== 以下为轮询/查询类,用 trace 避免刷屏 ==========
/// 获取同步状态快照(轮询高频调用,trace 级别)
Future<SyncStatusModel> getStatus() async {
final status = await ffi.getSyncStatus();
AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}');
return SyncStatusModel.fromFfi(status);
}
/// 获取活跃 Worker 数量(轮询高频调用,trace 级别)
Future<int> getActiveWorkerCount() async {
final count = await ffi.getActiveWorkerCount();
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
return count;
}
/// 获取活跃的同步任务列表(轮询高频调用,trace 级别)
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
final tasks = await ffi.getActiveTasks();
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel(
id: t.id,
trigger: t.trigger,
totalCount: t.totalCount,
completedCount: t.completedCount,
failedCount: t.failedCount,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
finishedAt: t.finishedAt,
)).toList();
}
/// 获取最近同步任务列表(轮询高频调用,trace 级别)
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
final tasks = await ffi.getRecentTasks(limit: limit);
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel(
id: t.id,
trigger: t.trigger,
totalCount: t.totalCount,
completedCount: t.completedCount,
failedCount: t.failedCount,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
finishedAt: t.finishedAt,
)).toList();
}
/// 获取任务详情(按需查询,trace 级别)
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
final items = await ffi.getTaskDetail(taskId: taskId);
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
return items.map((i) => SyncTaskItemModel(
id: i.id.toInt(),
taskId: i.taskId,
relativePath: i.relativePath,
actionType: i.actionType,
status: i.status,
fileSize: i.fileSize.toInt(),
errorMessage: i.errorMessage,
createdAt: i.createdAt,
updatedAt: i.updatedAt,
)).toList();
}
/// 分页查询任务详情(trace 级别)
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
required String taskId,
int limit = 20,
int offset = 0,
}) async {
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset');
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi(
taskId: taskId,
limit: limit,
offset: offset,
));
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
return items.map((i) => SyncTaskItemModel(
id: i.id.toInt(),
taskId: i.taskId,
relativePath: i.relativePath,
actionType: i.actionType,
status: i.status,
fileSize: i.fileSize.toInt(),
errorMessage: i.errorMessage,
createdAt: i.createdAt,
updatedAt: i.updatedAt,
)).toList();
}
// ========== 以下为低频操作,保持 debug 级别 ==========
/// 水合文件 (Windows CFAPi)
Future<void> hydrateFile(String localPath) async {
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
await ffi.hydrateFile(localPath: localPath);
AppLogger.d('[FFI] ← hydrateFile: ok');
}
/// 检查云端相册目录 (Android)
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}');
return {
'dcimExists': result.dcimExists,
'picturesExists': result.picturesExists,
'dcimUri': result.dcimUri,
'picturesUri': result.picturesUri,
};
}
/// 创建云端相册目录 (Android)
Future<void> createCloudAlbumDirs(String baseUri) async {
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
await ffi.createCloudAlbumDirs(baseUri: baseUri);
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
}
/// 销毁同步引擎
Future<void> dispose() async {
AppLogger.d('[FFI] → disposeSyncEngine');
await _eventController.close();
await ffi.disposeSyncEngine();
_initialized = false;
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
}
/// 热修改日志级别(立即生效,无需重启)
Future<void> setLogLevel(String level) async {
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
await ffi.setSyncLogLevel(level: level);
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
}
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
Future<void> resetSync() async {
AppLogger.d('[FFI] → resetSync');
await ffi.resetSync();
AppLogger.d('[FFI] ← resetSync: ok');
}
}
+368
View File
@@ -0,0 +1,368 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:open_file/open_file.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:xml/xml.dart';
import '../core/utils/app_logger.dart';
import '../data/models/app_update_model.dart';
class UpdateService {
UpdateService._();
static final UpdateService instance = UpdateService._();
static final Uri releasesRssUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases.rss',
);
static final Uri releasesPageUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases',
);
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 20),
followRedirects: true,
responseType: ResponseType.plain,
),
);
bool _checking = false;
Future<AppUpdateInfo?> checkForUpdate() async {
if (_checking) return null;
if (!_supportsAutoDownload) return null;
_checking = true;
try {
final current = await PackageInfo.fromPlatform();
final latest = await _fetchLatestRelease(currentVersion: current.version);
if (latest == null) return null;
if (_compareVersions(latest.version, current.version) <= 0) return null;
return latest;
} catch (e, stackTrace) {
AppLogger.e('检查更新失败: $e\n$stackTrace');
return null;
} finally {
_checking = false;
}
}
Future<File?> downloadUpdate(
AppUpdateInfo update, {
void Function(int received, int total)? onProgress,
}) async {
final url = update.downloadUrl;
if (url == null) {
return null;
}
final dir = await getTemporaryDirectory();
final updateDir = Directory(p.join(dir.path, 'app_updates'));
await updateDir.create(recursive: true);
final fileName = update.fileName ?? _fileNameFromUrl(url);
final file = File(p.join(updateDir.path, fileName));
if (await file.exists()) {
await file.delete();
}
await _dio.downloadUri(
url,
file.path,
onReceiveProgress: onProgress,
options: Options(responseType: ResponseType.bytes),
);
return file;
}
Future<void> openInstaller(File file) async {
final result = await OpenFile.open(file.path);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
}
Future<void> openReleasePage(AppUpdateInfo update) async {
final url = update.pageUrl;
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('无法打开更新页面: $url');
}
}
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
final response = await _dio.getUri<String>(releasesRssUrl);
final body = response.data;
if (body == null || body.trim().isEmpty) return null;
final document = XmlDocument.parse(body);
final items = document.findAllElements('item').toList();
if (items.isEmpty) return null;
final releases =
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
..sort(_compareRssReleaseDesc);
if (releases.isEmpty) return null;
for (final release in releases) {
if (currentVersion != null &&
_compareVersions(release.version, currentVersion) <= 0) {
continue;
}
final update = await _buildUpdateInfo(release);
if (update == null) continue;
if (update.downloadUrl == null) continue;
return update;
}
return null;
}
_RssRelease? _releaseFromRssItem(XmlElement item) {
final title = _elementText(item, 'title') ?? '';
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
final description = _elementText(item, 'description');
final content = _elementText(item, 'encoded');
final enclosure = item
.findElements('enclosure')
.map((e) => e.getAttribute('url'))
.whereType<String>()
.map(_absoluteUriOrNull)
.whereType<Uri>()
.toList();
final candidates = <Uri>[
...enclosure,
..._extractLinks(description ?? ''),
..._extractLinks(content ?? ''),
..._extractLinks(item.toXmlString()),
];
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
final version = _versionFromText(
'$title $link ${description ?? ''} ${content ?? ''}',
);
if (version == null) return null;
return _RssRelease(
version: version,
title: title.isEmpty ? 'v$version' : title,
description: _stripHtml(
(content ?? '').isNotEmpty ? content : description,
),
pageUrl: pageUrl,
candidates: candidates,
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
);
}
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
final candidates = <Uri>[...release.candidates];
final downloadUrl = _pickDownloadUrl(candidates);
Uri? resolvedDownloadUrl = downloadUrl;
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
return AppUpdateInfo(
version: release.version,
title: release.title,
description: release.description,
pageUrl: release.pageUrl,
downloadUrl: resolvedDownloadUrl,
fileName: resolvedDownloadUrl == null
? null
: _fileNameFromUrl(resolvedDownloadUrl),
publishedAt: release.publishedAt,
);
}
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
try {
final response = await _dio.getUri<String>(pageUrl);
final body = response.data;
if (body == null || body.trim().isEmpty) return null;
return _pickDownloadUrl(_extractLinks(body));
} catch (e) {
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
return null;
}
}
Uri? _pickDownloadUrl(List<Uri> candidates) {
final normalized = candidates.toSet().toList();
final preferred = normalized
.where(_isReleaseAttachment)
.where(_isSupportedPackage)
.toList();
if (preferred.isNotEmpty) return preferred.first;
return null;
}
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
bool _isReleaseAttachment(Uri uri) {
return uri.path.contains('/releases/download/');
}
bool _isSupportedPackage(Uri uri) {
final name = _fileNameFromUrl(uri).toLowerCase();
if (Platform.isAndroid) {
return name.endsWith('.apk');
}
if (Platform.isWindows) {
return name.endsWith('.exe') ||
name.endsWith('.msix') ||
name.endsWith('.msi') ||
name.endsWith('.zip');
}
if (Platform.isLinux) {
return name.endsWith('.appimage') ||
name.endsWith('.deb') ||
name.endsWith('.rpm') ||
name.endsWith('.tar.gz') ||
name.endsWith('.zip');
}
if (Platform.isMacOS) {
return name.endsWith('.dmg') ||
name.endsWith('.pkg') ||
name.endsWith('.zip');
}
return false;
}
List<Uri> _extractLinks(String html) {
final matches = RegExp(
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
caseSensitive: false,
).allMatches(html);
return matches
.map((m) => m.group(1) ?? m.group(0))
.whereType<String>()
.map(_absoluteUriOrNull)
.whereType<Uri>()
.toList();
}
Uri? _absoluteUriOrNull(String value) {
final cleaned = _decodeHtmlEntities(value.trim());
final parsed = Uri.tryParse(cleaned);
if (parsed == null) return null;
if (parsed.hasScheme) return parsed;
return releasesPageUrl.resolveUri(parsed);
}
String? _elementText(XmlElement element, String name) {
for (final child in element.children.whereType<XmlElement>()) {
if (child.name.qualified == name || child.name.local == name) {
return child.innerText.trim();
}
}
return null;
}
String? _versionFromText(String text) {
final match = RegExp(
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
).firstMatch(text);
return match?.group(1);
}
String? _stripHtml(String? value) {
if (value == null) return null;
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
return _decodeHtmlEntities(
withoutTags,
).replaceAll(RegExp(r'\s+'), ' ').trim();
}
String _decodeHtmlEntities(String value) {
return value
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&#43;', '+')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
DateTime? _parseRssDate(String? value) {
if (value == null || value.isEmpty) return null;
final direct = DateTime.tryParse(value);
if (direct != null) return direct;
try {
return HttpDate.parse(value);
} catch (_) {
return null;
}
}
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
final version = _compareVersions(b.version, a.version);
if (version != 0) return version;
final left = a.publishedAt;
final right = b.publishedAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
}
int _compareVersions(String a, String b) {
final left = _versionParts(a);
final right = _versionParts(b);
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
}
List<int> _versionParts(String version) {
final core = version.split(RegExp(r'[+\-]')).first;
return core
.split('.')
.map((part) => int.tryParse(part) ?? 0)
.toList(growable: false);
}
String _fileNameFromUrl(Uri uri) {
final segment = uri.pathSegments.isEmpty
? 'app_update'
: uri.pathSegments.last;
return Uri.decodeComponent(segment);
}
@visibleForTesting
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
}
class _RssRelease {
final String version;
final String title;
final String? description;
final Uri pageUrl;
final List<Uri> candidates;
final DateTime? publishedAt;
const _RssRelease({
required this.version,
required this.title,
required this.pageUrl,
required this.candidates,
this.description,
this.publishedAt,
});
}
+114
View File
@@ -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<void> initSyncEngine({required SyncConfigFfi config}) =>
RustSyncApi.instance.api.crateApiFfiInitSyncEngine(config: config);
/// 销毁同步引擎
Future<void> disposeSyncEngine() =>
RustSyncApi.instance.api.crateApiFfiDisposeSyncEngine();
/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放)
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
Future<void> syncShutdown() =>
RustSyncApi.instance.api.crateApiFfiSyncShutdown();
/// 执行初始全量同步
Future<SyncSummaryFfi> startInitialSync() =>
RustSyncApi.instance.api.crateApiFfiStartInitialSync();
/// 启动持续同步(后台运行,立即返回)
Future<void> startContinuousSync() =>
RustSyncApi.instance.api.crateApiFfiStartContinuousSync();
/// 停止同步
Future<void> stopSync() => RustSyncApi.instance.api.crateApiFfiStopSync();
/// 暂停同步
Future<void> pauseSync() => RustSyncApi.instance.api.crateApiFfiPauseSync();
/// 恢复同步
Future<void> resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync();
/// 强制同步(重新扫描全量差异)
Future<SyncSummaryFfi> forceSync() =>
RustSyncApi.instance.api.crateApiFfiForceSync();
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
Future<void> resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync();
/// 获取同步状态快照
Future<SyncStatusFfi> getSyncStatus() =>
RustSyncApi.instance.api.crateApiFfiGetSyncStatus();
/// 获取活跃 Worker 数量
Future<int> getActiveWorkerCount() =>
RustSyncApi.instance.api.crateApiFfiGetActiveWorkerCount();
/// 获取同步配置
Future<SyncConfigFfi> getSyncConfig() =>
RustSyncApi.instance.api.crateApiFfiGetSyncConfig();
/// 更新同步配置
Future<void> updateSyncConfig({required SyncConfigFfi config}) =>
RustSyncApi.instance.api.crateApiFfiUpdateSyncConfig(config: config);
/// Dart 推送新 Token 给 Rust
Future<void> updateTokens({required String accessToken}) =>
RustSyncApi.instance.api.crateApiFfiUpdateTokens(accessToken: accessToken);
/// 水合文件(Windows 按需下载)
Future<void> hydrateFile({required String localPath}) =>
RustSyncApi.instance.api.crateApiFfiHydrateFile(localPath: localPath);
/// 同步相册到云端
Future<void> syncAlbumToCloud({
required List<String> albumPaths,
required String remoteDcimUri,
}) => RustSyncApi.instance.api.crateApiFfiSyncAlbumToCloud(
albumPaths: albumPaths,
remoteDcimUri: remoteDcimUri,
);
/// 检查云端是否存在 DCIM/Pictures 目录
Future<CloudAlbumCheckResultFfi> checkCloudAlbumDirs({
required String baseUri,
}) => RustSyncApi.instance.api.crateApiFfiCheckCloudAlbumDirs(baseUri: baseUri);
/// 在云端创建 DCIM/Pictures 目录
Future<void> createCloudAlbumDirs({required String baseUri}) =>
RustSyncApi.instance.api.crateApiFfiCreateCloudAlbumDirs(baseUri: baseUri);
/// 注册 Rust→Dart 事件推送通道
Stream<SyncEventFfi> registerSyncEventSink() =>
RustSyncApi.instance.api.crateApiFfiRegisterSyncEventSink();
/// 运行时热修改日志级别(立即生效,无需重启)
Future<void> setSyncLogLevel({required String level}) =>
RustSyncApi.instance.api.crateApiFfiSetSyncLogLevel(level: level);
/// 获取活跃的同步任务列表
Future<List<SyncTaskFfi>> getActiveTasks() =>
RustSyncApi.instance.api.crateApiFfiGetActiveTasks();
/// 获取最近同步任务列表
Future<List<SyncTaskFfi>> getRecentTasks({required int limit}) =>
RustSyncApi.instance.api.crateApiFfiGetRecentTasks(limit: limit);
/// 获取任务详情(任务项列表)
Future<List<SyncTaskItemFfi>> getTaskDetail({required String taskId}) =>
RustSyncApi.instance.api.crateApiFfiGetTaskDetail(taskId: taskId);
/// 多维度查询任务项
Future<List<SyncTaskItemFfi>> queryTaskItems({
required TaskItemFilterFfi filter,
}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter);
+450
View File
@@ -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<String> 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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+328
View File
@@ -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<RustSyncApiWire> {
RustSyncApiApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<SyncEventFfi> 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<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
@protected
List<SyncTaskItemFfi> 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<SyncEventFfi> 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<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
@protected
List<SyncTaskItemFfi> 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<SyncEventFfi> 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<String> 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<SyncTaskFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_item_ffi(
List<SyncTaskItemFfi> 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<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustSyncApiWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}
+328
View File
@@ -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<RustSyncApiWire> {
RustSyncApiApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<SyncEventFfi> 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<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
@protected
List<SyncTaskItemFfi> 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<SyncEventFfi> 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<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
@protected
List<SyncTaskItemFfi> 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<SyncEventFfi> 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<String> 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<SyncTaskFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_item_ffi(
List<SyncTaskItemFfi> 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 {}
+33
View File
@@ -126,3 +126,36 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime) COMPONENT Runtime)
endif() 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()
+3056
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"] }
+5
View File
@@ -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
+16
View File
@@ -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"
+106
View File
@@ -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<String> {
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()));
}
}
+83
View File
@@ -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)'] }
+622
View File
@@ -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<Arc<SyncEngine>> = once_cell::sync::OnceCell::new();
/// 全局日志级别重载句柄(支持运行时热修改)
static LOG_RELOAD_HANDLE: once_cell::sync::OnceCell<
tracing_subscriber::reload::Handle<tracing_subscriber::EnvFilter, tracing_subscriber::Registry>,
> = 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<SyncSummaryFfi, SyncErrorFfi> {
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<SyncSummaryFfi, SyncErrorFfi> {
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<SyncStatusFfi, SyncErrorFfi> {
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<u32, SyncErrorFfi> {
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<SyncConfigFfi, SyncErrorFfi> {
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<String>,
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<CloudAlbumCheckResultFfi, SyncErrorFfi> {
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<SyncEventFfi>) -> 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<Vec<SyncTaskFfi>, 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<Vec<SyncTaskFfi>, 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<Vec<SyncTaskItemFfi>, 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<Vec<SyncTaskItemFfi>, 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,
}
}
+164
View File
@@ -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<String>,
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<String>,
pub error_message: Option<String>,
}
/// 初始同步摘要
#[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<String>,
pub pictures_uri: Option<String>,
}
/// 同步任务摘要(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<String>,
}
/// 同步任务项(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<String>,
pub created_at: String,
pub updated_at: String,
}
/// 任务项查询过滤器(FFI
#[derive(Debug, Clone)]
pub struct TaskItemFilterFfi {
pub task_id: Option<String>,
pub relative_path_contains: Option<String>,
pub action_type: Option<String>,
pub status: Option<String>,
pub limit: u32,
pub offset: u32,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod ffi;
pub mod ffi_types;
+635
View File
@@ -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<T> {
code: i32,
data: Option<T>,
msg: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct RefreshResponse {
access_token: String,
refresh_token: String,
}
/// Token 刷新状态:确保并发请求只刷新一次
struct RefreshState {
refreshing: bool,
notify: Arc<tokio::sync::Notify>,
}
pub struct ApiClient {
base_url: String,
access_token: RwLock<String>,
refresh_token: RwLock<String>,
refresh_state: Arc<Mutex<RefreshState>>,
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<RefreshResponse> = 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<serde_json::Value> {
if !resp.status().is_success() {
return Err(SyncError::Network(format!("HTTP {}", resp.status())));
}
let api_resp: ApiResponse<serde_json::Value> = 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<serde_json::Value> {
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<Vec<RemoteFileEntry>> {
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<RemoteFileEntry>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
if result.len() > 1_000_000 {
tracing::warn!("文件数量超过 100 万,截断初始同步");
return Ok(());
}
let mut next_page_token: Option<String> = 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<ListFilesResponse> {
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<RemoteFileEntry> = 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<_>>()));
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<i64>,
mime_type: Option<&str>,
policy_id: Option<&str>,
) -> Result<UploadSession> {
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<Vec<String>> {
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<reqwest::Response> {
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<RemoteFileEntry> {
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<RemoteFileEntry> {
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::<i64>() {
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()
}
+70
View File
@@ -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,
}
}
}
+204
View File
@@ -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<String, FileMapping>,
remote_root: &str,
sync_mode: &SyncMode,
) -> SyncPlan {
let mut plan = SyncPlan::default();
// 构建索引: relative_path → entry(统一正斜杠)
let local_map: HashMap<String, &LocalFileEntry> = local_files
.iter()
.map(|e| (crate::utils::normalize_path(&e.relative_path.to_string_lossy()), e))
.collect();
let remote_map: HashMap<String, &RemoteFileEntry> = 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<String> = 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,
}
}
+308
View File
@@ -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<u64>,
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<u64>,
) -> 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<u64>,
) -> Result<Vec<u8>> {
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)
}
+82
View File
@@ -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<LockConflictItem> },
#[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<rusqlite::Error> for SyncError {
fn from(e: rusqlite::Error) -> Self {
SyncError::Database(e.to_string())
}
}
impl From<r2d2::Error> for SyncError {
fn from(e: r2d2::Error) -> Self {
SyncError::Database(e.to_string())
}
}
impl From<reqwest::Error> for SyncError {
fn from(e: reqwest::Error) -> Self {
SyncError::Network(e.to_string())
}
}
impl From<std::io::Error> for SyncError {
fn from(e: std::io::Error) -> Self {
SyncError::FileSystem(e.to_string())
}
}
impl From<tokio::task::JoinError> 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<T> = std::result::Result<T, SyncError>;
+290
View File
@@ -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<ApiClient>,
client_id: String,
}
impl EventHandler {
pub fn new(api: Arc<ApiClient>, client_id: String) -> Self {
Self { api, client_id }
}
/// 订阅服务端 SSE 事件流 (GET /file/events)
pub async fn subscribe_sse(&self, uri: &str) -> Result<mpsc::Receiver<RemoteFileEvent>> {
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<RemoteFileEvent>,
) -> 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<SseFileEvent> = 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<RemoteFileEvent> {
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<PathBuf, Instant>,
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<RemoteFileEvent>,
window: Duration,
) -> Vec<RemoteFileEvent> {
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
}
+85
View File
@@ -0,0 +1,85 @@
use std::time::Duration;
/// SSE 事件缓冲区
#[derive(Default)]
pub(crate) struct SseEvent {
pub event_type: Option<String>,
pub data: String,
pub id: Option<String>,
}
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::<u64>() {
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,
}
+57
View File
@@ -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<Mutex<Option<crate::frb_generated::StreamSink<SyncEventFfi>>>>,
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<SyncEventFfi>) {
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 未运行)");
}
}
+71
View File
@@ -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<String, Arc<Mutex<()>>>,
}
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<Mutex<()>> {
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);
}
}
File diff suppressed because it is too large Load Diff
+156
View File
@@ -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_hashMirrorWcf 模式下可跳过以加速扫描)
pub async fn scan(
&self,
root: &Path,
depth_limit: u32,
follow_symlinks: bool,
compute_hash: bool,
) -> Result<Vec<LocalFileEntry>> {
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<String> {
mime_guess::from_path(path)
.first()
.map(|m| m.to_string())
}
+19
View File
@@ -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;
+601
View File
@@ -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<u64>,
pub excluded_paths: Vec<String>,
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteFileEntry {
pub uri: String,
pub name: String,
pub size: u64,
pub mtime_ms: i64,
pub hash: Option<String>,
pub is_dir: bool,
pub file_id: Option<String>,
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<String>,
pub local_hash: Option<String>,
pub remote_hash: Option<String>,
pub local_mtime: Option<i64>,
pub remote_mtime: Option<i64>,
pub local_size: Option<u64>,
pub remote_size: Option<u64>,
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<i64>,
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<String>,
pub session_id: Option<String>,
pub chunk_index: Option<u32>,
}
#[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<u64>,
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<String>,
pub error_message: Option<String>,
}
// ===== 本地文件事件 =====
#[derive(Debug, Clone)]
pub enum LocalFileEvent {
Created(Vec<PathBuf>),
Modified(Vec<PathBuf>),
Deleted(Vec<PathBuf>),
Renamed { old_paths: Vec<PathBuf>, new_paths: Vec<PathBuf> },
Moved { old_paths: Vec<PathBuf>, new_paths: Vec<PathBuf> },
}
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<RemoteFileEntry>,
pub pagination: Pagination,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub next_page_token: Option<String>,
pub is_cursor: bool,
pub total: Option<u64>,
}
// ===== 同步计划 =====
/// 重命名操作(本地触发 → 远程重命名,同目录下改文件名)
#[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<SyncAction>,
pub downloads: Vec<SyncAction>,
pub delete_local: Vec<SyncAction>,
pub delete_remote: Vec<SyncAction>,
pub rename_remote: Vec<RenameAction>,
pub move_remote: Vec<MoveAction>,
pub rename_local: Vec<LocalRenameAction>,
pub move_local: Vec<LocalRenameAction>,
pub conflicts: Vec<SyncConflict>,
pub mkdirs_local: Vec<String>,
pub mkdirs_remote: Vec<String>,
/// 需要递归扫描的顶层目录(持续同步场景,目录整体提交给 Worker)
pub scan_dirs: Vec<String>,
}
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<LocalFileEntry>,
pub remote_entry: Option<RemoteFileEntry>,
pub db_mapping: Option<FileMapping>,
}
#[derive(Debug, Clone)]
pub struct SyncConflict {
pub relative_path: String,
pub conflict_type: ConflictType,
pub local_entry: Option<LocalFileEntry>,
pub remote_entry: Option<RemoteFileEntry>,
pub db_mapping: Option<FileMapping>,
}
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<u64>,
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<String>,
}
/// 单文件操作记录 — 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<String>,
pub created_at: String,
pub updated_at: String,
}
/// 任务项查询过滤器
#[derive(Debug, Clone, Default)]
pub struct TaskItemFilter {
pub task_id: Option<String>,
pub relative_path_contains: Option<String>,
pub action_type: Option<String>,
pub status: Option<String>,
pub limit: u32,
pub offset: u32,
}
// ===== 云端相册检查结果 =====
#[derive(Debug, Clone)]
pub struct CloudAlbumCheckResult {
pub dcim_exists: bool,
pub pictures_exists: bool,
pub dcim_uri: Option<String>,
pub pictures_uri: Option<String>,
}
+36
View File
@@ -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<mpsc::Receiver<LocalFileEvent>>;
/// 创建本地文件占位符(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;
+152
View File
@@ -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<sync_windows::WindowsAdapter>,
fetch_rx: std::sync::Mutex<Option<mpsc::Receiver<sync_windows::FetchDataRequest>>>,
#[allow(dead_code)]
db: Arc<SyncDb>,
#[allow(dead_code)]
api: Arc<ApiClient>,
#[allow(dead_code)]
config: SyncConfig,
}
#[cfg(feature = "windows-cfapi")]
impl WcfPlatformAdapter {
pub fn new(
db: Arc<SyncDb>,
api: Arc<ApiClient>,
config: SyncConfig,
) -> anyhow::Result<Self> {
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<mpsc::Receiver<sync_windows::FetchDataRequest>> {
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()))
}
}
+972
View File
@@ -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<Connection>,
read_pool: r2d2::Pool<SqliteConnectionManager>,
}
#[derive(Debug)]
struct SyncDbConnectionCustomizer;
impl CustomizeConnection<Connection, rusqlite::Error> 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<SqliteConnectionManager> {
self.read_pool.clone()
}
pub fn open(db_path: &Path) -> Result<Self> {
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<String> = 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<String> {
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<String> = 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<Option<FileMapping>> {
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<Option<FileMapping>> {
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<Option<FileMapping>> {
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<Option<FileMapping>> {
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<Vec<FileMapping>> {
let pool = self.read_pool.clone();
let sync_root_id = sync_root_id.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<FileMapping>> {
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<bool> {
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<bool> {
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<u64> {
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<i64> {
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<Vec<TransferTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<TransferTask>> {
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<std::collections::HashMap<String, String>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(
move || -> Result<std::collections::HashMap<String, String>> {
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<Vec<SyncTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTask>> {
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<Vec<SyncTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTask>> {
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<i64> {
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<Vec<SyncTaskItem>> {
let pool = self.read_pool.clone();
let task_id = task_id.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTaskItem>> {
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<Vec<SyncTaskItem>> {
let pool = self.read_pool.clone();
let filter = filter.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTaskItem>> {
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<Box<dyn rusqlite::types::ToSql>> = 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<SyncTask> {
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::<WorkerStatus>()
.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<SyncTaskItem> {
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::<TaskActionType>()
.unwrap_or(TaskActionType::Upload),
status: status_str
.parse::<TaskItemStatus>()
.unwrap_or(TaskItemStatus::Pending),
file_size: row.get(5)?,
error_message: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
}
+63
View File
@@ -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<String>, 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<CloudAlbumCheckResult> {
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(())
}
}
@@ -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<LocalFileEvent> {
let (local_tx, rx) = tokio::sync::mpsc::channel::<LocalFileEvent>(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
}
@@ -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<SyncSummary> {
// 获取互斥锁:如果旧同步还在运行,等待其退出(旧同步的 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
}
}
@@ -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<LocalFileEvent>,
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<RenameAction> = Vec::new();
let mut move_remote: Vec<MoveAction> = Vec::new();
let mut handled_old_rels: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut handled_new_rels: std::collections::HashSet<String> = 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<String, std::path::PathBuf> = std::collections::BTreeMap::new();
let mut delete_paths: std::collections::BTreeSet<String> = 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<String> = std::collections::HashSet::new();
let mut matched_creates: std::collections::HashSet<String> = 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<String> = 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<String> = 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<SyncAction> = 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<String> {
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
}
+382
View File
@@ -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<SyncState>,
db: Arc<SyncDb>,
api: Arc<ApiClient>,
config: RwLock<SyncConfig>,
conflict: RwLock<ConflictResolver>,
sync_root_id: Option<String>,
shutdown_token: std::sync::Mutex<CancellationToken>,
/// 同步操作互斥锁:防止 force_sync / run_initial_sync 并发
sync_lock: tokio::sync::Mutex<()>,
worker_pool: WorkerPool,
#[allow(dead_code)]
file_locks: Arc<FileLockRegistry>,
#[allow(dead_code)]
ensured_dirs: Arc<DashMap<String, ()>>,
event_sink: Arc<EventSink>,
/// 远程操作导致的本地路径变化,抑制本地 debouncer 自触发事件
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
/// WCF 平台适配器(仅 MirrorWcf 模式下初始化)
#[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex<Option<Arc<crate::platform::wcf::WcfPlatformAdapter>>>,
/// WCF FETCH_DATA 回调接收端(在适配器初始化时提取)
#[cfg(feature = "windows-cfapi")]
wcf_fetch_rx: std::sync::Mutex<Option<mpsc::Receiver<sync_windows::FetchDataRequest>>>,
/// WCF 水合缓存:uri → 已下载的完整文件数据,避免同一文件重复下载
#[cfg(feature = "windows-cfapi")]
hydration_cache: Arc<DashMap<String, (Vec<u8>, std::time::Instant)>>,
/// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await)
#[cfg(feature = "windows-cfapi")]
cached_local_root: std::sync::Mutex<std::path::PathBuf>,
}
impl SyncEngine {
pub async fn new(config: SyncConfig) -> Result<Self> {
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<SyncSummary> {
// 取消当前所有操作(持续同步 + 正在运行的初始同步)
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<crate::api::ffi_types::SyncEventFfi>) {
self.event_sink.register(sink).await;
}
pub async fn get_active_tasks(&self) -> Result<Vec<SyncTask>> {
self.db.get_active_sync_tasks().await
}
pub async fn get_recent_tasks(&self, limit: u32) -> Result<Vec<SyncTask>> {
self.db.get_recent_sync_tasks(limit).await
}
pub async fn get_task_detail(&self, task_id: &str) -> Result<Vec<SyncTaskItem>> {
self.db.get_sync_task_items(task_id).await
}
pub async fn query_task_items(&self, filter: &TaskItemFilter) -> Result<Vec<SyncTaskItem>> {
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<HashMap<String, FileMapping>> {
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<HashMap<String, FileMapping>> {
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<String, FileMapping> = 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)
}
}
@@ -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",
}
}
+272
View File
@@ -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::<Vec<u8>, 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<u16> = 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<Vec<FileMapping>> {
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)
}
}
+2
View File
@@ -0,0 +1,2 @@
// 保留 TransferConfig / TransferTask / TransferDirection / TransferStatus 模型
// TransferManager 的 download/upload/retry_with_backoff 已迁移到 uploader.rs / downloader.rs / worker.rs
+299
View File
@@ -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<String, ()>,
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<i64>,
mime_type: Option<&str>,
policy_id: Option<&str>,
api: &ApiClient,
) -> Result<UploadSession> {
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<String, ()>,
) -> 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(())
}
+151
View File
@@ -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<String> {
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<Box<dyn std::future::Future<Output = u32> + 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);
}
}
+7
View File
@@ -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;
+472
View File
@@ -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<Semaphore>,
active_workers: Arc<DashMap<String, tokio::task::JoinHandle<()>>>,
/// 当前正在上传的相对路径集合,用于去重
active_upload_paths: Arc<DashSet<String>>,
/// 活跃 worker 总数(包括 submit 阻塞型 + submit_background 后台型)
active_count: Arc<std::sync::atomic::AtomicU32>,
db: Arc<SyncDb>,
api: Arc<ApiClient>,
file_locks: Arc<FileLockRegistry>,
ensured_dirs: Arc<DashMap<String, ()>>,
event_sink: Arc<crate::event_sink::EventSink>,
shutdown_token: std::sync::Mutex<CancellationToken>,
#[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex<Option<Arc<dyn PlaceholderCreator>>>,
}
impl WorkerPool {
#[allow(clippy::too_many_arguments)]
pub fn new(
db: Arc<SyncDb>,
api: Arc<ApiClient>,
file_locks: Arc<FileLockRegistry>,
ensured_dirs: Arc<DashMap<String, ()>>,
event_sink: Arc<crate::event_sink::EventSink>,
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<SyncSummary> {
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<String> {
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<String> = 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<dyn PlaceholderCreator>) {
*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<String> = 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<SyncTaskItem> = 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)
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
+80
View File
@@ -0,0 +1,80 @@
#![cfg(target_os = "linux")]
//! Linux 平台适配层
//!
//! 提供文件系统监听 (notify/inotify) 用于:
//! - 实时检测本地文件变更
//! - inotify 限制检测与配置
//! - 混合模式(事件+轮询)切换
use std::path::Path;
pub struct LinuxAdapter {
watcher: Option<notify::RecommendedWatcher>,
}
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<String>,
}
/// 占位符信息 (Linux 不使用,保持接口一致)
pub struct PlaceholderEntry {
pub relative_path: String,
pub file_size: u64,
pub is_dir: bool,
}
+29
View File
@@ -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 = []
+455
View File
@@ -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<Option<mpsc::Sender<FetchDataRequest>>> =
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<u8>,
pub required_offset: i64,
pub required_length: i64,
}
pub struct WindowsAdapter {
connection_key: Option<CF_CONNECTION_KEY>,
callback_rx: Option<mpsc::Receiver<FetchDataRequest>>,
}
/// 占位符创建信息
#[derive(Debug, Clone)]
pub struct PlaceholderEntry {
pub relative_path: String,
pub file_size: u64,
pub is_dir: bool,
pub file_identity: Vec<u8>,
}
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::<CF_SYNC_REGISTRATION>() 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::<CF_SYNC_POLICIES>() 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()),
&registration,
&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<mpsc::Receiver<FetchDataRequest>> {
self.callback_rx.take()
}
/// 创建占位符文件(批量)
pub fn create_placeholders(
&self,
base_dir: &Path,
entries: &[PlaceholderEntry],
) -> anyhow::Result<u32> {
if entries.is_empty() {
return Ok(0);
}
unsafe {
let base_path = to_pcwstr(base_dir);
let mut placeholders: Vec<CF_PLACEHOLDER_CREATE_INFO> = 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::<CF_OPERATION_INFO>() 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::<CF_OPERATION_PARAMETERS>() 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::<CF_OPERATION_INFO>() 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::<CF_OPERATION_PARAMETERS>() 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 = &params.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<u16>
fn to_pcwstr(path: &Path) -> Vec<u16> {
let mut wide: Vec<u16> = OsStr::new(path).encode_wide().collect();
wide.push(0);
wide
}
/// &str → wide null-terminated Vec<u16>
fn to_pcwstr_str(s: &str) -> Vec<u16> {
let mut wide: Vec<u16> = s.encode_utf16().collect();
wide.push(0);
wide
}
+45 -77
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "67.0.0" version: "93.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.4.1" version: "10.0.1"
animations: animations:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -69,18 +69,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: build_config:
dependency: transitive dependency: transitive
description: description:
name: build_config name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.3.0"
build_daemon: build_daemon:
dependency: transitive dependency: transitive
description: description:
@@ -89,30 +97,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.1" 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: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.13" version: "2.15.0"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev"
source: hosted
version: "7.3.2"
built_collection: built_collection:
dependency: transitive dependency: transitive
description: description:
@@ -193,14 +185,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: code_text_field:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -253,10 +237,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.6" version: "3.1.7"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -435,6 +419,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.34" 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: flutter_single_instance:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -461,22 +453,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: freezed_annotation:
dependency: transitive dependency: "direct main"
description: description:
name: freezed_annotation name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" 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: glob:
dependency: transitive dependency: transitive
description: description:
@@ -549,14 +541,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" 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: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -701,14 +685,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation: json_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -721,10 +697,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: json_serializable name: json_serializable
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.8.0" version: "6.11.4"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -1446,18 +1422,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_gen name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "4.2.3"
source_helper: source_helper:
dependency: transitive dependency: transitive
description: description:
name: source_helper name: source_helper
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.5" version: "1.3.12"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -1562,14 +1538,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.10"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
tray_manager: tray_manager:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1667,7 +1635,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
@@ -1827,7 +1795,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: transitive dependency: "direct main"
description: description:
name: xml name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+6 -2
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.11.4 sdk: ^3.11.4
@@ -93,6 +93,7 @@ dependencies:
image_picker: ^1.2.2 image_picker: ^1.2.2
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
webview_flutter: ^4.13.0 webview_flutter: ^4.13.0
xml: ^6.6.1
# 桌面端 # 桌面端
window_manager: ^0.5.0 window_manager: ^0.5.0
@@ -103,6 +104,9 @@ dependencies:
crypto: ^3.0.7 crypto: ^3.0.7
desktop_drop: ^0.7.1 desktop_drop: ^0.7.1
cross_file: ^0.3.5+2 cross_file: ^0.3.5+2
freezed_annotation: ^3.1.0
flutter_rust_bridge: ^2.12.0
uuid: ^4.5.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -116,8 +120,8 @@ dev_dependencies:
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
build_runner: ^2.4.7 build_runner: ^2.4.7
json_serializable: ^6.7.1 json_serializable: ^6.7.1
hive_generator: ^2.0.1
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
freezed: ^3.1.0
flutter_launcher_icons: flutter_launcher_icons:
android: "launcher_icon" android: "launcher_icon"
+32
View File
@@ -0,0 +1,32 @@
import 'package:cloudreve4_flutter/core/utils/file_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FileUtils', () {
test('decodePathForDisplay decodes encoded Chinese segments', () {
expect(
FileUtils.decodePathForDisplay(
'/%E4%B8%AD%E6%96%87/%E8%B5%84%E6%96%99',
),
'/\u4e2d\u6587/\u8d44\u6599',
);
});
test('decodePathForDisplay leaves malformed percent sequences safe', () {
expect(FileUtils.decodePathForDisplay('/100%/reports'), '/100%/reports');
});
test('toCloudreveUriWithQuery encodes query values', () {
final uri = FileUtils.toCloudreveUriWithQuery('/folder', {
'name': '\u4e2d\u6587 \u6587\u6863',
});
expect(uri, contains('cloudreve://my/folder?name='));
expect(uri, contains('%E4%B8%AD%E6%96%87'));
expect(
Uri.parse(uri).queryParameters['name'],
'\u4e2d\u6587 \u6587\u6863',
);
});
});
}
+1
View File
@@ -0,0 +1 @@
void main() {}
+113
View File
@@ -53,6 +53,61 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
add_subdirectory("runner") 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 # Generated plugin build rules, which manage building the plugins and adding
# them to the application. # them to the application.
include(flutter/generated_plugins.cmake) 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}" install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
CONFIGURATIONS Profile;Release CONFIGURATIONS Profile;Release
COMPONENT Runtime) 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 "$<IF:$<OR:$<CONFIG:Release>,$<CONFIG:Profile>>,release,debug>")
# 5. 动态选择 dll 路径进行安装复制
install(FILES "${RUST_TARGET_DIR}/${CONFIG_DIR_EXPR}/sync_core.dll"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
+1
View File
@@ -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. # Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble) add_dependencies(${BINARY_NAME} flutter_assemble)
+2 -2
View File
@@ -89,11 +89,11 @@ BEGIN
BEGIN BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.limo" "\0" VALUE "CompanyName", "saont.net" "\0"
VALUE "FileDescription", "公云存储" "\0" VALUE "FileDescription", "公云存储" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "公允存储" "\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 "OriginalFilename", "cloudreve4_flutter.exe" "\0"
VALUE "ProductName", "公云存储" "\0" VALUE "ProductName", "公云存储" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"