Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
17673b2862
|
|||
|
546cef5ba6
|
|||
|
39d8361080
|
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,200 @@
|
|||||||
# app
|
# app
|
||||||
|
|
||||||
🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验
|
[](https://flutter.dev)
|
||||||
|
[](https://dart.dev)
|
||||||
|
[](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) 文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -829,30 +1009,30 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
onCancel: () => fileManager.clearSelection(),
|
onCancel: () => fileManager.clearSelection(),
|
||||||
onRename: fileManager.selectedFiles.length == 1
|
onRename: fileManager.selectedFiles.length == 1
|
||||||
? () => FileOperationDialogs.showRenameDialog(
|
? () => FileOperationDialogs.showRenameDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.files.firstWhere(
|
fileManager.files.firstWhere(
|
||||||
(f) => f.path == fileManager.selectedFiles.first,
|
(f) => f.path == fileManager.selectedFiles.first,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,24 +185,27 @@ 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: Row(
|
child: SizedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
height: fillHeight ? double.infinity : null,
|
||||||
children: [
|
child: Row(
|
||||||
Icon(item.icon, color: foreground, size: 22),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const SizedBox(width: 9),
|
children: [
|
||||||
Flexible(
|
Icon(item.icon, color: foreground, size: 22),
|
||||||
child: Text(
|
const SizedBox(width: 9),
|
||||||
item.label,
|
Flexible(
|
||||||
maxLines: 1,
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
item.label,
|
||||||
style: TextStyle(
|
maxLines: 1,
|
||||||
color: foreground,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontWeight: FontWeight.w800,
|
style: TextStyle(
|
||||||
fontSize: 14,
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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: '快捷入口',
|
||||||
|
|||||||
@@ -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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -91,52 +92,54 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
right: false,
|
right: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 8,
|
right: 8,
|
||||||
top: 8,
|
top: 8,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
|
|
||||||
),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
border: Border(
|
||||||
children: [
|
bottom: BorderSide(
|
||||||
FileIconUtils.buildIconWidget(
|
color: theme.dividerColor.withValues(alpha: 0.2),
|
||||||
context: context,
|
|
||||||
file: widget.file,
|
|
||||||
size: 32,
|
|
||||||
iconSize: 18,
|
|
||||||
borderRadius: 8,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
widget.file.name,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(LucideIcons.x),
|
child: Row(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
children: [
|
||||||
),
|
FileIconUtils.buildIconWidget(
|
||||||
],
|
context: context,
|
||||||
|
file: widget.file,
|
||||||
|
size: 32,
|
||||||
|
iconSize: 18,
|
||||||
|
borderRadius: 8,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.file.name,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: _isLoading
|
||||||
child: _isLoading
|
? const Center(child: CircularProgressIndicator())
|
||||||
? const Center(child: CircularProgressIndicator())
|
: _error != null
|
||||||
: _error != null
|
? _buildError(theme)
|
||||||
? _buildError(theme)
|
: _buildContent(theme, colorScheme),
|
||||||
: _buildContent(theme, colorScheme),
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
style: TextStyle(color: theme.hintColor),
|
_errorMessage!,
|
||||||
textAlign: TextAlign.center),
|
style: TextStyle(color: theme.hintColor),
|
||||||
|
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(
|
||||||
size: 40,
|
LucideIcons.searchX,
|
||||||
color: theme.hintColor.withValues(alpha: 0.5)),
|
size: 40,
|
||||||
|
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(
|
'搜索历史',
|
||||||
fontSize: 13,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontSize: 13,
|
||||||
color: theme.hintColor)),
|
fontWeight: FontWeight.w500,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../core/utils/app_logger.dart';
|
||||||
|
import '../data/models/sync_config_model.dart';
|
||||||
|
import '../data/models/sync_event_model.dart';
|
||||||
|
import '../data/models/sync_status_model.dart';
|
||||||
|
import '../data/models/sync_task_model.dart';
|
||||||
|
import '../src/rust/api/ffi.dart' as ffi;
|
||||||
|
import '../src/rust/api/ffi_types.dart' as ffi_types;
|
||||||
|
|
||||||
|
/// 同步服务单例 - 桥接 Flutter UI 和 Rust 同步引擎
|
||||||
|
class SyncService {
|
||||||
|
static final SyncService instance = SyncService._();
|
||||||
|
SyncService._();
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// 事件流,供 SyncProvider 订阅
|
||||||
|
final _eventController = StreamController<SyncEventModel>.broadcast();
|
||||||
|
Stream<SyncEventModel> get events => _eventController.stream;
|
||||||
|
|
||||||
|
/// 初始化同步引擎(已初始化时更新配置)
|
||||||
|
Future<void> init(SyncConfigModel config) async {
|
||||||
|
if (_initialized) {
|
||||||
|
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||||
|
await ffi.updateSyncConfig(config: config.toFfi());
|
||||||
|
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}');
|
||||||
|
|
||||||
|
await ffi.initSyncEngine(config: config.toFfi());
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
AppLogger.d('[FFI] ← initSyncEngine: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行初始全量同步
|
||||||
|
Future<SyncSummaryModel> startInitialSync() async {
|
||||||
|
AppLogger.d('[FFI] → startInitialSync');
|
||||||
|
final summary = await ffi.startInitialSync();
|
||||||
|
AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||||
|
return SyncSummaryModel.fromFfi(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动持续同步
|
||||||
|
Future<void> startContinuousSync() async {
|
||||||
|
AppLogger.d('[FFI] → startContinuousSync');
|
||||||
|
await ffi.startContinuousSync();
|
||||||
|
AppLogger.d('[FFI] ← startContinuousSync: spawned');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止同步
|
||||||
|
Future<void> stop() async {
|
||||||
|
AppLogger.d('[FFI] → stopSync');
|
||||||
|
await ffi.stopSync();
|
||||||
|
AppLogger.d('[FFI] ← stopSync: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 暂停同步
|
||||||
|
Future<void> pause() async {
|
||||||
|
AppLogger.d('[FFI] → pauseSync');
|
||||||
|
await ffi.pauseSync();
|
||||||
|
AppLogger.d('[FFI] ← pauseSync: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复同步
|
||||||
|
Future<void> resume() async {
|
||||||
|
AppLogger.d('[FFI] → resumeSync');
|
||||||
|
await ffi.resumeSync();
|
||||||
|
AppLogger.d('[FFI] ← resumeSync: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强制重新同步
|
||||||
|
Future<SyncSummaryModel> forceSync() async {
|
||||||
|
AppLogger.d('[FFI] → forceSync');
|
||||||
|
final summary = await ffi.forceSync();
|
||||||
|
AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||||
|
return SyncSummaryModel.fromFfi(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token 变更时推送给 Rust
|
||||||
|
Future<void> updateTokens(String accessToken) async {
|
||||||
|
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
|
||||||
|
await ffi.updateTokens(accessToken: accessToken);
|
||||||
|
AppLogger.d('[FFI] ← updateTokens: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
|
||||||
|
Future<void> updateConfig(SyncConfigModel config) async {
|
||||||
|
if (!_initialized) return;
|
||||||
|
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||||
|
try {
|
||||||
|
await ffi.updateSyncConfig(config: config.toFfi());
|
||||||
|
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 以下为轮询/查询类,用 trace 避免刷屏 ==========
|
||||||
|
|
||||||
|
/// 获取同步状态快照(轮询高频调用,trace 级别)
|
||||||
|
Future<SyncStatusModel> getStatus() async {
|
||||||
|
final status = await ffi.getSyncStatus();
|
||||||
|
AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}');
|
||||||
|
return SyncStatusModel.fromFfi(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取活跃 Worker 数量(轮询高频调用,trace 级别)
|
||||||
|
Future<int> getActiveWorkerCount() async {
|
||||||
|
final count = await ffi.getActiveWorkerCount();
|
||||||
|
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取活跃的同步任务列表(轮询高频调用,trace 级别)
|
||||||
|
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
|
||||||
|
final tasks = await ffi.getActiveTasks();
|
||||||
|
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
|
||||||
|
return tasks.map((t) => SyncTaskModel(
|
||||||
|
id: t.id,
|
||||||
|
trigger: t.trigger,
|
||||||
|
totalCount: t.totalCount,
|
||||||
|
completedCount: t.completedCount,
|
||||||
|
failedCount: t.failedCount,
|
||||||
|
status: t.status,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
finishedAt: t.finishedAt,
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取最近同步任务列表(轮询高频调用,trace 级别)
|
||||||
|
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
|
||||||
|
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
|
||||||
|
final tasks = await ffi.getRecentTasks(limit: limit);
|
||||||
|
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
|
||||||
|
return tasks.map((t) => SyncTaskModel(
|
||||||
|
id: t.id,
|
||||||
|
trigger: t.trigger,
|
||||||
|
totalCount: t.totalCount,
|
||||||
|
completedCount: t.completedCount,
|
||||||
|
failedCount: t.failedCount,
|
||||||
|
status: t.status,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
finishedAt: t.finishedAt,
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取任务详情(按需查询,trace 级别)
|
||||||
|
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
|
||||||
|
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
|
||||||
|
final items = await ffi.getTaskDetail(taskId: taskId);
|
||||||
|
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
|
||||||
|
return items.map((i) => SyncTaskItemModel(
|
||||||
|
id: i.id.toInt(),
|
||||||
|
taskId: i.taskId,
|
||||||
|
relativePath: i.relativePath,
|
||||||
|
actionType: i.actionType,
|
||||||
|
status: i.status,
|
||||||
|
fileSize: i.fileSize.toInt(),
|
||||||
|
errorMessage: i.errorMessage,
|
||||||
|
createdAt: i.createdAt,
|
||||||
|
updatedAt: i.updatedAt,
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页查询任务详情(trace 级别)
|
||||||
|
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
|
||||||
|
required String taskId,
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset');
|
||||||
|
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi(
|
||||||
|
taskId: taskId,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
));
|
||||||
|
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
|
||||||
|
return items.map((i) => SyncTaskItemModel(
|
||||||
|
id: i.id.toInt(),
|
||||||
|
taskId: i.taskId,
|
||||||
|
relativePath: i.relativePath,
|
||||||
|
actionType: i.actionType,
|
||||||
|
status: i.status,
|
||||||
|
fileSize: i.fileSize.toInt(),
|
||||||
|
errorMessage: i.errorMessage,
|
||||||
|
createdAt: i.createdAt,
|
||||||
|
updatedAt: i.updatedAt,
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 以下为低频操作,保持 debug 级别 ==========
|
||||||
|
|
||||||
|
/// 水合文件 (Windows CFAPi)
|
||||||
|
Future<void> hydrateFile(String localPath) async {
|
||||||
|
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
|
||||||
|
await ffi.hydrateFile(localPath: localPath);
|
||||||
|
AppLogger.d('[FFI] ← hydrateFile: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查云端相册目录 (Android)
|
||||||
|
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
|
||||||
|
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
|
||||||
|
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
|
||||||
|
AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}');
|
||||||
|
return {
|
||||||
|
'dcimExists': result.dcimExists,
|
||||||
|
'picturesExists': result.picturesExists,
|
||||||
|
'dcimUri': result.dcimUri,
|
||||||
|
'picturesUri': result.picturesUri,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建云端相册目录 (Android)
|
||||||
|
Future<void> createCloudAlbumDirs(String baseUri) async {
|
||||||
|
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
|
||||||
|
await ffi.createCloudAlbumDirs(baseUri: baseUri);
|
||||||
|
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 销毁同步引擎
|
||||||
|
Future<void> dispose() async {
|
||||||
|
AppLogger.d('[FFI] → disposeSyncEngine');
|
||||||
|
await _eventController.close();
|
||||||
|
await ffi.disposeSyncEngine();
|
||||||
|
_initialized = false;
|
||||||
|
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 热修改日志级别(立即生效,无需重启)
|
||||||
|
Future<void> setLogLevel(String level) async {
|
||||||
|
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
|
||||||
|
await ffi.setSyncLogLevel(level: level);
|
||||||
|
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||||
|
Future<void> resetSync() async {
|
||||||
|
AppLogger.d('[FFI] → resetSync');
|
||||||
|
await ffi.resetSync();
|
||||||
|
AppLogger.d('[FFI] ← resetSync: ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:open_file/open_file.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import '../core/utils/app_logger.dart';
|
||||||
|
import '../data/models/app_update_model.dart';
|
||||||
|
|
||||||
|
class UpdateService {
|
||||||
|
UpdateService._();
|
||||||
|
|
||||||
|
static final UpdateService instance = UpdateService._();
|
||||||
|
|
||||||
|
static final Uri releasesRssUrl = Uri.parse(
|
||||||
|
'https://git.saont.net/gongyun/app/releases.rss',
|
||||||
|
);
|
||||||
|
static final Uri releasesPageUrl = Uri.parse(
|
||||||
|
'https://git.saont.net/gongyun/app/releases',
|
||||||
|
);
|
||||||
|
|
||||||
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 12),
|
||||||
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
|
followRedirects: true,
|
||||||
|
responseType: ResponseType.plain,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _checking = false;
|
||||||
|
|
||||||
|
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||||
|
if (_checking) return null;
|
||||||
|
if (!_supportsAutoDownload) return null;
|
||||||
|
|
||||||
|
_checking = true;
|
||||||
|
try {
|
||||||
|
final current = await PackageInfo.fromPlatform();
|
||||||
|
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||||
|
if (latest == null) return null;
|
||||||
|
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
||||||
|
return latest;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
_checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> downloadUpdate(
|
||||||
|
AppUpdateInfo update, {
|
||||||
|
void Function(int received, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final url = update.downloadUrl;
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final updateDir = Directory(p.join(dir.path, 'app_updates'));
|
||||||
|
await updateDir.create(recursive: true);
|
||||||
|
|
||||||
|
final fileName = update.fileName ?? _fileNameFromUrl(url);
|
||||||
|
final file = File(p.join(updateDir.path, fileName));
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dio.downloadUri(
|
||||||
|
url,
|
||||||
|
file.path,
|
||||||
|
onReceiveProgress: onProgress,
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openInstaller(File file) async {
|
||||||
|
final result = await OpenFile.open(file.path);
|
||||||
|
if (result.type != ResultType.done) {
|
||||||
|
throw Exception(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openReleasePage(AppUpdateInfo update) async {
|
||||||
|
final url = update.pageUrl;
|
||||||
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
|
throw Exception('无法打开更新页面: $url');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||||
|
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||||
|
final body = response.data;
|
||||||
|
if (body == null || body.trim().isEmpty) return null;
|
||||||
|
|
||||||
|
final document = XmlDocument.parse(body);
|
||||||
|
final items = document.findAllElements('item').toList();
|
||||||
|
if (items.isEmpty) return null;
|
||||||
|
|
||||||
|
final releases =
|
||||||
|
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
|
||||||
|
..sort(_compareRssReleaseDesc);
|
||||||
|
if (releases.isEmpty) return null;
|
||||||
|
|
||||||
|
for (final release in releases) {
|
||||||
|
if (currentVersion != null &&
|
||||||
|
_compareVersions(release.version, currentVersion) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final update = await _buildUpdateInfo(release);
|
||||||
|
if (update == null) continue;
|
||||||
|
|
||||||
|
if (update.downloadUrl == null) continue;
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_RssRelease? _releaseFromRssItem(XmlElement item) {
|
||||||
|
final title = _elementText(item, 'title') ?? '';
|
||||||
|
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
|
||||||
|
final description = _elementText(item, 'description');
|
||||||
|
final content = _elementText(item, 'encoded');
|
||||||
|
final enclosure = item
|
||||||
|
.findElements('enclosure')
|
||||||
|
.map((e) => e.getAttribute('url'))
|
||||||
|
.whereType<String>()
|
||||||
|
.map(_absoluteUriOrNull)
|
||||||
|
.whereType<Uri>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final candidates = <Uri>[
|
||||||
|
...enclosure,
|
||||||
|
..._extractLinks(description ?? ''),
|
||||||
|
..._extractLinks(content ?? ''),
|
||||||
|
..._extractLinks(item.toXmlString()),
|
||||||
|
];
|
||||||
|
|
||||||
|
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
|
||||||
|
final version = _versionFromText(
|
||||||
|
'$title $link ${description ?? ''} ${content ?? ''}',
|
||||||
|
);
|
||||||
|
if (version == null) return null;
|
||||||
|
|
||||||
|
return _RssRelease(
|
||||||
|
version: version,
|
||||||
|
title: title.isEmpty ? 'v$version' : title,
|
||||||
|
description: _stripHtml(
|
||||||
|
(content ?? '').isNotEmpty ? content : description,
|
||||||
|
),
|
||||||
|
pageUrl: pageUrl,
|
||||||
|
candidates: candidates,
|
||||||
|
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
|
||||||
|
final candidates = <Uri>[...release.candidates];
|
||||||
|
final downloadUrl = _pickDownloadUrl(candidates);
|
||||||
|
Uri? resolvedDownloadUrl = downloadUrl;
|
||||||
|
|
||||||
|
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
|
||||||
|
|
||||||
|
return AppUpdateInfo(
|
||||||
|
version: release.version,
|
||||||
|
title: release.title,
|
||||||
|
description: release.description,
|
||||||
|
pageUrl: release.pageUrl,
|
||||||
|
downloadUrl: resolvedDownloadUrl,
|
||||||
|
fileName: resolvedDownloadUrl == null
|
||||||
|
? null
|
||||||
|
: _fileNameFromUrl(resolvedDownloadUrl),
|
||||||
|
publishedAt: release.publishedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.getUri<String>(pageUrl);
|
||||||
|
final body = response.data;
|
||||||
|
if (body == null || body.trim().isEmpty) return null;
|
||||||
|
return _pickDownloadUrl(_extractLinks(body));
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _pickDownloadUrl(List<Uri> candidates) {
|
||||||
|
final normalized = candidates.toSet().toList();
|
||||||
|
final preferred = normalized
|
||||||
|
.where(_isReleaseAttachment)
|
||||||
|
.where(_isSupportedPackage)
|
||||||
|
.toList();
|
||||||
|
if (preferred.isNotEmpty) return preferred.first;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
||||||
|
|
||||||
|
bool _isReleaseAttachment(Uri uri) {
|
||||||
|
return uri.path.contains('/releases/download/');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSupportedPackage(Uri uri) {
|
||||||
|
final name = _fileNameFromUrl(uri).toLowerCase();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return name.endsWith('.apk');
|
||||||
|
}
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
return name.endsWith('.exe') ||
|
||||||
|
name.endsWith('.msix') ||
|
||||||
|
name.endsWith('.msi') ||
|
||||||
|
name.endsWith('.zip');
|
||||||
|
}
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return name.endsWith('.appimage') ||
|
||||||
|
name.endsWith('.deb') ||
|
||||||
|
name.endsWith('.rpm') ||
|
||||||
|
name.endsWith('.tar.gz') ||
|
||||||
|
name.endsWith('.zip');
|
||||||
|
}
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
return name.endsWith('.dmg') ||
|
||||||
|
name.endsWith('.pkg') ||
|
||||||
|
name.endsWith('.zip');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Uri> _extractLinks(String html) {
|
||||||
|
final matches = RegExp(
|
||||||
|
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
|
||||||
|
caseSensitive: false,
|
||||||
|
).allMatches(html);
|
||||||
|
return matches
|
||||||
|
.map((m) => m.group(1) ?? m.group(0))
|
||||||
|
.whereType<String>()
|
||||||
|
.map(_absoluteUriOrNull)
|
||||||
|
.whereType<Uri>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _absoluteUriOrNull(String value) {
|
||||||
|
final cleaned = _decodeHtmlEntities(value.trim());
|
||||||
|
final parsed = Uri.tryParse(cleaned);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
if (parsed.hasScheme) return parsed;
|
||||||
|
return releasesPageUrl.resolveUri(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _elementText(XmlElement element, String name) {
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.name.qualified == name || child.name.local == name) {
|
||||||
|
return child.innerText.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _versionFromText(String text) {
|
||||||
|
final match = RegExp(
|
||||||
|
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
|
||||||
|
).firstMatch(text);
|
||||||
|
return match?.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stripHtml(String? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||||
|
return _decodeHtmlEntities(
|
||||||
|
withoutTags,
|
||||||
|
).replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decodeHtmlEntities(String value) {
|
||||||
|
return value
|
||||||
|
.replaceAll(' ', ' ')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll('+', '+')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseRssDate(String? value) {
|
||||||
|
if (value == null || value.isEmpty) return null;
|
||||||
|
final direct = DateTime.tryParse(value);
|
||||||
|
if (direct != null) return direct;
|
||||||
|
try {
|
||||||
|
return HttpDate.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
|
||||||
|
final version = _compareVersions(b.version, a.version);
|
||||||
|
if (version != 0) return version;
|
||||||
|
|
||||||
|
final left = a.publishedAt;
|
||||||
|
final right = b.publishedAt;
|
||||||
|
if (left == null && right == null) return 0;
|
||||||
|
if (left == null) return 1;
|
||||||
|
if (right == null) return -1;
|
||||||
|
return right.compareTo(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _compareVersions(String a, String b) {
|
||||||
|
final left = _versionParts(a);
|
||||||
|
final right = _versionParts(b);
|
||||||
|
final length = left.length > right.length ? left.length : right.length;
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final l = i < left.length ? left[i] : 0;
|
||||||
|
final r = i < right.length ? right[i] : 0;
|
||||||
|
if (l != r) return l.compareTo(r);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _versionParts(String version) {
|
||||||
|
final core = version.split(RegExp(r'[+\-]')).first;
|
||||||
|
return core
|
||||||
|
.split('.')
|
||||||
|
.map((part) => int.tryParse(part) ?? 0)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fileNameFromUrl(Uri uri) {
|
||||||
|
final segment = uri.pathSegments.isEmpty
|
||||||
|
? 'app_update'
|
||||||
|
: uri.pathSegments.last;
|
||||||
|
return Uri.decodeComponent(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RssRelease {
|
||||||
|
final String version;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final Uri pageUrl;
|
||||||
|
final List<Uri> candidates;
|
||||||
|
final DateTime? publishedAt;
|
||||||
|
|
||||||
|
const _RssRelease({
|
||||||
|
required this.version,
|
||||||
|
required this.title,
|
||||||
|
required this.pageUrl,
|
||||||
|
required this.candidates,
|
||||||
|
this.description,
|
||||||
|
this.publishedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
|||||||
Generated
+3056
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)'] }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod ffi;
|
||||||
|
pub mod ffi_types;
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 未运行)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,156 @@
|
|||||||
|
#[cfg(unix)]
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use crate::errors::Result;
|
||||||
|
use crate::models::LocalFileEntry;
|
||||||
|
use crate::utils::quick_hash;
|
||||||
|
use std::path::Path;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// 需要跳过的文件/目录名前缀和名称
|
||||||
|
pub const SKIP_NAMES: &[&str] = &[
|
||||||
|
".DS_Store",
|
||||||
|
"Thumbs.db",
|
||||||
|
"desktop.ini",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 需要跳过的文件扩展名(同步临时文件)
|
||||||
|
pub const SKIP_EXTENSIONS: &[&str] = &[
|
||||||
|
"sync_tmp",
|
||||||
|
"sync_temp",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct FsScanner;
|
||||||
|
|
||||||
|
impl Default for FsScanner {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsScanner {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归扫描本地目录
|
||||||
|
/// `compute_hash`: 是否计算文件 quick_hash(MirrorWcf 模式下可跳过以加速扫描)
|
||||||
|
pub async fn scan(
|
||||||
|
&self,
|
||||||
|
root: &Path,
|
||||||
|
depth_limit: u32,
|
||||||
|
follow_symlinks: bool,
|
||||||
|
compute_hash: bool,
|
||||||
|
) -> Result<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())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// 保留 TransferConfig / TransferTask / TransferDirection / TransferStatus 模型
|
||||||
|
// TransferManager 的 download/upload/retry_with_backoff 已迁移到 uploader.rs / downloader.rs / worker.rs
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
use crate::api_client::ApiClient;
|
||||||
|
use crate::errors::{Result, SyncError};
|
||||||
|
use crate::file_lock::FileLockRegistry;
|
||||||
|
use crate::models::*;
|
||||||
|
use crate::sync_db::SyncDb;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
/// 上传单个文件(含重试),受并发信号量控制
|
||||||
|
/// 逐块读取文件,避免全量加载到内存
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn upload_file(
|
||||||
|
task_id: &str,
|
||||||
|
action: &SyncAction,
|
||||||
|
config: &WorkerConfig,
|
||||||
|
api: &ApiClient,
|
||||||
|
db: &SyncDb,
|
||||||
|
file_locks: &FileLockRegistry,
|
||||||
|
ensured_dirs: &DashMap<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(())
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 = []
|
||||||
@@ -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()),
|
||||||
|
®istration,
|
||||||
|
&policies,
|
||||||
|
CF_REGISTER_FLAG_UPDATE,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tracing::info!("同步根已注册: {}", root_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接同步根,注册回调(先断开残留连接)
|
||||||
|
pub fn connect_sync_root(&mut self, root_path: &Path) -> anyhow::Result<()> {
|
||||||
|
// 先断开残留的旧连接
|
||||||
|
if let Some(key) = self.connection_key.take() {
|
||||||
|
unsafe {
|
||||||
|
let _ = CfDisconnectSyncRoot(key);
|
||||||
|
}
|
||||||
|
tracing::debug!("已断开旧的同步根连接");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel(256);
|
||||||
|
// 替换全局回调发送端
|
||||||
|
{
|
||||||
|
let mut guard = CALLBACK_SENDER.lock().unwrap();
|
||||||
|
*guard = Some(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let sync_root_path = to_pcwstr(root_path);
|
||||||
|
|
||||||
|
let callbacks = [
|
||||||
|
CF_CALLBACK_REGISTRATION {
|
||||||
|
Type: CF_CALLBACK_TYPE_FETCH_DATA,
|
||||||
|
Callback: Some(cf_fetch_data_callback),
|
||||||
|
},
|
||||||
|
CF_CALLBACK_REGISTRATION {
|
||||||
|
Type: CF_CALLBACK_TYPE_CANCEL_FETCH_DATA,
|
||||||
|
Callback: Some(cf_cancel_fetch_callback),
|
||||||
|
},
|
||||||
|
CF_CALLBACK_REGISTRATION {
|
||||||
|
Type: CF_CALLBACK_TYPE_NONE,
|
||||||
|
Callback: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let connection_key = CfConnectSyncRoot(
|
||||||
|
PCWSTR(sync_root_path.as_ptr()),
|
||||||
|
callbacks.as_ptr(),
|
||||||
|
None,
|
||||||
|
CF_CONNECT_FLAG_NONE,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.connection_key = Some(connection_key);
|
||||||
|
self.callback_rx = Some(rx);
|
||||||
|
|
||||||
|
tracing::info!("同步根已连接: {}", root_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走回调接收端(供 sync-core 消费)
|
||||||
|
pub fn take_callback_receiver(&mut self) -> Option<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 = ¶ms.Anonymous.FetchData;
|
||||||
|
(fetch.RequiredFileOffset, fetch.RequiredLength)
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = FetchDataRequest {
|
||||||
|
connection_key: info.ConnectionKey.0,
|
||||||
|
transfer_key: info.TransferKey,
|
||||||
|
file_identity,
|
||||||
|
required_offset,
|
||||||
|
required_length,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"CFApi 水合请求: transfer_key={}, identity_len={}, offset={}, length={}",
|
||||||
|
info.TransferKey,
|
||||||
|
info.FileIdentityLength,
|
||||||
|
required_offset,
|
||||||
|
required_length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Ok(guard) = CALLBACK_SENDER.lock() {
|
||||||
|
if let Some(ref sender) = *guard {
|
||||||
|
let _ = sender.blocking_send(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CFApi 回调:取消水合
|
||||||
|
unsafe extern "system" fn cf_cancel_fetch_callback(
|
||||||
|
callback_info: *const CF_CALLBACK_INFO,
|
||||||
|
_callback_parameters: *const CF_CALLBACK_PARAMETERS,
|
||||||
|
) {
|
||||||
|
if callback_info.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::debug!("CFApi 取消水合请求: transfer_key={}", (*callback_info).TransferKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path → wide null-terminated Vec<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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
void main() {}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user