19 Commits

Author SHA1 Message Date
gongyun 3b94f40def Merge pull request '新增网络测试功能,预留付费用户线路自定义与线路自选入口' (#8) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h6m5s
Reviewed-on: #8
2026-05-31 10:23:10 +08:00
gongyun edcde96051 Merge branch 'main' into dev 2026-05-31 10:21:47 +08:00
gongyun 5ee6ba1e28 完善PR审查后出现的问题 2026-05-31 10:13:36 +08:00
gongyun 61ad85f6fc Optimize automatic updates 2026-05-31 09:32:32 +08:00
gongyun 8dfc22691a 新增网络测试功能,预留付费用户线路自定义与线路自选入口 2026-05-31 08:19:13 +08:00
gongyun c583c51d80 Merge pull request 'Upstream source code synchronization' (#6) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h2m9s
Reviewed-on: #6
2026-05-28 23:13:18 +08:00
gongyun 4d6ca139e5 v1.3.4 2026-05-28 22:59:25 +08:00
gongyun 81864c99c2 Upstream source code synchronization 2026-05-28 22:08:12 +08:00
gongyun fb5fd6c9bc 统一tag与release版本号格式 2026-05-28 05:24:02 +08:00
gongyun c208a06af5 t push origin mainMerge branch 'dev'
Android APK Release / Build Android APK (push) Successful in 1h1m49s
2026-05-28 03:25:24 +08:00
gongyun c2db8b38e9 修复apk build error 2026-05-28 00:46:52 +08:00
gongyun 24d20cbfcb 修复处理内容包括公告 WebView、文件预览页、分享页兜底获取下载链接的隐藏 WebView。 2026-05-27 23:47:10 +08:00
gongyun 12f2c2660e Version number update 2026-05-27 19:27:27 +08:00
gongyun 98af110531 merge new features 2026-05-27 19:19:11 +08:00
gongyun 886046a72b 更新 README.md 2026-05-26 17:02:18 +08:00
gongyun d20f7790e3 更新 README.md
dev分支README文件修改
2026-05-26 16:49:14 +08:00
gongyun 17673b2862 1.3.21.3.2
Android APK Release / Build Android APK (push) Successful in 55m29s
2026-05-26 16:32:18 +08:00
gongyun 546cef5ba6 Complete the readme.
Android APK Release / Build Android APK (push) Failing after 54m14s
2026-05-25 22:42:18 +08:00
gongyun 39d8361080 Fix some known issues and add some new functions
Android APK Release / Build Android APK (push) Failing after 52m53s
2026-05-25 20:03:59 +08:00
118 changed files with 32702 additions and 1008 deletions
+5 -5
View File
@@ -191,7 +191,7 @@ jobs:
exit 1 exit 1
fi fi
if [[ "$GITHUB_REF" == refs/tags/android-app-v* ]]; then if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
tag_name="${GITHUB_REF#refs/tags/}" tag_name="${GITHUB_REF#refs/tags/}"
else else
app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)" app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)"
@@ -199,7 +199,7 @@ jobs:
echo "Unable to resolve app version from pubspec.yaml." echo "Unable to resolve app version from pubspec.yaml."
exit 1 exit 1
fi fi
tag_name="android-app-v${app_version}" tag_name="v${app_version}"
fi fi
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT" echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
@@ -249,7 +249,7 @@ jobs:
releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases" releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases"
release_by_tag_url="$releases_url/tags/$TAG_NAME" release_by_tag_url="$releases_url/tags/$TAG_NAME"
export RELEASE_TITLE="Android App APK ${TAG_NAME}" export RELEASE_TITLE="${TAG_NAME}"
export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}." export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}."
status="$(curl -sS -o release.json -w "%{http_code}" \ status="$(curl -sS -o release.json -w "%{http_code}" \
@@ -265,7 +265,7 @@ jobs:
print(json.dumps({ print(json.dumps({
"tag_name": tag, "tag_name": tag,
"target_commitish": os.environ.get("TARGET_COMMITISH", ""), "target_commitish": os.environ.get("TARGET_COMMITISH", ""),
"name": f"Android App APK {tag}", "name": os.environ["RELEASE_TITLE"],
"body": f"Android app installation package (APK) for {tag}.", "body": f"Android app installation package (APK) for {tag}.",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
@@ -353,7 +353,7 @@ jobs:
fi fi
done < asset-ids-to-delete.txt done < asset-ids-to-delete.txt
release_asset_name="gongyun-${TAG_NAME}.apk" release_asset_name="app-release-${TAG_NAME}.apk"
curl -sS -f \ curl -sS -f \
-X POST \ -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
+11
View File
@@ -21,6 +21,7 @@ migrate_working_dir/
.frontend/ .frontend/
# custom # custom
.claude/
CLAUDE.md CLAUDE.md
claude_project.md claude_project.md
PROJECT_REQUIREMENTS.md PROJECT_REQUIREMENTS.md
@@ -28,6 +29,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 +63,12 @@ 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
android/app/src/main/jniLibs/*
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
+198 -1
View File
@@ -1,3 +1,200 @@
# app # app
🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验 [![Flutter](https://img.shields.io/badge/Flutter-3.41.6-blue?logo=flutter)](https://flutter.dev)
[![Dart](https://img.shields.io/badge/Dart-3.11.4-blue?logo=dart)](https://dart.dev)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
> 🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验
---
## 📖 项目简介
功能丰富的云存储客户端,支持文件上传、下载、管理和分享。针对 公云存储 API 进行了基本完整适配和优化。
### ✨ 特性亮点
- 🎯 现支持的功能基本完整适配 公云存储 API
- 📱 跨平台支持(Android / Linux / Windows / Web
- 🎨 现代化 Material Design 3 界面
- ⚡ 支持断点续传和下载进度监听
- 🔐 安全的 Token 认证机制
---
## 🛠️ 技术栈
| 组件 | 版本 |
|------|------|
| Flutter | 3.41.6 |
| Dart | 3.11.4 |
| 后端 API | 公云存储 |
**开发环境详情:**
```
Debia
Flutter 3.41.6 • channel stable
Framework • revision db50e20168 • 2026-03-25
Dart 3.11.4 • DevTools 2.54.2
```
**构建环境详情:**
```
Android: compileAPI: 36, targetAPI: 36, miniAPI: 34
Windows: 11
Linux: Debian 12
```
---
## 📋 功能状态
### ✅ 基础功能
| 功能模块 | 状态 | 说明 |
|--------|------|----------------------------------------|
| 用户登录 | ✅ | 自定义服务器, Token 认证、持久化 |
| 文件列表 | ✅ | 列表/网格双视图 |
| 刷新列表 | ✅ | 增量更新 |
| 全屏手势 | ✅ | 右侧左滑返回上级目录, 根目录提示退出 |
| 文件下载 | ✅ | 原生/浏览器双实现、进度监听、断点续传、后台下载 |
| 文件上传 | ✅ | 进度展示、分片上传<服务端需要开启> Windows/Linux支持拖拽上传 |
| 删除文件 | ✅ | 删除文件 |
| 重命名 | ✅ | 重命名文件 |
| 移动复制 | ✅ | 移动/复制文件, 添加文件夹选择器对话框 |
| 我的分享 | ✅ | 完整的分享功能, 包括创建,删除,管理列表,编辑等 |
| 找回密码 | ✅ | 使用邮箱找回密码 (依赖控制台STMP可用性) |
| 用户注册 | ✅ | 使用邮箱注册新用户 (依赖控制台允许注册新用户) |
| 回收站 | ✅ | 文件恢复/彻底删除 |
| WebDav | ✅ | 增删改查(硬编码查50条) |
| 文件搜索 | ✅ | 全局搜索功能, 点击跳转到对应目录 |
| 设置页面 | ✅ | 增加多个实用的设置项 |
| 离线下载 | ✅ | 离线下载(依赖服务端aria2可用) |
| 缩略图 | ✅ | 网格布局缩略图懒加载支持 |
> 文件下载
> 原生/浏览器双实现原因:
>
> > ~~选型使用了 `flutter_downloader` 来实现Android后台下载, 避免切换后台下载异常, 但这玩意儿不支持跨平台, 所以PC端就实现了获取文件url地址在浏览器打开进行下载; 正常应该选用 `background_downloader`~~
>
> 文件上传
>
> > 看后端接口文档, 必须要按分片顺序上传, 看着是不支持多分片并发上传, 差点意思, 效率不高.
------
### ✅ 设置页面
| 功能模块 | 状态 | 说明 |
|------|----|-------------------------------------|
| 个人资料 | ✅ | 修改昵称和头像 |
| 安全设置 | ✅ | 修改密码/2FA等 |
| 快捷入口 | ✅ | 概览页快捷入口设置, 默认4个, 支持新增修改和调整顺序 |
| 文件偏好 | ✅ | 历史版本开关, 视图同步, 个人主页分享链接可见性 |
| 应用设置 | ✅ | 深色模式/主题/语言/gravatar镜像/下载设置/缓存/日志管理等 |
| 关于 | ✅ | APP信息 |
-----
### ✅ 预览模块
| 功能模块 | 状态 | 说明 |
|----------|------|------|
| 图片预览 | ✅ | 全平台支持, win/linux 支持CTRL+鼠标滚轮缩放,双击恢复,平滑动画 |
| PDF预览 | ✅ | 全平台支持, 支持缩放, 选中文字复制等 |
| 音频预览 | ✅ | 全平台支持流式播放, 算好看的播放器UI, 进度条, 暂停, 快进/退10秒 |
| 视频预览 | ✅ | 全平台支持流式播放, 暂停, 调整音量, 全屏, 增加倍速支持 |
| 文本预览 | ✅ | 全平台支持, 189中语言代码高亮, SourceCodePro等宽字体, 一键复制 |
| MD预览 | ✅ | 全平台支持, 类github风格, TOC, 暗色模式支持.(dark缺陷) |
> 音视频预览库底层是 mpv 提供编解码能力, 理论上 mpv 支持的格式均支持, 具体没有实测;
>
> (待改进)视频预览进度条内嵌: MaterialVideoControlsTheme -> MaterialVideoControls
>
> 文本预览现在是一次性渲染, 大文件会有性能问题, 如果借用listview来优化, 会丢失代码高亮, 暂时保持现阶段的情况, 另外应该也没啥大文本文件预览的场景
### 🚧 开发中
| 功能模块 | 进度 | 说明 |
|---------------|------|-----------------------------|
| ~~文件预览~~ | ✅ | 图片/文档/视频等预览(核心功能基本完成) |
| ~~设置页面~~ | ✅ | 用户信息, 2FA等 |
| ~~桌面端托盘~~ | ✅ | 桌面端托盘 |
| ~~桌面端原生下载~~ | ✅ | 统一为 `background_downloader` |
| ~~我的页面~~ | ✅ | 我的页面 |
| ~~批量C&M~~ | ✅ | 批量移动/复制 |
| ~~桌面端支持拖拽上传~~ | ✅ | windows/linux 支持拖拽上传到当前文件夹 |
### 📝 待优化
- [x] SnackBar 样式美化 (`oktoast`)
- [x] 错误/提示优化 (重构所有SnackBar为okToast)
- [x] 重构所有开发过程中的`debugPrint``logger`
- [x] Windows/Linux平台使用`background_downloader`替代`flutter_downloader`下载, 增加下载速度显示
- [x] ListView 似乎还是完整重绘, 上传插值似乎也还是在完整重绘
### 🚧 待重构
| 功能模块 | 进度 | 说明 |
|-------------------------|----|---------------------------------------------------------------------------|
| UI | ✅ | windows/linux/Android phone&pad 完整ui重构 |
| `background_downloader` | ✅ | flutter_downloader -> background_downloader 全平台统一下载管理, 支持后台, 断点续传, 自动恢复等 |
| 搜索 | ✅ | 移除旧搜索, 实现新版本支持实时搜索, 搜索历史, 搜索防抖; 优化搜索结果点击跳转 |
| 拖拽上传 | ✅ | Windows/Linux支持拖拽上传 |
---
## 🚀 快速开始
### 环境要求
- Flutter SDK >= 3.41.6
- Dart SDK >= 3.11.4
- 公云存储 后端服务
### 安装依赖
```bash
flutter pub get
```
### 运行项目
```bash
flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,自行解决网络问题
```
### 构建发布
```bash
# Android
flutter build apk --release
# Linux
flutter build -d linux --release
# windows
flutter build -d windows --release
```
---
## 📬 联系方式
- 📧 问题反馈:提交 Issue
- 💬 讨论交流:无
---
## ⚖️ 开源协议 (License)
本项目采用 **AGPL-3.0 (GNU Affero General Public License v3.0)** 协议开源。
### 核心约束:
1. **传染性**:如果你修改了本项目代码并重新发布,你的项目也必须以 AGPL-3.0 协议开源。
2. **云端公开声明**:如果你在服务器/云真机等上运行本项目并向公众提供网络服务(网盘服务),你必须向用户公开你所使用的源代码(包括任何修改)。
3. **禁止闭源商业化**:未经授权,禁止将本项目代码闭源后作为商业产品销售。
详情请参阅项目根目录下的 [LICENSE](./LICENSE) 文件。
---
+26 -6
View File
@@ -30,6 +30,14 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile)) keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} }
val releaseStoreFile = keystoreProperties.getProperty("storeFile")?.let { rootProject.file(it) }
val hasReleaseSigning = listOf(
"keyAlias",
"keyPassword",
"storePassword",
).all { !keystoreProperties.getProperty(it).isNullOrBlank() } &&
releaseStoreFile?.exists() == true
android { android {
namespace = "com.limo.cloudreve4_flutter" namespace = "com.limo.cloudreve4_flutter"
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36) compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
@@ -38,10 +46,12 @@ android {
// 2. 配置签名选项 // 2. 配置签名选项
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias = keystoreProperties["keyAlias"] as String? if (hasReleaseSigning) {
keyPassword = keystoreProperties["keyPassword"] as String? keyAlias = keystoreProperties.getProperty("keyAlias")
storeFile = keystoreProperties["storeFile"]?.let { file(it) } keyPassword = keystoreProperties.getProperty("keyPassword")
storePassword = keystoreProperties["storePassword"] as String? storeFile = releaseStoreFile
storePassword = keystoreProperties.getProperty("storePassword")
}
} }
} }
@@ -54,15 +64,25 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = JavaVersion.VERSION_17.toString()
} }
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.limo.cloudreve4_flutter" applicationId = "com.limo.cloudreve4_flutter"
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = getLocalProperty("flutter.minSdkVersion", 24) minSdk = getLocalProperty("flutter.minSdkVersion", 24)
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35) targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
ndk {
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
}
} }
packaging { packaging {
@@ -76,7 +96,7 @@ android {
buildTypes { buildTypes {
release { release {
// 1. 将原来的 getByName("debug") 替换为 getByName("release") // 1. 将原来的 getByName("debug") 替换为 getByName("release")
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug")
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码 // 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
isMinifyEnabled = true isMinifyEnabled = true
+5 -1
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.workers.max=2
org.gradle.vfs.watch=false
kotlin.compiler.execution.strategy=in-process
kotlin.incremental=false
android.useAndroidX=true android.useAndroidX=true
+16
View File
@@ -27,4 +27,20 @@ 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';
// 公告
static const String siteAnnouncementDismissedFingerprint =
'site_announcement_dismissed_fingerprint';
// 版本更新
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
} }
+27
View File
@@ -0,0 +1,27 @@
import 'dart:io';
class SyncDefaults {
SyncDefaults._();
/// 默认同步目录
static String defaultLocalRoot() {
if (Platform.isWindows) {
return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4';
} else if (Platform.isLinux) {
final xdgDownload =
Platform.environment['XDG_DOWNLOAD_DIR'] ?? ("${Platform.environment['HOME'] ?? ''}/Downloads");
return '$xdgDownload/Cloudreve4';
} else if (Platform.isAndroid) {
return ''; // Android 使用系统相册目录
}
return '';
}
static const String defaultRemoteRoot = 'cloudreve://my';
static const String defaultSyncMode = 'full';
static const String defaultConflictStrategy = 'keep_both';
static const int defaultMaxConcurrentTransfers = 3;
static const int defaultBandwidthLimitKbps = 0;
static const int defaultMaxWorkers = 0; // 0 = CPU 核心数
static const String defaultLogLevel = 'info';
}
+29 -1
View File
@@ -10,6 +10,10 @@ class AppLogger {
static Logger? _logger; static Logger? _logger;
static File? _logFile; static File? _logFile;
/// 当前日志级别(默认 info,debug 模式下也是 info 避免刷屏)
static Level _level = Level.info;
static Level get level => _level;
/// 初始化日志,必须在 main 中 await /// 初始化日志,必须在 main 中 await
static Future<void> init() async { static Future<void> init() async {
if (_logger != null) return; if (_logger != null) return;
@@ -22,6 +26,10 @@ class AppLogger {
} }
_logFile = File(p.join(logDir.path, 'log.txt')); _logFile = File(p.join(logDir.path, 'log.txt'));
_createLogger();
}
static void _createLogger() {
// 2. 配置多路输出:同时输出到控制台和文件 // 2. 配置多路输出:同时输出到控制台和文件
_logger = Logger( _logger = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
@@ -38,10 +46,16 @@ class AppLogger {
file: _logFile!, file: _logFile!,
), ),
]), ]),
filter: ProductionFilter(), filter: _LevelFilter(_level),
); );
} }
/// 运行时切换日志级别
static void setLevel(Level level) {
_level = level;
_createLogger();
}
// 使用 getter 确保 logger 已初始化,防止空指针 // 使用 getter 确保 logger 已初始化,防止空指针
static Logger get _instance { static Logger get _instance {
_logger ??= Logger( _logger ??= Logger(
@@ -67,6 +81,9 @@ class AppLogger {
/// Error 级别日志 /// Error 级别日志
static void e(String message) => _instance.e(message); static void e(String message) => _instance.e(message);
/// Trace 级别日志(高频轮询/查询使用,仅 trace 级别可见)
static void t(String message) => _instance.t(message);
/// Debug 级别日志(支持格式化) /// Debug 级别日志(支持格式化)
static void df(String message, List<Object> args) => _instance.d(message, error: args); static void df(String message, List<Object> args) => _instance.d(message, error: args);
@@ -132,6 +149,17 @@ class AppLogger {
} }
} }
/// 自定义级别过滤器:低于设定级别的日志被过滤
class _LevelFilter extends LogFilter {
final Level minLevel;
_LevelFilter(this.minLevel);
@override
bool shouldLog(LogEvent event) {
return event.level.index >= minLevel.index;
}
}
/// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加 /// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加
class CustomFileOutput extends LogOutput { class CustomFileOutput extends LogOutput {
final File file; final File file;
+151 -30
View File
@@ -1,90 +1,192 @@
/// 文件工具类
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',
'heif',
'avif',
'tif',
'tiff',
]; ];
return imageExtensions.contains(getFileExtension(fileName)); return imageExtensions.contains(getFileExtension(fileName));
} }
/// 判断是否为视频文件 static bool isFlutterRenderableImageFile(String fileName) {
const renderableImageExtensions = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
];
return renderableImageExtensions.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',
'm4v',
'mpg',
'mpeg',
'3gp',
'ts',
'm2ts',
]; ];
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 isPsdFile(String fileName) {
const psdExtensions = ['psd', 'psb'];
return psdExtensions.contains(getFileExtension(fileName));
}
static bool isThumbnailableFile(String fileName) {
final ext = getFileExtension(fileName);
if (ext.isEmpty || ext == 'svg') return false;
return isImageFile(fileName) || isVideoFile(fileName) || isPsdFile(fileName);
}
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 = {
@@ -94,13 +196,33 @@ class FileUtils {
'gif': 'image/gif', 'gif': 'image/gif',
'webp': 'image/webp', 'webp': 'image/webp',
'svg': 'image/svg+xml', 'svg': 'image/svg+xml',
'bmp': 'image/bmp',
'heic': 'image/heic',
'heif': 'image/heif',
'avif': 'image/avif',
'tif': 'image/tiff',
'tiff': 'image/tiff',
'psd': 'image/vnd.adobe.photoshop',
'psb': 'image/vnd.adobe.photoshop',
'mp4': 'video/mp4', 'mp4': 'video/mp4',
'webm': 'video/webm', 'webm': 'video/webm',
'mkv': 'video/x-matroska', 'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo', 'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'flv': 'video/x-flv',
'wmv': 'video/x-ms-wmv',
'm4v': 'video/x-m4v',
'mpg': 'video/mpeg',
'mpeg': 'video/mpeg',
'3gp': 'video/3gpp',
'ts': 'video/mp2t',
'm2ts': 'video/mp2t',
'mp3': 'audio/mpeg', 'mp3': 'audio/mpeg',
'wav': 'audio/wav', 'wav': 'audio/wav',
'ogg': 'audio/ogg', 'ogg': 'audio/ogg',
'flac': 'audio/flac',
'aac': 'audio/aac',
'm4a': 'audio/mp4',
'pdf': 'application/pdf', 'pdf': 'application/pdf',
'txt': 'text/plain', 'txt': 'text/plain',
'json': 'application/json', 'json': 'application/json',
@@ -112,7 +234,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';
+26
View File
@@ -0,0 +1,26 @@
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
/// 通过 Win32 SetEnvironmentVariableW 设置/清除进程环境变量
/// 仅 Windows 有效,其他平台直接返回 false
bool winSetEnvVar(String name, String? value) {
if (!Platform.isWindows) return false;
final dylib = DynamicLibrary.open('kernel32.dll');
final fn = dylib.lookupFunction<
Int32 Function(Pointer<Utf16>, Pointer<Utf16>),
int Function(Pointer<Utf16>, Pointer<Utf16>)>(
'SetEnvironmentVariableW',
);
final namePtr = name.toNativeUtf16();
final valuePtr = value != null ? value.toNativeUtf16() : nullptr;
try {
return fn(namePtr, valuePtr) != 0;
} finally {
calloc.free(namePtr);
if (value != null) calloc.free(valuePtr);
}
}
+19
View File
@@ -0,0 +1,19 @@
class AppUpdateInfo {
final String version;
final String title;
final String? description;
final Uri pageUrl;
final Uri? downloadUrl;
final String? fileName;
final DateTime? publishedAt;
const AppUpdateInfo({
required this.version,
required this.title,
required this.pageUrl,
this.description,
this.downloadUrl,
this.fileName,
this.publishedAt,
});
}
+114
View File
@@ -0,0 +1,114 @@
import '../../src/rust/api/ffi_types.dart' as ffi;
class SyncConfigModel {
final String baseUrl;
final String accessToken;
final String refreshToken;
final String localRoot;
final String remoteRoot;
final String syncMode;
final String conflictStrategy;
final String wcfDeleteMode;
final int maxConcurrentTransfers;
final int bandwidthLimitKbps;
final List<String> excludedPaths;
final int maxWorkers;
final String dataDir;
final String clientId;
final String logLevel;
const SyncConfigModel({
required this.baseUrl,
required this.accessToken,
required this.refreshToken,
required this.localRoot,
required this.dataDir,
required this.clientId,
this.remoteRoot = 'cloudreve://my',
this.syncMode = 'full',
this.conflictStrategy = 'keep_both',
this.wcfDeleteMode = 'wcf_delete_local_only',
this.maxConcurrentTransfers = 3,
this.bandwidthLimitKbps = 0,
this.excludedPaths = const [],
this.maxWorkers = 0,
this.logLevel = 'info',
});
SyncConfigModel copyWith({
String? baseUrl,
String? accessToken,
String? refreshToken,
String? localRoot,
String? remoteRoot,
String? syncMode,
String? conflictStrategy,
String? wcfDeleteMode,
int? maxConcurrentTransfers,
int? bandwidthLimitKbps,
List<String>? excludedPaths,
int? maxWorkers,
String? dataDir,
String? clientId,
String? logLevel,
}) {
return SyncConfigModel(
baseUrl: baseUrl ?? this.baseUrl,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
localRoot: localRoot ?? this.localRoot,
remoteRoot: remoteRoot ?? this.remoteRoot,
syncMode: syncMode ?? this.syncMode,
conflictStrategy: conflictStrategy ?? this.conflictStrategy,
wcfDeleteMode: wcfDeleteMode ?? this.wcfDeleteMode,
maxConcurrentTransfers:
maxConcurrentTransfers ?? this.maxConcurrentTransfers,
bandwidthLimitKbps: bandwidthLimitKbps ?? this.bandwidthLimitKbps,
excludedPaths: excludedPaths ?? this.excludedPaths,
maxWorkers: maxWorkers ?? this.maxWorkers,
dataDir: dataDir ?? this.dataDir,
clientId: clientId ?? this.clientId,
logLevel: logLevel ?? this.logLevel,
);
}
ffi.SyncConfigFfi toFfi() {
return ffi.SyncConfigFfi(
baseUrl: baseUrl,
accessToken: accessToken,
refreshToken: refreshToken,
localRoot: localRoot,
remoteRoot: remoteRoot,
syncMode: syncMode,
conflictStrategy: conflictStrategy,
wcfDeleteMode: wcfDeleteMode,
maxConcurrentTransfers: maxConcurrentTransfers,
bandwidthLimitKbps: BigInt.from(bandwidthLimitKbps),
excludedPaths: excludedPaths,
maxWorkers: maxWorkers,
dataDir: dataDir,
clientId: clientId,
logLevel: logLevel,
);
}
static SyncConfigModel fromFfi(ffi.SyncConfigFfi ffi) {
return SyncConfigModel(
baseUrl: ffi.baseUrl,
accessToken: ffi.accessToken,
refreshToken: ffi.refreshToken,
localRoot: ffi.localRoot,
remoteRoot: ffi.remoteRoot,
syncMode: ffi.syncMode,
conflictStrategy: ffi.conflictStrategy,
wcfDeleteMode: ffi.wcfDeleteMode,
maxConcurrentTransfers: ffi.maxConcurrentTransfers,
bandwidthLimitKbps: ffi.bandwidthLimitKbps.toInt(),
excludedPaths: ffi.excludedPaths,
maxWorkers: ffi.maxWorkers,
dataDir: ffi.dataDir,
clientId: ffi.clientId,
logLevel: ffi.logLevel,
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import '../../src/rust/api/ffi_types.dart' as ffi;
sealed class SyncEventModel {}
class SyncStateChanged extends SyncEventModel {
final String newState;
SyncStateChanged(this.newState);
}
class SyncProgress extends SyncEventModel {
final int synced;
final int total;
final String currentFile;
SyncProgress(this.synced, this.total, this.currentFile);
}
class SyncFileUploaded extends SyncEventModel {
final String localPath;
final String remoteUri;
SyncFileUploaded(this.localPath, this.remoteUri);
}
class SyncFileDownloaded extends SyncEventModel {
final String localPath;
final String remoteUri;
SyncFileDownloaded(this.localPath, this.remoteUri);
}
class SyncConflictDetected extends SyncEventModel {
final String localPath;
final String conflictType;
SyncConflictDetected(this.localPath, this.conflictType);
}
class SyncError extends SyncEventModel {
final String message;
final bool recoverable;
SyncError(this.message, this.recoverable);
}
class SyncTokenExpired extends SyncEventModel {}
class SyncDiskSpaceWarning extends SyncEventModel {
final int availableMb;
SyncDiskSpaceWarning(this.availableMb);
}
class SyncInitialSyncComplete extends SyncEventModel {
final SyncSummaryModel summary;
SyncInitialSyncComplete(this.summary);
}
class SyncSummaryModel {
final int uploaded;
final int downloaded;
final int renamed;
final int moved;
final int conflicts;
final int failed;
final int skipped;
final int deletedLocal;
final int deletedRemote;
final int durationMs;
const SyncSummaryModel({
this.uploaded = 0,
this.downloaded = 0,
this.renamed = 0,
this.moved = 0,
this.conflicts = 0,
this.failed = 0,
this.skipped = 0,
this.deletedLocal = 0,
this.deletedRemote = 0,
this.durationMs = 0,
});
static SyncSummaryModel fromFfi(ffi.SyncSummaryFfi f) {
return SyncSummaryModel(
uploaded: f.uploaded,
downloaded: f.downloaded,
renamed: f.renamed,
moved: f.moved,
conflicts: f.conflicts,
failed: f.failed,
skipped: f.skipped,
deletedLocal: f.deletedLocal,
deletedRemote: f.deletedRemote,
durationMs: f.durationMs.toInt(),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import '../../src/rust/api/ffi_types.dart' as ffi;
class SyncStatusModel {
final String state;
final int syncedFiles;
final int totalFiles;
final int uploadingCount;
final int downloadingCount;
final int conflictCount;
final int errorCount;
final String? lastSyncTime;
final String? errorMessage;
const SyncStatusModel({
this.state = 'idle',
this.syncedFiles = 0,
this.totalFiles = 0,
this.uploadingCount = 0,
this.downloadingCount = 0,
this.conflictCount = 0,
this.errorCount = 0,
this.lastSyncTime,
this.errorMessage,
});
bool get isIdle => state == 'idle';
bool get isInitializing => state == 'initializing';
bool get isInitialSync => state == 'initialSync';
bool get isContinuous => state == 'continuous';
bool get isPaused => state == 'paused';
bool get isError => state == 'error';
bool get isStopped => state == 'stopped';
bool get isActive => isInitializing || isInitialSync || isContinuous;
static SyncStatusModel fromFfi(ffi.SyncStatusFfi f) {
return SyncStatusModel(
state: f.state,
syncedFiles: f.syncedFiles.toInt(),
totalFiles: f.totalFiles.toInt(),
uploadingCount: f.uploadingCount,
downloadingCount: f.downloadingCount,
conflictCount: f.conflictCount,
errorCount: f.errorCount,
lastSyncTime: f.lastSyncTime,
errorMessage: f.errorMessage,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
class SyncTaskModel {
final String id;
final String trigger;
final int totalCount;
final int completedCount;
final int failedCount;
final String status;
final String createdAt;
final String updatedAt;
final String? finishedAt;
const SyncTaskModel({
required this.id,
required this.trigger,
required this.totalCount,
required this.completedCount,
required this.failedCount,
required this.status,
required this.createdAt,
required this.updatedAt,
this.finishedAt,
});
String get shortId => id.length > 8 ? id.substring(0, 8) : id;
String get triggerLabel => switch (trigger) {
'initial_sync' => '初始同步',
'continuous' => '持续同步',
'manual' => '手动同步',
_ => trigger,
};
String get statusLabel => switch (status) {
'pending' => '等待中',
'running' => '执行中',
'completed' => '已完成',
'failed' => '失败',
'cancelled' => '已取消',
_ => status,
};
double get progress =>
totalCount > 0 ? completedCount / totalCount : 0.0;
}
class SyncTaskItemModel {
final int id;
final String taskId;
final String relativePath;
final String actionType;
final String status;
final int fileSize;
final String? errorMessage;
final String createdAt;
final String updatedAt;
const SyncTaskItemModel({
required this.id,
required this.taskId,
required this.relativePath,
required this.actionType,
required this.status,
required this.fileSize,
this.errorMessage,
required this.createdAt,
required this.updatedAt,
});
String get actionLabel => switch (actionType) {
'upload' => '上传',
'download' => '下载',
'delete_local' => '删本地',
'delete_remote' => '删远程',
'rename' => '重命名',
'move' => '移动',
'mkdir_remote' => '创建远程目录',
'mkdir_local' => '创建本地目录',
'conflict_resolve' => '冲突解决',
'create_placeholder' => '创建占位符',
_ => actionType,
};
String get statusLabel => switch (status) {
'pending' => '未开始',
'running' => '进行中',
'completed' => '已完成',
'failed' => '失败',
'skipped' => '跳过',
_ => status,
};
String get filename {
final parts = relativePath.split('/');
return parts.isNotEmpty ? parts.last : relativePath;
}
}
+36 -5
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:logger/logger.dart' show Level;
import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart'; import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -11,6 +12,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:oktoast/oktoast.dart'; import 'package:oktoast/oktoast.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'config/app_config.dart'; import 'config/app_config.dart';
import 'core/constants/storage_keys.dart';
import 'presentation/providers/auth_provider.dart'; import 'presentation/providers/auth_provider.dart';
import 'presentation/providers/file_manager_provider.dart'; import 'presentation/providers/file_manager_provider.dart';
import 'presentation/providers/navigation_provider.dart'; import 'presentation/providers/navigation_provider.dart';
@@ -19,9 +21,11 @@ import 'presentation/providers/download_manager_provider.dart';
import 'presentation/providers/user_setting_provider.dart'; import 'presentation/providers/user_setting_provider.dart';
import 'presentation/providers/admin_provider.dart'; import 'presentation/providers/admin_provider.dart';
import 'presentation/providers/quick_access_provider.dart'; import 'presentation/providers/quick_access_provider.dart';
import 'presentation/providers/sync_provider.dart';
import 'presentation/providers/theme_provider.dart'; import 'presentation/providers/theme_provider.dart';
import 'services/upload_service.dart'; import 'services/upload_service.dart';
import 'services/api_service.dart'; import 'services/api_service.dart';
import 'services/storage_service.dart';
import 'services/server_service.dart'; import 'services/server_service.dart';
import 'services/cache_manager_service.dart'; import 'services/cache_manager_service.dart';
import 'services/avatar_cache_service.dart'; import 'services/avatar_cache_service.dart';
@@ -29,12 +33,42 @@ import 'core/utils/video_fullscreen.dart';
import 'services/desktop_service.dart'; import 'services/desktop_service.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
import 'presentation/widgets/toast_helper.dart'; import 'presentation/widgets/toast_helper.dart';
import 'presentation/widgets/update_prompt.dart';
import 'src/rust/frb_generated.dart' show RustSyncApi;
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>(); final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
Level _parseLogLevel(String level) {
return switch (level) {
'error' => Level.error,
'warning' => Level.warning,
'info' => Level.info,
'debug' => Level.debug,
'trace' => Level.trace,
_ => Level.info,
};
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await AppLogger.init();
final savedLevel = await StorageService.instance.getString(
StorageKeys.logLevel,
);
if (savedLevel != null) {
final level = _parseLogLevel(savedLevel);
AppLogger.setLevel(level);
}
AppLogger.i("应用启动,日志系统就绪");
try {
await RustSyncApi.init();
AppLogger.i("RustSyncApi 初始化成功");
} catch (e) {
AppLogger.e("RustSyncApi 初始化失败: $e");
}
// 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常 // 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常
// 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略 // 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略
FlutterError.onError = (details) { FlutterError.onError = (details) {
@@ -51,10 +85,6 @@ void main() async {
return false; return false;
}; };
// 初始化日志
await AppLogger.init();
AppLogger.i("应用启动,日志系统就绪");
// 桌面端初始化窗口管理和系统托盘 // 桌面端初始化窗口管理和系统托盘
if (Platform.isWindows || Platform.isLinux) { if (Platform.isWindows || Platform.isLinux) {
// 实例化 FlutterSingleInstance 获取单实例句柄 // 实例化 FlutterSingleInstance 获取单实例句柄
@@ -156,6 +186,7 @@ class CloudreveApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => UserSettingProvider()), ChangeNotifierProvider(create: (_) => UserSettingProvider()),
ChangeNotifierProvider(create: (_) => AdminProvider()), ChangeNotifierProvider(create: (_) => AdminProvider()),
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()), ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
ChangeNotifierProvider(create: (_) => SyncProvider()),
], ],
child: const AppView(), child: const AppView(),
); );
@@ -215,7 +246,7 @@ class AppView extends StatelessWidget {
currentWidget = FilterQualityWidget(child: currentWidget); currentWidget = FilterQualityWidget(child: currentWidget);
} }
// 添加全局错误处理 // 添加全局错误处理
return ErrorHandler(child: currentWidget); return UpdatePrompt(child: ErrorHandler(child: currentWidget));
}, },
), ),
); );
@@ -1,7 +1,59 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/core/utils/win_env.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:webview_flutter/webview_flutter.dart' as mobile;
// ═════════════════════════════════════════════════════
// WebView 代理配置(仅 Windows,无认证)
// ═════════════════════════════════════════════════════
class CaptchaProxyConfig {
final String host;
final int port;
const CaptchaProxyConfig({required this.host, required this.port});
String get proxyArg => '--proxy-server=http://$host:$port';
@override
String toString() => '$host:$port';
}
// ═════════════════════════════════════════════════════
// WebView2 代理环境变量管理
// ═════════════════════════════════════════════════════
const _envVarName = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS';
/// 为 WebView2 设置代理环境变量(进程级,不影响其他程序)
void _applyWebView2Proxy(CaptchaProxyConfig? proxy) {
if (!Platform.isWindows) return;
if (proxy != null) {
final existing = Platform.environment[_envVarName];
final newValue = existing != null && existing.isNotEmpty
? '$existing ${proxy.proxyArg}'
: proxy.proxyArg;
winSetEnvVar(_envVarName, newValue);
AppLogger.i('WebView2 代理环境变量已设置: $newValue');
}
}
/// 清除 WebView2 代理环境变量
void _clearWebView2Proxy() {
if (!Platform.isWindows) return;
winSetEnvVar(_envVarName, null);
AppLogger.i('WebView2 代理环境变量已清除');
}
// ═════════════════════════════════════════════════════
// CaptchaWebConfig
// ═════════════════════════════════════════════════════
class CaptchaWebConfig { class CaptchaWebConfig {
final String type; final String type;
@@ -21,12 +73,20 @@ class CaptchaWebConfig {
const CaptchaWebConfig.recaptchaV2({ const CaptchaWebConfig.recaptchaV2({
required String siteKey, required String siteKey,
String displayName = 'reCAPTCHA V2', String displayName = 'reCAPTCHA V2',
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey); }) : this._(
type: 'recaptcha',
displayName: displayName,
siteKey: siteKey,
);
const CaptchaWebConfig.turnstile({ const CaptchaWebConfig.turnstile({
required String siteKey, required String siteKey,
String displayName = 'Cloudflare Turnstile', String displayName = 'Cloudflare Turnstile',
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey); }) : this._(
type: 'turnstile',
displayName: displayName,
siteKey: siteKey,
);
const CaptchaWebConfig.cap({ const CaptchaWebConfig.cap({
required String instanceUrl, required String instanceUrl,
@@ -34,22 +94,36 @@ class CaptchaWebConfig {
String? assetServer, String? assetServer,
String displayName = 'Cap', String displayName = 'Cap',
}) : this._( }) : this._(
type: 'cap', type: 'cap',
displayName: displayName, displayName: displayName,
instanceUrl: instanceUrl, instanceUrl: instanceUrl,
siteKey: siteKey, siteKey: siteKey,
assetServer: assetServer, assetServer: assetServer,
); );
} }
// ─── 桌面端判断 ───────────────────────────────────────
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
// ─── 桌面 User-Agent ──────────────────────────────────
const _desktopUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
// ═════════════════════════════════════════════════════
// CaptchaChallengePage
// ═════════════════════════════════════════════════════
class CaptchaChallengePage extends StatefulWidget { class CaptchaChallengePage extends StatefulWidget {
final CaptchaWebConfig config; final CaptchaWebConfig config;
final String baseUrl; final String baseUrl;
final CaptchaProxyConfig? proxyConfig;
const CaptchaChallengePage({ const CaptchaChallengePage({
super.key, super.key,
required this.config, required this.config,
required this.baseUrl, required this.baseUrl,
this.proxyConfig,
}); });
@override @override
@@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget {
} }
class _CaptchaChallengePageState extends State<CaptchaChallengePage> { class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
late final WebViewController _controller; // ── 移动端 ──
mobile.WebViewController? _mobileController;
// ── 桌面端 ──
InAppWebViewController? _desktopController;
Key _desktopKey = UniqueKey();
// ── 共享状态 ──
bool _isLoading = true; bool _isLoading = true;
int _progress = 0; int _progress = 0;
String? _errorMessage; String? _errorMessage;
String? _statusText; String? _statusText;
bool _disposed = false;
// ── 是否设置了代理环境变量(用于清理时判断)──
bool _proxyEnvSet = false;
// ── HTML ──
late String _currentHtml;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentHtml = _buildHtml(widget.config);
_controller = WebViewController() // Windows: 在 WebView2 创建前设置代理环境变量
..setJavaScriptMode(JavaScriptMode.unrestricted) if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) {
..setBackgroundColor(Colors.transparent) _applyWebView2Proxy(widget.proxyConfig);
..addJavaScriptChannel( _proxyEnvSet = true;
'CaptchaBridge', }
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) {
if (mounted) setState(() => _progress = progress);
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = '${error.errorCode}: ${error.description}'
.trim();
});
}
},
),
);
_loadCaptcha(); if (!_isDesktop) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel(
'CaptchaBridge',
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
mobile.NavigationDelegate(
onProgress: (progress) {
if (mounted) setState(() => _progress = progress);
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage =
'${error.errorCode}: ${error.description}'.trim();
});
}
},
),
)
..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl);
}
} }
// ─── 加载 / 刷新 ────────────────────────────────────
Future<void> _loadCaptcha() async { Future<void> _loadCaptcha() async {
if (_disposed) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
_statusText = null; _statusText = null;
_progress = 0; _progress = 0;
_currentHtml = _buildHtml(widget.config);
}); });
final html = _buildHtml(widget.config); if (!_isDesktop && _mobileController != null) {
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl); await _mobileController!.loadHtmlString(
_currentHtml,
baseUrl: widget.baseUrl,
);
} else if (_isDesktop) {
setState(() => _desktopKey = UniqueKey());
}
} }
// ─── Bridge 消息处理 ─────────────────────────────────
void _handleBridgeMessage(String rawMessage) { void _handleBridgeMessage(String rawMessage) {
AppLogger.d(
'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}',
);
try { try {
final decoded = jsonDecode(rawMessage); final decoded = jsonDecode(rawMessage);
if (decoded is! Map) return; if (decoded is! Map) return;
@@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
if (type == 'success') { if (type == 'success') {
final token = decoded['token']?.toString() ?? ''; final token = decoded['token']?.toString() ?? '';
if (token.isNotEmpty && mounted) { final jsTs = decoded['_jsTs'];
if (jsTs is num) {
final delayMs = DateTime.now().millisecondsSinceEpoch - jsTs.toInt();
AppLogger.d('Bridge 收到 success, JS→Dart 传输延迟=${delayMs}ms, token长度=${token.length}');
} else {
AppLogger.d('Bridge 收到 success, token长度=${token.length}');
}
if (token.isNotEmpty && mounted && !_disposed) {
_disposed = true;
AppLogger.d('准备 pop 返回登录页');
Navigator.of(context).pop(token); Navigator.of(context).pop(token);
AppLogger.d('pop 完成');
} }
return; return;
} }
@@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
final progress = decoded['progress']?.toString(); final progress = decoded['progress']?.toString();
if (mounted) { if (mounted) {
setState(() { setState(() {
_statusText = progress == null ? '正在验证...' : '正在验证... $progress'; _statusText =
progress == null ? '正在验证...' : '正在验证... $progress';
}); });
} }
return; return;
@@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
return; return;
} }
if (type == 'debug') {
AppLogger.d('JS debug: ${decoded['message']}');
return;
}
if (type == 'expired') { if (type == 'expired') {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
} }
return; return;
} }
} catch (_) { } catch (_) {}
// 忽略非 JSON 消息。 }
// ─── WebView 销毁 ───────────────────────────────────
void _cleanupWebView() {
if (_isDesktop) {
final ctrl = _desktopController;
_desktopController = null;
AppLogger.d('开始清理 WebView controller');
ctrl?.dispose();
AppLogger.d('WebView controller 已 dispose');
if (_proxyEnvSet) {
_clearWebView2Proxy();
_proxyEnvSet = false;
}
} }
} }
@override
void dispose() {
AppLogger.d('CaptchaChallengePage dispose 开始');
_disposed = true;
_cleanupWebView();
super.dispose();
AppLogger.d('CaptchaChallengePage dispose 完成');
}
// ═════════════════════════════════════════════════════
// HTML 生成
// ═════════════════════════════════════════════════════
String _buildHtml(CaptchaWebConfig config) { String _buildHtml(CaptchaWebConfig config) {
switch (config.type) { switch (config.type) {
case 'turnstile': case 'turnstile':
return _buildTurnstileHtml(config.siteKey!); return _baseHtml(
title: 'Cloudflare Turnstile',
body: '<div id="widget"></div>',
script: '''
function onTurnstileLoad() {
try {
turnstile.render('#widget', {
sitekey: '${_js(config.siteKey!)}',
callback: function(token) { solved(token); },
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
'expired-callback': function() { expired(); },
'after-interactive-callback': function() {
sendBridge({ type: 'debug', message: 'after-interactive fired' });
markStatus('正在与 Cloudflare 服务器验证,请稍候...', false);
sendBridge({ type: 'progress', progress: '服务器验证中' });
}
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
case 'recaptcha': case 'recaptcha':
return _buildRecaptchaHtml(config.siteKey!); return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script: '''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(config.siteKey!)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
case 'cap': case 'cap':
return _buildCapHtml( final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
instanceUrl: config.instanceUrl!, final scriptUrl = _capWidgetScript(config.assetServer);
siteKey: config.siteKey!, final safeEndpoint = const HtmlEscape().convert(endpoint);
assetServer: config.assetServer, final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script: '''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
); );
default: default:
return _buildErrorHtml('不支持的验证码类型: ${config.type}'); return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert('不支持的验证码类型: ${config.type}')}</div>',
script: "failed('不支持的验证码类型');",
);
} }
} }
@@ -260,8 +493,14 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
</div> </div>
<script> <script>
function sendBridge(payload) { function sendBridge(payload) {
payload._jsTs = Date.now();
var json = JSON.stringify(payload);
try { try {
CaptchaBridge.postMessage(JSON.stringify(payload)); if (typeof CaptchaBridge !== 'undefined' && CaptchaBridge.postMessage) {
CaptchaBridge.postMessage(json);
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
window.flutter_inappwebview.callHandler('CaptchaBridge', json);
}
} catch (e) {} } catch (e) {}
} }
function markStatus(text, isError) { function markStatus(text, isError) {
@@ -289,139 +528,9 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
'''; ''';
} }
String _buildTurnstileHtml(String siteKey) { // ═════════════════════════════════════════════════════
return _baseHtml( // Widget 构建
title: 'Cloudflare Turnstile', // ═════════════════════════════════════════════════════
body: '<div id="widget"></div>',
script:
'''
function onTurnstileLoad() {
try {
turnstile.render('#widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
'expired-callback': function() { expired(); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildRecaptchaHtml(String siteKey) {
return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script:
'''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildCapHtml({
required String instanceUrl,
required String siteKey,
String? assetServer,
}) {
final endpoint = _capEndpoint(instanceUrl, siteKey);
final scriptUrl = _capWidgetScript(assetServer);
final safeEndpoint = const HtmlEscape().convert(endpoint);
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script:
'''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
);
}
String _buildErrorHtml(String message) {
return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
script: 'failed(${jsonEncode(message)});',
);
}
String _capEndpoint(String instanceUrl, String siteKey) {
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '$trimmedInstance/$trimmedSiteKey/';
}
String _capWidgetScript(String? assetServer) {
final asset = assetServer?.trim();
if (asset != null && asset.isNotEmpty) {
if (asset.startsWith('http://') || asset.startsWith('https://')) {
return asset;
}
if (asset.toLowerCase() == 'jsdelivr') {
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
if (asset.toLowerCase() == 'unpkg') {
return 'https://unpkg.com/cap-widget';
}
}
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
String _js(String input) {
return input
.replaceAll(r'\\', r'\\\\')
.replaceAll("'", r"\\'")
.replaceAll('\\n', r'\\n')
.replaceAll('\\r', r'\\r');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -446,51 +555,173 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
), ),
body: Stack( body: Stack(
children: [ children: [
WebViewWidget(controller: _controller), _isDesktop ? _buildDesktopWebView() : _buildMobileWebView(),
if (_isLoading) const Center(child: CircularProgressIndicator()), if (_isLoading)
const Center(child: CircularProgressIndicator()),
if (_errorMessage != null) if (_errorMessage != null)
Align( _buildOverlayBanner(
alignment: Alignment.bottomCenter, child: Text(
child: SafeArea( _errorMessage!,
child: Container( textAlign: TextAlign.center,
width: double.infinity, style: TextStyle(
margin: const EdgeInsets.all(12), color: Theme.of(context).colorScheme.onErrorContainer,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
), ),
), ),
color: Theme.of(context).colorScheme.errorContainer,
) )
else if (_statusText != null) else if (_statusText != null)
Align( _buildOverlayBanner(
alignment: Alignment.bottomCenter, child: Text(
child: SafeArea( _statusText!,
child: Container( textAlign: TextAlign.center,
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
),
child: Text(_statusText!, textAlign: TextAlign.center),
),
), ),
), ),
], ],
), ),
); );
} }
// ── 移动端 WebView ──────────────────────────────────
Widget _buildMobileWebView() {
return mobile.WebViewWidget(controller: _mobileController!);
}
// ── 桌面端 WebView ──────────────────────────────────
Widget _buildDesktopWebView() {
AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}');
return InAppWebView(
key: _desktopKey,
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
safeBrowsingEnabled: true,
isInspectable: true,
cacheMode: CacheMode.LOAD_NO_CACHE,
supportMultipleWindows: true,
allowUniversalAccessFromFileURLs: true,
allowFileAccessFromFileURLs: true,
userAgent: _desktopUserAgent,
transparentBackground: true,
supportZoom: false,
useHybridComposition: true,
),
onWebViewCreated: (controller) {
_desktopController = controller;
controller.addJavaScriptHandler(
handlerName: 'CaptchaBridge',
callback: (args) {
if (args.isNotEmpty) {
_handleBridgeMessage(args[0].toString());
}
},
);
controller.loadUrl(
urlRequest: URLRequest(
url: WebUri("${widget.baseUrl}/virtual_captcha.html"),
),
);
},
shouldInterceptRequest: (controller, request) async {
if (request.url.toString().contains('virtual_captcha.html')) {
AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML');
return WebResourceResponse(
contentType: 'text/html',
contentEncoding: 'utf-8',
data: Uint8List.fromList(utf8.encode(_currentHtml)),
statusCode: 200,
reasonPhrase: 'OK',
);
}
if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') {
AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404');
await Future.delayed(Duration(seconds: 2));
return WebResourceResponse(
contentType: 'text/plain',
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List(0),
);
}
return null;
},
onLoadStart: (controller, url) {},
onLoadStop: (controller, url) {
if (mounted) setState(() => _isLoading = false);
},
onProgressChanged: (controller, progress) {
if (mounted) setState(() => _progress = progress);
},
onReceivedError: (controller, request, error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = '${error.type}: ${error.description}'.trim();
});
}
},
);
}
// ── 通用底部提示条 ──────────────────────────────────
Widget _buildOverlayBanner({required Widget child, Color? color}) {
return Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color ??
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
),
child: child,
),
),
);
}
// ═════════════════════════════════════════════════════
// 辅助
// ═════════════════════════════════════════════════════
String _capEndpoint(String instanceUrl, String siteKey) {
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '$trimmedInstance/$trimmedSiteKey/';
}
String _capWidgetScript(String? assetServer) {
final asset = assetServer?.trim();
if (asset != null && asset.isNotEmpty) {
if (asset.startsWith('http://') || asset.startsWith('https://')) {
return asset;
}
if (asset.toLowerCase() == 'jsdelivr') {
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
if (asset.toLowerCase() == 'unpkg') {
return 'https://unpkg.com/cap-widget';
}
}
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
String _js(String input) {
return input
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll('\n', r'\n')
.replaceAll('\r', r'\r');
}
} }
@@ -1,11 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
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_type_utils.dart'; import '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart'; import '../../../router/app_router.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../providers/file_manager_provider.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
import '../../widgets/selection_toolbar.dart';
import '../../widgets/thumbnail_image.dart'; import '../../widgets/thumbnail_image.dart';
import '../../widgets/toast_helper.dart'; import '../../widgets/toast_helper.dart';
@@ -46,23 +52,34 @@ class CategoryFilesPageArgs {
class CategoryFilesPage extends StatefulWidget { class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args; final CategoryFilesPageArgs args;
const CategoryFilesPage({super.key, required this.args}); const CategoryFilesPage({
super.key,
required this.args,
});
@override @override
State<CategoryFilesPage> createState() => _CategoryFilesPageState(); State<CategoryFilesPage> createState() => _CategoryFilesPageState();
} }
class _CategoryFilesPageState extends State<CategoryFilesPage> { class _CategoryFilesPageState extends State<CategoryFilesPage>
with TickerProviderStateMixin {
final _fileService = FileService(); final _fileService = FileService();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final List<FileModel> _files = []; final List<FileModel> _files = [];
final Set<String> _selectedFilePaths = <String>{};
String? _nextPageToken; String? _nextPageToken;
String? _contextHint; String? _contextHint;
bool _isLoading = true; bool _isLoading = true;
bool _isLoadingMore = false; bool _isLoadingMore = false;
String? _errorMessage; String? _errorMessage;
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
List<FileModel> get _selectedFiles => _files
.where((file) => _selectedFilePaths.contains(file.path))
.toList(growable: false);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -74,6 +91,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
void didUpdateWidget(covariant CategoryFilesPage oldWidget) { void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.args.category != widget.args.category) { if (oldWidget.args.category != widget.args.category) {
_clearSelection();
_loadFiles(refresh: true); _loadFiles(refresh: true);
} }
} }
@@ -102,6 +120,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
_nextPageToken = null; _nextPageToken = null;
_contextHint = null; _contextHint = null;
_files.clear(); _files.clear();
_selectedFilePaths.clear();
}); });
} else { } else {
setState(() { setState(() {
@@ -118,8 +137,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
); );
final filesData = response['files'] as List<dynamic>? ?? const []; final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination = final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>)) .map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile) .where((file) => file.isFile)
@@ -134,9 +152,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
..addAll(newFiles); ..addAll(newFiles);
} else { } else {
final existingIds = _files.map((e) => e.id).toSet(); final existingIds = _files.map((e) => e.id).toSet();
_files.addAll( _files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
newFiles.where((file) => !existingIds.contains(file.id)),
);
} }
_nextPageToken = pagination['next_token'] as String?; _nextPageToken = pagination['next_token'] as String?;
@@ -156,22 +172,149 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
Future<void> _refresh() => _loadFiles(refresh: true); Future<void> _refresh() => _loadFiles(refresh: true);
void _toggleSelection(FileModel file) {
HapticFeedback.selectionClick();
setState(() {
if (_selectedFilePaths.contains(file.path)) {
_selectedFilePaths.remove(file.path);
} else {
_selectedFilePaths.add(file.path);
}
});
}
void _clearSelection() {
if (_selectedFilePaths.isEmpty) return;
setState(_selectedFilePaths.clear);
}
void _selectAllVisible() {
setState(() {
_selectedFilePaths
..clear()
..addAll(_files.map((file) => file.path));
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope(
canPop: !_hasSelection,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _hasSelection) {
_clearSelection();
}
},
child: Scaffold(
appBar: _buildAppBar(context),
body: RefreshIndicator(
onRefresh: _refresh,
child: _buildBody(context),
),
bottomNavigationBar: _buildSelectionBottomBar(context),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
final args = widget.args; final args = widget.args;
return Scaffold( if (_hasSelection) {
appBar: AppBar( return AppBar(
title: Text(args.title), automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(LucideIcons.x),
tooltip: '取消选择',
onPressed: _clearSelection,
),
centerTitle: true,
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
actions: [ actions: [
IconButton( TextButton(
icon: const Icon(LucideIcons.refreshCw), onPressed: _selectAllVisible,
tooltip: '刷新', child: const Text('全选'),
onPressed: _refresh,
), ),
], ],
);
}
return AppBar(
title: Text(args.title),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
onPressed: _refresh,
),
],
);
}
Widget _buildSelectionBottomBar(BuildContext context) {
final selected = _selectedFiles;
final singleSelected = selected.length == 1 ? selected.first : null;
return AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
...previousChildren,
?currentChild,
],
);
},
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final slide = Tween<Offset>(
begin: const Offset(0, 0.10),
end: Offset.zero,
).animate(curved);
return FadeTransition(
opacity: curved,
child: SlideTransition(position: slide, child: child),
);
},
child: _hasSelection
? SelectionToolbar(
key: const ValueKey('category-selection-toolbar'),
selectionCount: _selectedFilePaths.length,
totalCount: _files.length,
onCancel: _clearSelection,
onSelectAll: _selectAllVisible,
onMore: singleSelected == null
? null
: () => _showSelectionMore(context, singleSelected),
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
onDelete: () => _deleteSelectedFiles(context, selected),
)
: const SizedBox.shrink(
key: ValueKey('category-selection-toolbar-empty'),
),
), ),
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
); );
} }
@@ -216,7 +359,11 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25), SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(widget.args.icon, size: 52, color: widget.args.color), Icon(
widget.args.icon,
size: 52,
color: widget.args.color,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Center( Center(
child: Text( child: Text(
@@ -236,7 +383,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
final horizontalPadding = width >= 720 ? 16.0 : 10.0; final horizontalPadding = width >= 720 ? 16.0 : 10.0;
final columnWidth = final columnWidth =
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) / (width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
columnCount; columnCount;
final columns = List.generate(columnCount, (_) => <FileModel>[]); final columns = List.generate(columnCount, (_) => <FileModel>[]);
final heights = List.generate(columnCount, (_) => 0.0); final heights = List.generate(columnCount, (_) => 0.0);
@@ -244,8 +391,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
for (final file in _files) { for (final file in _files) {
final targetIndex = _indexOfMin(heights); final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file); columns[targetIndex].add(file);
heights[targetIndex] += heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
_estimatedTileHeight(file, columnWidth) + spacing;
} }
return ListView( return ListView(
@@ -255,7 +401,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
horizontalPadding, horizontalPadding,
10, 10,
horizontalPadding, horizontalPadding,
24, _hasSelection ? 92 : 24,
), ),
children: [ children: [
_buildSummaryHeader(context), _buildSummaryHeader(context),
@@ -271,11 +417,22 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
Padding( Padding(
padding: EdgeInsets.only(bottom: spacing), padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile( child: _CategoryFileTile(
key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'),
file: file, file: file,
contextHint: _contextHint, contextHint: _contextHint,
category: widget.args.category, category: widget.args.category,
accentColor: widget.args.color, accentColor: widget.args.color,
onTap: () => _openFile(context, file), isSelected: _selectedFilePaths.contains(file.path),
selectionMode: _hasSelection,
onTap: () {
if (_hasSelection) {
_toggleSelection(file);
} else {
_openFile(context, file);
}
},
onLongPress: () => _toggleSelection(file),
onSelect: () => _toggleSelection(file),
), ),
), ),
], ],
@@ -379,19 +536,147 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
} 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( Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) { } else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of( Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else { } else {
ToastHelper.info( ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
} }
} }
Future<void> _deleteSelectedFiles(
BuildContext context,
List<FileModel> selectedFiles,
) async {
if (selectedFiles.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除确认'),
content: Text('确定删除这 ${selectedFiles.length} 个文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _fileService.deleteFiles(
uris: selectedFiles.map((file) => file.path).toList(),
);
if (!mounted) return;
setState(() {
final selectedPaths = selectedFiles.map((file) => file.path).toSet();
_files.removeWhere((file) => selectedPaths.contains(file.path));
_selectedFilePaths.clear();
});
ToastHelper.success('删除成功');
} catch (e) {
if (context.mounted) {
ToastHelper.failure('删除失败: $e');
}
}
}
Future<void> _renameFile(BuildContext context, FileModel file) async {
final controller = TextEditingController(text: file.name);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('重命名'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '新名称',
prefixIcon: Icon(LucideIcons.edit3, size: 20),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('确定'),
),
],
),
);
final newName = controller.text.trim();
if (confirmed != true || newName.isEmpty || newName == file.name) return;
try {
final response = await _fileService.renameFile(
uri: file.path,
newName: newName,
);
if (!mounted) return;
if (response.isEmpty) {
await _refresh();
} else {
final updatedFile = FileModel.fromJson(response);
setState(() {
final index = _files.indexWhere((item) => item.path == file.path);
if (index != -1) _files[index] = updatedFile;
_selectedFilePaths
..remove(file.path)
..add(updatedFile.path);
});
}
ToastHelper.success('重命名成功');
} catch (e) {
if (context.mounted) {
ToastHelper.failure('重命名失败: $e');
}
}
}
void _showSelectionMore(BuildContext context, FileModel file) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
_renameFile(context, file);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('查看详情'),
onTap: () {
Navigator.of(sheetContext).pop();
FileInfoPanel.showAsBottomSheet(context, file);
},
),
],
),
),
);
}
} }
class _CategoryFileTile extends StatelessWidget { class _CategoryFileTile extends StatelessWidget {
@@ -399,14 +684,23 @@ class _CategoryFileTile extends StatelessWidget {
final String? contextHint; final String? contextHint;
final String category; final String category;
final Color accentColor; final Color accentColor;
final bool isSelected;
final bool selectionMode;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onLongPress;
final VoidCallback onSelect;
const _CategoryFileTile({ const _CategoryFileTile({
super.key,
required this.file, required this.file,
required this.contextHint, required this.contextHint,
required this.category, required this.category,
required this.accentColor, required this.accentColor,
required this.isSelected,
required this.selectionMode,
required this.onTap, required this.onTap,
required this.onLongPress,
required this.onSelect,
}); });
@override @override
@@ -419,73 +713,128 @@ class _CategoryFileTile extends StatelessWidget {
final ext = FileTypeUtils.getExtension(file.name); final ext = FileTypeUtils.getExtension(file.name);
final isPsd = ext == 'psd' || ext == 'psb'; final isPsd = ext == 'psd' || ext == 'psb';
return Material( final borderColor = isSelected
color: theme.colorScheme.surfaceContainerLow, ? theme.colorScheme.primary
borderRadius: BorderRadius.circular(14), : theme.dividerColor.withValues(alpha: 0.12);
clipBehavior: Clip.antiAlias,
child: InkWell( final showSelectionCircle = selectionMode || isSelected;
onTap: onTap,
child: DecoratedBox( return RepaintBoundary(
decoration: BoxDecoration( child: AnimatedScale(
border: Border.all( duration: const Duration(milliseconds: 150),
color: theme.dividerColor.withValues(alpha: 0.12), curve: Curves.easeOutCubic,
scale: isSelected ? 0.985 : 1.0,
child: Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: isMedia || isPsd ? 1 : 1.45,
child: Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: ThumbnailImage(
file: file,
contextHint: contextHint,
borderRadius: 0,
),
),
Positioned(
top: 7,
left: 7,
child: _TypeBadge(
icon: _badgeIcon(),
label: _badgeLabel(ext),
color: accentColor,
compact: isMedia,
),
),
if (category == 'video')
const Center(
child: _PlayOverlay(),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: isAudio || isDocument ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
Positioned.fill(
child: IgnorePointer(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: borderColor,
width: isSelected ? 2.2 : 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary
.withValues(alpha: 0.12),
blurRadius: 10,
spreadRadius: 0.5,
),
]
: const [],
),
),
),
),
Positioned(
top: 7,
right: 7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 130),
opacity: showSelectionCircle ? 1 : 0,
child: IgnorePointer(
ignoring: !showSelectionCircle,
child: _SelectionCircle(
selected: isSelected,
onTap: onSelect,
),
),
),
),
],
), ),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: isMedia || isPsd ? 1 : 1.45,
child: Stack(
fit: StackFit.expand,
children: [
ThumbnailImage(
file: file,
contextHint: contextHint,
borderRadius: 0,
),
Positioned(
top: 7,
left: 7,
child: _TypeBadge(
icon: _badgeIcon(),
label: _badgeLabel(ext),
color: accentColor,
compact: isMedia,
),
),
if (category == 'video')
const Center(child: _PlayOverlay()),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: isAudio || isDocument ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
), ),
), ),
), ),
@@ -524,6 +873,60 @@ class _CategoryFileTile extends StatelessWidget {
} }
} }
class _SelectionCircle extends StatelessWidget {
final bool selected;
final VoidCallback? onTap;
const _SelectionCircle({
required this.selected,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.86),
border: Border.all(
color: selected
? colorScheme.primary
: colorScheme.outline.withValues(alpha: 0.42),
width: 1.4,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Icon(
LucideIcons.check,
color: Colors.white,
size: 16,
)
: null,
),
),
);
}
}
class _TypeBadge extends StatelessWidget { class _TypeBadge extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;
@@ -545,7 +948,10 @@ class _TypeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
), ),
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4), padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 7,
vertical: 4,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -580,7 +986,11 @@ class _PlayOverlay extends StatelessWidget {
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(10), padding: EdgeInsets.all(10),
child: Icon(LucideIcons.play, color: Colors.white, size: 22), child: Icon(
LucideIcons.play,
color: Colors.white,
size: 22,
),
), ),
); );
} }
+116 -7
View File
@@ -101,6 +101,38 @@ class _FilesPageState extends State<FilesPage> {
}); });
} }
void _showSelectionMore(
FileModel file,
FileManagerProvider fileManager,
) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(context, fileManager, file);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('查看详情'),
onTap: () {
Navigator.of(sheetContext).pop();
_showFileInfo(file);
},
),
],
),
),
);
}
// ---- FAB 显隐控制 ---- // ---- FAB 显隐控制 ----
void _hideFab() { void _hideFab() {
@@ -168,8 +200,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,6 +209,65 @@ class _FilesPageState extends State<FilesPage> {
); );
} }
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
String _decodePathSegment(String segment) {
var decoded = segment;
for (var i = 0; i < 5; i++) {
try {
final next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
} catch (_) {
break;
}
}
return decoded;
}
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) { Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@@ -187,6 +277,7 @@ class _FilesPageState extends State<FilesPage> {
return SizedBox( return SizedBox(
height: 40, height: 40,
child: ListView( child: ListView(
key: const PageStorageKey('mobile_breadcrumb'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
_buildBreadcrumbChip( _buildBreadcrumbChip(
@@ -203,7 +294,7 @@ class _FilesPageState extends State<FilesPage> {
), ),
_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,
@@ -702,6 +793,9 @@ class _FilesPageState extends State<FilesPage> {
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification, onNotification: _onScrollNotification,
child: ListView.builder( child: ListView.builder(
key: PageStorageKey('files_list_${fileManager.currentPath}'),
cacheExtent: 900,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: fileManager.files.length, itemCount: fileManager.files.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final file = fileManager.files[index]; final file = fileManager.files[index];
@@ -729,6 +823,7 @@ 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 ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
@@ -771,6 +866,9 @@ class _FilesPageState extends State<FilesPage> {
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification, onNotification: _onScrollNotification,
child: GridView.builder( child: GridView.builder(
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
cacheExtent: 1100,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
@@ -804,6 +902,7 @@ 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 ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
@@ -826,14 +925,15 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.hasSelection) { if (fileManager.hasSelection) {
return SelectionToolbar( return SelectionToolbar(
selectionCount: fileManager.selectedFiles.length, selectionCount: fileManager.selectedFiles.length,
totalCount: fileManager.files.length,
onCancel: () => fileManager.clearSelection(), onCancel: () => fileManager.clearSelection(),
onRename: fileManager.selectedFiles.length == 1 onSelectAll: () => fileManager.selectAll(),
? () => FileOperationDialogs.showRenameDialog( onMore: fileManager.selectedFiles.length == 1
context, ? () => _showSelectionMore(
fileManager,
fileManager.files.firstWhere( fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first, (f) => f.path == fileManager.selectedFiles.first,
), ),
fileManager,
) )
: null, : null,
onMove: () => FileOperationDialogs.showBatchMoveDialog( onMove: () => FileOperationDialogs.showBatchMoveDialog(
@@ -935,4 +1035,13 @@ class _FilesPageState extends State<FilesPage> {
} }
} }
} }
void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(context).pushNamed(
RouteNames.cloudreveFileApp,
arguments: {
'file': file,
},
);
}
} }
@@ -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,
),
), ),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -0,0 +1,355 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
import '../../../data/models/file_model.dart';
import '../../../services/api_service.dart';
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
/// Cloudreve Web
///
/// file_viewers viewerSession
/// Cloudreve `/home?path=...&open=...`
/// 使WOPIMarkdownEPUB
class CloudreveFileAppPage extends StatefulWidget {
final FileModel file;
final String? preferredAction;
const CloudreveFileAppPage({
super.key,
required this.file,
this.preferredAction,
});
@override
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
}
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
bool _isPreparing = true;
bool _sessionInjected = false;
int _progress = 0;
String? _error;
late Uri _targetUri;
late Uri _originUri;
String? _sessionStateJson;
@override
void initState() {
super.initState();
if (!_useDesktopWebView) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
mobile.NavigationDelegate(
onProgress: (progress) {
if (!mounted) return;
setState(() => _progress = progress);
},
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
onWebResourceError: (error) {
if (!mounted) return;
if (error.isForMainFrame == true) {
setState(() {
_error = '${error.errorCode}: ${error.description}';
});
}
},
),
);
}
_prepareAndOpen();
}
@override
void dispose() {
_desktopController?.dispose();
super.dispose();
}
Future<void> _prepareAndOpen() async {
setState(() {
_isPreparing = true;
_error = null;
_progress = 0;
_sessionInjected = false;
if (_useDesktopWebView) {
_desktopController = null;
}
});
try {
_originUri = _buildOriginUri();
_targetUri = _buildCloudreveHomeUri();
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
// localStorage
// Cloudreve App access_token
await _loadOrigin();
if (!mounted) return;
setState(() => _isPreparing = false);
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isPreparing = false;
});
}
}
Uri _buildOriginUri() {
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
return Uri(
scheme: base.scheme,
host: base.host,
port: base.hasPort ? base.port : null,
path: '/',
);
}
Uri _buildCloudreveHomeUri() {
final parent = _parentUri(widget.file.relativePath);
final openTarget = widget.file.id.isNotEmpty
? widget.file.id
: widget.file.relativePath;
return Uri(
scheme: _originUri.scheme,
host: _originUri.host,
port: _originUri.hasPort ? _originUri.port : null,
path: '/home',
queryParameters: {
'path': parent,
'open': openTarget,
'size': widget.file.size.toString(),
},
);
}
String _parentUri(String uri) {
final normalized = uri.endsWith('/')
? uri.substring(0, uri.length - 1)
: uri;
final index = normalized.lastIndexOf('/');
if (index <= 'cloudreve://my'.length) {
return 'cloudreve://my';
}
return normalized.substring(0, index);
}
Future<String?> _buildCloudreveFrontendSessionJson() async {
final tokenGetter = ApiService.instance.getTokenCallback;
final token = tokenGetter == null ? null : await tokenGetter();
if (token == null || token.isEmpty) {
return null;
}
Map<String, dynamic>? user;
try {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/me',
);
user = Map<String, dynamic>.from(response);
} catch (_) {
user = null;
}
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
final now = DateTime.now().toUtc();
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
final sessionState = {
'current': userId,
'sessions': {
userId: {
'user': user ?? {'id': userId},
'token': {
'access_token': token,
// App refresh token
// Cloudreve session WebView
'refresh_token': '',
'access_expires': accessExpires,
'refresh_expires': refreshExpires,
},
'settings': {},
},
},
'anonymousSettings': {},
};
return jsonEncode(sessionState);
}
Future<void> _injectSessionAndOpenIfNeeded() async {
if (_sessionInjected) return;
_sessionInjected = true;
final sessionJson = _sessionStateJson;
final target = _targetUri.toString();
try {
if (sessionJson != null) {
final script =
'''
try {
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
} catch (e) {}
window.location.replace(${jsonEncode(target)});
''';
await _runJavaScript(script);
} else {
await _loadTarget();
}
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _openTargetAgain() async {
_sessionInjected = true;
await _loadTarget();
}
Future<void> _reloadWebView() async {
if (_useDesktopWebView) {
await _desktopController?.reload();
} else {
await _mobileController?.reload();
}
}
Future<void> _loadOrigin() async {
if (_useDesktopWebView) {
final controller = _desktopController;
if (controller == null) return;
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
);
} else {
await _mobileController!.loadRequest(_originUri);
}
}
Future<void> _loadTarget() async {
if (_useDesktopWebView) {
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
);
} else {
await _mobileController!.loadRequest(_targetUri);
}
}
Future<void> _runJavaScript(String source) async {
if (_useDesktopWebView) {
await _desktopController?.evaluateJavascript(source: source);
} else {
await _mobileController!.runJavaScript(source);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: '重新打开',
icon: const Icon(Icons.open_in_browser),
onPressed: _openTargetAgain,
),
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh),
onPressed: _reloadWebView,
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(3),
child: _progress > 0 && _progress < 100
? LinearProgressIndicator(value: _progress / 100)
: const SizedBox(height: 3),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isPreparing) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 56, color: Colors.red),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _prepareAndOpen,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
);
}
if (_useDesktopWebView) {
return desktop.InAppWebView(
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
isInspectable: true,
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
supportMultipleWindows: true,
transparentBackground: true,
supportZoom: true,
useHybridComposition: true,
),
onWebViewCreated: (controller) async {
_desktopController = controller;
if (!_isPreparing) {
await _loadOrigin();
}
},
onProgressChanged: (_, progress) {
if (!mounted) return;
setState(() => _progress = progress);
},
onLoadStop: (_, url) => _injectSessionAndOpenIfNeeded(),
onReceivedError: (_, request, error) {
if (!mounted || request.isForMainFrame != true) return;
setState(() => _error = '${error.type}: ${error.description}');
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
}
}
@@ -1,27 +1,47 @@
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(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav), _QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload), _QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin), _QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
_QuickFunction(
icon: LucideIcons.refreshCw,
label: '文件同步',
onTap: (ctx) {
final nav = ctx.read<NavigationProvider>();
// Tabindex 4
final isDesktop = defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
if (isDesktop) {
nav.setIndex(4);
} else {
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
}
},
),
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings), _QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
]; ];
@@ -70,7 +90,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(),
@@ -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),
Flexible(child: 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)}');
}
}
} }
@@ -0,0 +1,800 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import '../../widgets/desktop_constrained.dart';
import '../../widgets/toast_helper.dart';
enum _NetworkTestTab { network, service, custom }
class NetworkTestPage extends StatefulWidget {
const NetworkTestPage({super.key});
@override
State<NetworkTestPage> createState() => _NetworkTestPageState();
}
class _NetworkTestPageState extends State<NetworkTestPage> {
static final _panUri = Uri.parse('https://pan.gongyun.org');
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
static const _probeHeaders = {'Cache-Control': 'no-cache'};
static const _probeGetFallbackHeaders = {
'Cache-Control': 'no-cache',
'Range': 'bytes=0-0',
};
_NetworkTestTab _tab = _NetworkTestTab.network;
bool _networkLoading = false;
bool _serviceLoading = false;
_NetworkDiagnostics? _networkDiagnostics;
_ServiceDiagnostics? _serviceDiagnostics;
String? _networkError;
String? _serviceError;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('网络测试'),
actions: [
IconButton(
tooltip: '说明',
icon: const Icon(Icons.info_outline),
onPressed: () => _openExternalUrl(_infoUri),
),
],
),
body: DesktopConstrained(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: SizedBox(
width: double.infinity,
child: SegmentedButton<_NetworkTestTab>(
segments: const [
ButtonSegment(
value: _NetworkTestTab.network,
icon: Icon(Icons.public),
label: Text('网络'),
),
ButtonSegment(
value: _NetworkTestTab.service,
icon: Icon(Icons.dns_outlined),
label: Text('服务'),
),
ButtonSegment(
value: _NetworkTestTab.custom,
icon: Icon(Icons.route_outlined),
label: Text('自定义线路'),
),
],
selected: {_tab},
onSelectionChanged: (selection) {
final selected = selection.first;
if (selected == _NetworkTestTab.custom) {
_showCustomUnavailableDialog();
return;
}
setState(() => _tab = selected);
if (selected == _NetworkTestTab.network &&
_networkDiagnostics == null &&
!_networkLoading) {
_runNetworkTest();
}
if (selected == _NetworkTestTab.service &&
_serviceDiagnostics == null &&
!_serviceLoading) {
_runServiceTest();
}
},
),
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: switch (_tab) {
_NetworkTestTab.network => _NetworkPanel(
key: const ValueKey('network'),
loading: _networkLoading,
error: _networkError,
diagnostics: _networkDiagnostics,
onRefresh: _runNetworkTest,
),
_NetworkTestTab.service => _ServicePanel(
key: const ValueKey('service'),
loading: _serviceLoading,
error: _serviceError,
diagnostics: _serviceDiagnostics,
onRefresh: _runServiceTest,
),
_NetworkTestTab.custom => const SizedBox.shrink(),
},
),
),
],
),
),
);
}
Future<void> _showCustomUnavailableDialog() async {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('自定义线路'),
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
actions: [
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text(''),
),
],
),
);
}
Future<void> _openExternalUrl(Uri uri) async {
try {
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!opened) {
ToastHelper.failure('无法打开链接: $uri');
}
} catch (e) {
ToastHelper.failure('无法打开链接: $e');
}
}
Future<void> _runNetworkTest() async {
if (_networkLoading) return;
setState(() {
_networkLoading = true;
_networkError = null;
});
try {
final ipFuture = _fetchIpInfo();
final panProbe = await _probeUri(_panUri);
final stability = await _testStability(_panUri);
final ipInfo = await ipFuture;
if (!mounted) return;
setState(() {
_networkDiagnostics = _NetworkDiagnostics(
panReachable: panProbe.reachable,
latencyMs: panProbe.latencyMs,
ipInfo: ipInfo,
stable: stability.stable,
sampleCount: stability.sampleCount,
successCount: stability.successCount,
);
});
} catch (e) {
if (mounted) setState(() => _networkError = e.toString());
} finally {
if (mounted) setState(() => _networkLoading = false);
}
}
Future<void> _runServiceTest() async {
if (_serviceLoading) return;
setState(() {
_serviceLoading = true;
_serviceError = null;
});
try {
final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun');
_ServiceItem monitor(String label, int monitorId) {
return _monitorItem(
label: label,
monitorId: monitorId,
heartbeats: monitorHeartbeats,
);
}
final telegramBot = await _probeItem(
label: 'Telegram bot',
uri: Uri.parse('https://bot.mygongyun.com'),
healthyStatusCodes: const {200, 401},
);
final business = <_ServiceItem>[
monitor('官网', 3),
monitor('网盘', 2),
monitor('短链', 37),
monitor('SSO', 39),
telegramBot,
];
final dc = <_ServiceItem>[
monitor('DC3-CN-SZ', 13),
monitor('DC3-CN-MIAMI', 11),
monitor('DC3-CN-NY', 12),
monitor('DC3-CN-LV', 10),
];
final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)];
final storage = <_ServiceItem>[
monitor('DC-S-NA-1', 5),
monitor('DC-S-NA-2', 6),
monitor('DC-S-EU', 7),
monitor('DC-S-EP', 8),
];
final acceleration = <_ServiceItem>[
monitor('香港', 18),
monitor('香港', 19),
monitor('新加坡', 22),
monitor('洛杉矶', 24),
monitor('蒙特利尔', 21),
];
if (!mounted) return;
setState(() {
_serviceDiagnostics = _ServiceDiagnostics(
fetchedAt: DateTime.now(),
business: business,
dc: dc,
backend: backend,
storage: storage,
acceleration: acceleration,
);
});
} catch (e) {
if (mounted) setState(() => _serviceError = e.toString());
} finally {
if (mounted) setState(() => _serviceLoading = false);
}
}
Future<_IpInfo> _fetchIpInfo() async {
try {
final response = await http
.get(_primaryIpLookupUri)
.timeout(const Duration(seconds: 8));
if (response.statusCode >= 200 && response.statusCode < 300) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['success'] != false) {
final connection = data['connection'];
final connectionMap = connection is Map<String, dynamic>
? connection
: <String, dynamic>{};
return _IpInfo(
ip: data['ip']?.toString() ?? '未知',
asn: connectionMap['asn']?.toString() ?? '未知',
operatorName: connectionMap['isp']?.toString().isNotEmpty == true
? connectionMap['isp'].toString()
: (connectionMap['org']?.toString() ?? '未知'),
);
}
}
} catch (_) {
// Fall back to the next public endpoint.
}
try {
final response = await http
.get(_secondaryIpLookupUri)
.timeout(const Duration(seconds: 8));
if (response.statusCode >= 200 && response.statusCode < 300) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return _IpInfo(
ip: data['ip']?.toString() ?? '未知',
asn: data['asn']?.toString() ?? '未知',
operatorName: data['org']?.toString() ?? '未知',
);
}
} catch (_) {
// Handled by the default value below.
}
return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知');
}
_ServiceItem _monitorItem({
required String label,
required int monitorId,
required Map<int, _MonitorHeartbeat> heartbeats,
}) {
final heartbeat = heartbeats[monitorId];
return _ServiceItem(
label: label,
online: heartbeat?.online ?? false,
latencyMs: heartbeat?.pingMs,
);
}
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
String pageId,
) async {
try {
final response = await http
.get(
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
headers: _probeHeaders,
)
.timeout(const Duration(seconds: 10));
if (response.statusCode < 200 || response.statusCode >= 300) {
return const {};
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return const {};
final data = decoded['data'];
if (data is! Map<String, dynamic>) return const {};
final heartbeatList = data['heartbeatList'];
if (heartbeatList is! Map<String, dynamic>) return const {};
final result = <int, _MonitorHeartbeat>{};
for (final entry in heartbeatList.entries) {
final monitorId = int.tryParse(entry.key);
final records = entry.value;
if (monitorId == null || records is! List || records.isEmpty) continue;
final latest = records.last;
if (latest is! Map<String, dynamic>) continue;
final status = _asInt(latest['status']);
final ping = _asInt(latest['ping']);
result[monitorId] = _MonitorHeartbeat(
online: status == 1,
pingMs: ping,
);
}
return result;
} catch (_) {
return const {};
}
}
int? _asInt(Object? value) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '');
}
Future<_ServiceItem> _probeItem({
required String label,
required Uri uri,
Set<int> healthyStatusCodes = const {},
}) async {
final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes);
return _ServiceItem(
label: label,
online: probe.reachable,
latencyMs: probe.latencyMs,
);
}
Future<_ProbeResult> _probeUri(
Uri uri, {
Set<int> healthyStatusCodes = const {},
}) async {
final stopwatch = Stopwatch()..start();
try {
var response = await http
.head(uri, headers: _probeHeaders)
.timeout(const Duration(seconds: 8));
if (_shouldRetryProbeWithGet(response.statusCode)) {
response = await http
.get(uri, headers: _probeGetFallbackHeaders)
.timeout(const Duration(seconds: 8));
}
stopwatch.stop();
final isHealthy = _isHealthyStatus(
response.statusCode,
healthyStatusCodes,
);
return _ProbeResult(
reachable: isHealthy,
latencyMs: stopwatch.elapsedMilliseconds,
);
} catch (_) {
stopwatch.stop();
return const _ProbeResult(reachable: false);
}
}
bool _shouldRetryProbeWithGet(int statusCode) {
return statusCode == 403 || statusCode == 405 || statusCode == 501;
}
bool _isHealthyStatus(int statusCode, Set<int> healthyStatusCodes) {
if (healthyStatusCodes.isNotEmpty) {
return healthyStatusCodes.contains(statusCode);
}
return statusCode >= 200 && statusCode < 500;
}
Future<_StabilityResult> _testStability(Uri uri) async {
final latencies = <int>[];
var successCount = 0;
const sampleCount = 3;
for (var i = 0; i < sampleCount; i++) {
final probe = await _probeUri(uri);
if (probe.reachable) {
successCount += 1;
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
}
if (i < sampleCount - 1) {
await Future<void>.delayed(const Duration(milliseconds: 350));
}
}
final jitter = latencies.isEmpty
? 999999
: latencies.reduce(max) - latencies.reduce(min);
return _StabilityResult(
stable: successCount == sampleCount && jitter < 1500,
sampleCount: sampleCount,
successCount: successCount,
);
}
}
class _NetworkPanel extends StatelessWidget {
final bool loading;
final String? error;
final _NetworkDiagnostics? diagnostics;
final VoidCallback onRefresh;
const _NetworkPanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final data = diagnostics;
final errorColor = Theme.of(context).colorScheme.error;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
children: [
_SectionCard(
title: '网络',
children: [
if (loading) const LinearProgressIndicator(),
if (error != null) _ErrorText(error!),
_InfoRow(
label: '公云存储网盘访问测试',
value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'),
valueColor: _statusColor(context, data?.panReachable),
),
_InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'),
_InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'),
_InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'),
_InfoRow(
label: '连接时延',
value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms',
),
_InfoRow(
label: '长期连接稳定性',
value: data == null
? '检测中'
: '${data.stable ? '稳定' : '不稳定'} '
'(${data.successCount}/${data.sampleCount})',
valueColor: _statusColor(context, data?.stable),
),
],
),
_SectionCard(
title: '说明',
children: const [
_DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'),
_DescriptionLine(
'当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。',
),
_DescriptionLine(
'由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象',
),
_DescriptionLine(
'如若出现网络连接问题,您可将本页面内容向公云存储团队报告,团队会根据相关诊断线索进行排查与优化。',
),
],
),
_SectionCard(
title: '警告',
titleColor: errorColor,
children: [
Text(
'本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!',
style: TextStyle(color: errorColor, fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: loading ? null : onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重新检测'),
),
],
);
}
}
class _ServicePanel extends StatelessWidget {
final bool loading;
final String? error;
final _ServiceDiagnostics? diagnostics;
final VoidCallback onRefresh;
const _ServicePanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final data = diagnostics;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
children: [
_SectionCard(
title: '业务状态',
children: [
if (loading) const LinearProgressIndicator(),
if (error != null) _ErrorText(error!),
..._itemsOrLoading(data?.business),
],
),
_SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)),
_SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)),
_SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)),
_SectionCard(
title: '加速线路',
children: _itemsOrLoading(data?.acceleration, showLatency: true),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: loading ? null : onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重新检测'),
),
],
);
}
List<Widget> _itemsOrLoading(
List<_ServiceItem>? items, {
bool showLatency = false,
}) {
if (items == null) {
return const [_InfoRow(label: '状态', value: '检测中')];
}
return items
.map(
(item) => _InfoRow(
label: item.label,
value: showLatency
? item.latencyLabel
: (item.online ? '正常' : '离线'),
valueColor: item.online ? Colors.green : Colors.red,
),
)
.toList();
}
}
class _SectionCard extends StatelessWidget {
final String title;
final List<Widget> children;
final Color? titleColor;
const _SectionCard({
required this.title,
required this.children,
this.titleColor,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: titleColor,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
...children,
],
),
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
const _InfoRow({required this.label, required this.value, this.valueColor});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
const SizedBox(width: 16),
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _DescriptionLine extends StatelessWidget {
final String text;
const _DescriptionLine(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Text(text),
);
}
}
class _ErrorText extends StatelessWidget {
final String error;
const _ErrorText(this.error);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'检测失败:$error',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
);
}
}
Color? _statusColor(BuildContext context, bool? value) {
if (value == null) return null;
return value ? Colors.green : Theme.of(context).colorScheme.error;
}
class _NetworkDiagnostics {
final bool panReachable;
final int? latencyMs;
final _IpInfo ipInfo;
final bool stable;
final int sampleCount;
final int successCount;
const _NetworkDiagnostics({
required this.panReachable,
required this.latencyMs,
required this.ipInfo,
required this.stable,
required this.sampleCount,
required this.successCount,
});
}
class _IpInfo {
final String ip;
final String asn;
final String operatorName;
const _IpInfo({
required this.ip,
required this.asn,
required this.operatorName,
});
}
class _ServiceDiagnostics {
final DateTime fetchedAt;
final List<_ServiceItem> business;
final List<_ServiceItem> dc;
final List<_ServiceItem> backend;
final List<_ServiceItem> storage;
final List<_ServiceItem> acceleration;
const _ServiceDiagnostics({
required this.fetchedAt,
required this.business,
required this.dc,
required this.backend,
required this.storage,
required this.acceleration,
});
}
class _ServiceItem {
final String label;
final bool online;
final int? latencyMs;
const _ServiceItem({
required this.label,
required this.online,
this.latencyMs,
});
String get latencyLabel {
if (!online) return '离线';
if (latencyMs == null) return '正常 / -- ms';
return '正常 / $latencyMs ms';
}
}
class _MonitorHeartbeat {
final bool online;
final int? pingMs;
const _MonitorHeartbeat({required this.online, this.pingMs});
}
class _ProbeResult {
final bool reachable;
final int? latencyMs;
const _ProbeResult({required this.reachable, this.latencyMs});
}
class _StabilityResult {
final bool stable;
final int sampleCount;
final int successCount;
const _StabilityResult({
required this.stable,
required this.sampleCount,
required this.successCount,
});
}
+441 -156
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../data/models/user_model.dart'; import '../../../data/models/user_model.dart';
import '../../../data/models/user_setting_model.dart'; import '../../../data/models/user_setting_model.dart';
import '../../../services/update_service.dart';
import '../../../services/user_setting_service.dart'; import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart'; import '../../providers/user_setting_provider.dart';
@@ -16,6 +18,8 @@ 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 'network_test_page.dart';
import '../../../router/app_router.dart';
/// ///
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@@ -82,106 +86,121 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
body: DesktopConstrained( body: DesktopConstrained(
child: ListView( child: ListView(
children: [ children: [
_buildProfileCard(context, user, settings, capacity), _buildProfileCard(context, user, settings, capacity),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSection( _buildSection(
title: '账户与安全', title: '账户与安全',
children: [ children: [
_SettingsTile( _SettingsTile(
icon: Icons.person_outline, icon: Icons.person_outline,
title: '个人资料', title: '个人资料',
subtitle: '修改昵称、头像', subtitle: '修改昵称、头像',
onTap: () => _navigateTo(context, const ProfileEditPage()), onTap: () => _navigateTo(context, const ProfileEditPage()),
), ),
_SettingsTile( _SettingsTile(
icon: Icons.security_outlined, icon: Icons.security_outlined,
title: '安全设置', title: '安全设置',
subtitle: _securitySubtitle(settings), subtitle: _securitySubtitle(settings),
onTap: () => _navigateTo(context, const SecuritySettingsPage()), onTap: () =>
), _navigateTo(context, const SecuritySettingsPage()),
], ),
), ],
_buildSection( ),
title: '偏好', _buildSection(
children: [ title: '偏好',
_SettingsTile( children: [
icon: Icons.apps_outlined, _SettingsTile(
title: '快捷入口', icon: Icons.sync_outlined,
subtitle: '自定义概览页快捷目录', title: '文件同步',
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()), subtitle: '本地与云端文件自动同步',
), onTap: () =>
_SettingsTile( Navigator.of(context).pushNamed(RouteNames.syncSettings),
icon: Icons.folder_outlined, ),
title: '文件偏好', _SettingsTile(
subtitle: '版本保留、视图同步、分享可见性', icon: Icons.apps_outlined,
onTap: () => _navigateTo(context, const FilePreferencesPage()), title: '快捷入口',
), subtitle: '自定义概览页快捷目录',
_SettingsTile( onTap: () =>
icon: Icons.tune, _navigateTo(context, const QuickAccessSettingsPage()),
title: '应用设置', ),
subtitle: '缓存、主题、语言', _SettingsTile(
onTap: () => _navigateTo(context, const AppSettingsPage()), icon: Icons.folder_outlined,
), title: '文件偏好',
], subtitle: '版本保留、视图同步、分享可见性',
), onTap: () =>
// _navigateTo(context, const FilePreferencesPage()),
if (settings != null) ..._buildProSections(context, settings), ),
_buildSection( _SettingsTile(
title: '关于', icon: Icons.tune,
children: [ title: '应用设置',
ListTile( subtitle: '缓存、主题、语言',
leading: const Icon(Icons.info_outline), onTap: () => _navigateTo(context, const AppSettingsPage()),
title: const Text('应用名称'), ),
subtitle: const Text('公云存储'), _SettingsTile(
), icon: Icons.network_check,
ListTile( title: '网络测试',
leading: const Icon(Icons.tag), subtitle: '服务连通性测试与自定义线路(会员)',
title: const Text('版本号'), onTap: () => _openNetworkTest(context),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), ),
), ],
ListTile( ),
leading: const Icon(Icons.copyright), //
title: const Text('License'), if (settings != null) ..._buildProSections(context, settings),
subtitle: const Text('AGPL-3.0'), _buildSection(
), title: '关于',
ListTile( children: [
leading: const Icon(Icons.code), ListTile(
title: const Text('二次开发'), leading: const Icon(Icons.info_outline),
subtitle: const Text('gongyun_app'), title: const Text('应用名称'),
trailing: const Icon(Icons.open_in_new, size: 16), subtitle: const Text('公云存储'),
onTap: () { ),
launchUrl( _buildVersionTile(),
Uri.parse('https://git.saont.net/gongyun/app'), ListTile(
mode: LaunchMode.externalApplication, leading: const Icon(Icons.copyright),
); title: const Text('License'),
}, subtitle: const Text('AGPL-3.0'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.code), leading: const Icon(Icons.code),
title: const Text('基于'), title: const Text('二次开发'),
subtitle: const Text('cloudreve4_flutter'), subtitle: const Text('gongyun_app'),
trailing: const Icon(Icons.open_in_new, size: 16), trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () { onTap: () {
launchUrl( _openExternalUrl(
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'), Uri.parse('https://git.saont.net/gongyun/app'),
mode: LaunchMode.externalApplication, );
); },
}, ),
), ListTile(
], leading: const Icon(Icons.code),
), title: const Text('基于'),
const SizedBox(height: 24), subtitle: const Text('cloudreve4_flutter'),
_buildLogoutButton(context, auth), trailing: const Icon(Icons.open_in_new, size: 16),
const SizedBox(height: 32), onTap: () {
], _openExternalUrl(
), Uri.parse(
'https://github.com/LimoYuan/cloudreve4_flutter',
),
);
},
),
],
),
const SizedBox(height: 24),
_buildLogoutButton(context, auth),
const SizedBox(height: 32),
],
),
), ),
); );
} }
/// ///
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) { List<Widget> _buildProSections(
BuildContext context,
UserSettingModel settings,
) {
final sections = <Widget>[]; final sections = <Widget>[];
final hasStoragePacks = settings.storagePacks.isNotEmpty; final hasStoragePacks = settings.storagePacks.isNotEmpty;
final hasCredit = settings.credit > 0; final hasCredit = settings.credit > 0;
@@ -190,40 +209,54 @@ class _SettingsPageState extends State<SettingsPage> {
if (hasStoragePacks || hasCredit || hasMembership) { if (hasStoragePacks || hasCredit || hasMembership) {
final children = <Widget>[]; final children = <Widget>[];
if (hasMembership) { if (hasMembership) {
children.add(ListTile( children.add(
leading: const Icon(Icons.workspace_premium_outlined), ListTile(
title: const Text('会员'), leading: const Icon(Icons.workspace_premium_outlined),
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), title: const Text('会员'),
trailing: TextButton( subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
onPressed: () => _cancelMembership(context), trailing: TextButton(
child: const Text('取消会员', style: TextStyle(color: Colors.red)), onPressed: () => _cancelMembership(context),
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
),
), ),
)); );
} }
if (hasStoragePacks) { if (hasStoragePacks) {
children.add(ListTile( children.add(
leading: const Icon(Icons.inventory_2_outlined), ListTile(
title: Text('存储包 (${settings.storagePacks.length})'), leading: const Icon(Icons.inventory_2_outlined),
subtitle: Text(_storagePackSummary(settings.storagePacks)), title: Text('存储包 (${settings.storagePacks.length})'),
trailing: const Icon(Icons.chevron_right), subtitle: Text(_storagePackSummary(settings.storagePacks)),
onTap: () => _showStoragePacks(context, settings.storagePacks), trailing: const Icon(Icons.chevron_right),
)); onTap: () => _showStoragePacks(context, settings.storagePacks),
),
);
} }
if (hasCredit) { if (hasCredit) {
children.add(ListTile( children.add(
leading: const Icon(Icons.account_balance_wallet_outlined), ListTile(
title: const Text('积分'), leading: const Icon(Icons.account_balance_wallet_outlined),
subtitle: Text('${settings.credit} 积分'), title: const Text('积分'),
trailing: const Icon(Icons.chevron_right), subtitle: Text('${settings.credit} 积分'),
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)), trailing: const Icon(Icons.chevron_right),
)); onTap: () => _navigateTo(
context,
CreditHistoryPage(currentCredit: settings.credit),
),
),
);
} }
sections.add(_buildSection(title: '财务', children: children)); sections.add(_buildSection(title: '财务', children: children));
} }
return sections; return sections;
} }
Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) { Widget _buildProfileCard(
BuildContext context,
UserModel? user,
UserSettingModel? settings,
UserCapacityModel? capacity,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@@ -246,17 +279,24 @@ class _SettingsPageState extends State<SettingsPage> {
children: [ children: [
Text( Text(
user?.nickname ?? '未登录', user?.nickname ?? '未登录',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
user?.email ?? '', user?.email ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
if (user?.group != null) ...[ if (user?.group != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -309,9 +349,12 @@ class _SettingsPageState extends State<SettingsPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('存储空间', style: theme.textTheme.bodySmall), Text('存储空间', style: theme.textTheme.bodySmall),
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith( Text(
color: colorScheme.onSurfaceVariant, '$usedText / $totalText',
)), style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -327,7 +370,10 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildSection({required String title, required List<Widget> children}) { Widget _buildSection({
required String title,
required List<Widget> children,
}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -353,17 +399,43 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildVersionTile() {
return AnimatedBuilder(
animation: UpdateService.instance,
builder: (context, _) {
final hasUpdate = UpdateService.instance.hasUpdate;
return ListTile(
leading: Icon(
hasUpdate ? Icons.new_releases_outlined : Icons.tag,
color: hasUpdate ? Theme.of(context).colorScheme.primary : null,
),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
onTap: hasUpdate ? () => _openDownloadsPage() : null,
trailing: hasUpdate ? const _UpdateAvailableBadge() : null,
);
},
);
}
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) { Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => _confirmLogout(context, auth), onPressed: () => _confirmLogout(context, auth),
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)), label: Text(
'退出登录',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)), side: BorderSide(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
); );
@@ -400,33 +472,47 @@ class _SettingsPageState extends State<SettingsPage> {
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium), child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
), ),
...packs.map((pack) => Card( ...packs.map(
child: Padding( (pack) => Card(
padding: const EdgeInsets.all(12), child: Padding(
child: Column( padding: const EdgeInsets.all(12),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, Row(
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall), children: [
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge), Text(
], pack.name,
), style: Theme.of(ctx).textTheme.titleSmall,
const SizedBox(height: 4), ),
Text( Text(
'激活: ${_formatDate(pack.activeSince)}' _formatBytes(pack.size),
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}', style: Theme.of(ctx).textTheme.labelLarge,
style: Theme.of(ctx).textTheme.bodySmall, ),
), ],
if (pack.isExpired) ...[ ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)), Text(
'激活: ${_formatDate(pack.activeSince)}'
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}',
style: Theme.of(ctx).textTheme.bodySmall,
),
if (pack.isExpired) ...[
const SizedBox(height: 4),
Text(
'已过期',
style: TextStyle(
color: Theme.of(ctx).colorScheme.error,
fontSize: 12,
),
),
],
], ],
], ),
), ),
), ),
)), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
@@ -442,10 +528,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('取消会员'), title: const Text('取消会员'),
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'), content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('确认取消'), child: const Text('确认取消'),
), ),
], ],
@@ -472,6 +563,59 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
Future<void> _openExternalUrl(Uri uri) async {
try {
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!opened) {
ToastHelper.failure('无法打开链接: $uri');
}
} catch (e) {
ToastHelper.failure('无法打开链接: $e');
}
}
Future<void> _openDownloadsPage() async {
try {
await UpdateService.instance.openDownloadsPage();
} catch (e) {
ToastHelper.failure('打开下载页面失败: $e');
}
}
Future<void> _openNetworkTest(BuildContext context) async {
final agreed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
title: const Text('会话信息授权'),
content: _NetworkTestConsentText(
onOpenPrivacy: () => _openExternalUrl(
Uri.parse('https://www.gongyun.org/policy/privacy.html'),
),
onOpenTerms: () => _openExternalUrl(
Uri.parse('https://www.gongyun.org/policy/terms.html'),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('不同意'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('同意'),
),
],
),
);
if (agreed == true && context.mounted) {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const NetworkTestPage()));
}
}
Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async { Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -479,10 +623,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('退出登录'), title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'), content: const Text('确定要退出当前账号吗?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('退出'), child: const Text('退出'),
), ),
], ],
@@ -499,7 +648,9 @@ class _SettingsPageState extends State<SettingsPage> {
String _formatBytes(int bytes) { String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} }
@@ -508,6 +659,140 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
class _UpdateAvailableBadge extends StatefulWidget {
const _UpdateAvailableBadge();
@override
State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState();
}
class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_opacity = Tween<double>(
begin: 0.3,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return FadeTransition(
opacity: _opacity,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: colorScheme.error,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'发现新版本',
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 18, color: colorScheme.error),
],
),
);
}
}
class _NetworkTestConsentText extends StatefulWidget {
final VoidCallback onOpenPrivacy;
final VoidCallback onOpenTerms;
const _NetworkTestConsentText({
required this.onOpenPrivacy,
required this.onOpenTerms,
});
@override
State<_NetworkTestConsentText> createState() =>
_NetworkTestConsentTextState();
}
class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> {
late final TapGestureRecognizer _privacyRecognizer;
late final TapGestureRecognizer _termsRecognizer;
@override
void initState() {
super.initState();
_privacyRecognizer = TapGestureRecognizer()..onTap = widget.onOpenPrivacy;
_termsRecognizer = TapGestureRecognizer()..onTap = widget.onOpenTerms;
}
@override
void didUpdateWidget(covariant _NetworkTestConsentText oldWidget) {
super.didUpdateWidget(oldWidget);
_privacyRecognizer.onTap = widget.onOpenPrivacy;
_termsRecognizer.onTap = widget.onOpenTerms;
}
@override
void dispose() {
_privacyRecognizer.dispose();
_termsRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final linkStyle = TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
);
return Text.rich(
TextSpan(
children: [
const TextSpan(text: '您需同意'),
TextSpan(
text: '隐私政策',
style: linkStyle,
recognizer: _privacyRecognizer,
),
const TextSpan(text: ''),
TextSpan(
text: '用户协议',
style: linkStyle,
recognizer: _termsRecognizer,
),
const TextSpan(
text:
'方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。',
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget { class _SettingsTile extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
File diff suppressed because it is too large Load Diff
+308 -42
View File
@@ -1,21 +1,50 @@
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/announcement_dialog.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:cloudreve4_flutter/services/announcement_service.dart';
import 'package:cloudreve4_flutter/services/dialog_queue_service.dart';
import 'package:cloudreve4_flutter/services/share_link_service.dart';
import 'package:cloudreve4_flutter/presentation/pages/share/share_link_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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 +52,143 @@ 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, WidgetsBindingObserver {
final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController;
String? _lastClipboardShareId;
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),
);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_showPostLoginAnnouncement();
_checkClipboardShareLink();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_syncSpinController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkClipboardShareLink();
}
}
Future<void> _showPostLoginAnnouncement() async {
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) return;
try {
final service = AnnouncementService.instance;
final announcement = await service.getChangedSiteNotice();
if (!mounted || announcement == null) return;
await DialogQueueService.instance.enqueue<void>(() async {
if (!mounted) return;
await AnnouncementDialog.show(
context,
title: announcement.title,
html: announcement.html,
baseUrl: announcement.baseUrl,
);
await service.markDismissed(announcement);
});
} catch (_) {
// Announcement checks should never block the shell.
}
}
Future<void> _checkClipboardShareLink() async {
await Future<void>.delayed(const Duration(milliseconds: 650));
if (!mounted) return;
try {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final candidate = ShareLinkService.instance.parseShareLink(data?.text);
if (candidate == null) return;
if (_lastClipboardShareId == candidate.id) return;
_lastClipboardShareId = candidate.id;
await DialogQueueService.instance.enqueue<void>(() async {
if (!mounted) return;
final open = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('检测到分享链接'),
content: Text(
'是否打开这个文件分享?\n\n${candidate.url}',
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('忽略'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('打开'),
),
],
),
);
if (open == true && mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ShareLinkPage(candidate: candidate),
),
);
}
});
} catch (_) {
// Clipboard access failures should not affect the shell.
}
}
@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 +198,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 +229,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 +322,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 +359,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 +372,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 +411,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 +424,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 +463,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 +508,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 +539,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 +566,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
await authProvider.logout(); await authProvider.logout();
fileManager.clearFiles(); fileManager.clearFiles();
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false); Navigator.of(
context,
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
} }
} }
} }
@@ -5,6 +5,7 @@ import '../../../services/api_service.dart';
import '../../../services/server_service.dart'; import '../../../services/server_service.dart';
import '../../../services/storage_service.dart'; import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/sync_provider.dart';
/// ///
class SplashPage extends StatefulWidget { class SplashPage extends StatefulWidget {
@@ -45,6 +46,16 @@ class _SplashPageState extends State<SplashPage> {
if (!mounted) return; if (!mounted) return;
if (authProvider.isAuthenticated) { if (authProvider.isAuthenticated) {
//
if (!mounted) return;
final syncProvider = Provider.of<SyncProvider>(context, listen: false);
final token = authProvider.token;
await syncProvider.autoResumeIfNeeded(
currentAccessToken: token?.accessToken,
currentRefreshToken: token?.refreshToken,
);
if (!mounted) return;
Navigator.of(context).pushReplacementNamed(RouteNames.home); Navigator.of(context).pushReplacementNamed(RouteNames.home);
} else { } else {
Navigator.of(context).pushReplacementNamed(RouteNames.login); Navigator.of(context).pushReplacementNamed(RouteNames.login);
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../widgets/toast_helper.dart';
///
class SyncLogViewerPage extends StatefulWidget {
const SyncLogViewerPage({super.key});
@override
State<SyncLogViewerPage> createState() => _SyncLogViewerPageState();
}
class _SyncLogViewerPageState extends State<SyncLogViewerPage> {
String _logContent = '';
bool _isLoading = true;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadLog();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<String> _getSyncLogPath() async {
final appDir = await getApplicationSupportDirectory();
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
}
Future<void> _loadLog() async {
setState(() => _isLoading = true);
try {
final path = await _getSyncLogPath();
final file = File(path);
if (!await file.exists()) {
if (mounted) {
setState(() {
_logContent = '';
_isLoading = false;
});
}
return;
}
final lines = await file.readAsLines();
const maxLines = 1000;
String content;
if (lines.length <= maxLines) {
content = lines.join('\n');
} else {
content = '... (仅显示最近 $maxLines 行)\n\n'
'${lines.sublist(lines.length - maxLines).join('\n')}';
}
if (mounted) {
setState(() {
_logContent = content;
_isLoading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ToastHelper.error('读取同步日志失败:$e');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('同步日志'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLog,
tooltip: '刷新',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _logContent.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.description_outlined,
size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text('暂无同步日志', style: TextStyle(color: theme.hintColor)),
],
),
)
: Container(
color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
child: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(12),
child: SelectableText(
_logContent,
style: TextStyle(
fontFamily: 'SourceCodePro',
fontSize: 13,
height: 1.5,
color: isDark ? Colors.grey[300] : Colors.grey[900],
),
),
),
),
),
);
}
}
+604
View File
@@ -0,0 +1,604 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../data/models/sync_task_model.dart';
import '../../providers/sync_provider.dart';
import '../../widgets/toast_helper.dart';
import 'sync_settings_page.dart';
/// Tab -
class SyncPage extends StatefulWidget {
const SyncPage({super.key});
@override
State<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends State<SyncPage> {
// ID
final Set<String> _expandedTasks = {};
// ID
final Set<String> _loadingDetails = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SyncProvider>().loadRecentTasks();
});
}
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncProvider>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('文件同步'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _navigateToSettings(),
tooltip: '同步设置',
),
],
),
body: RefreshIndicator(
onRefresh: () async {
final sync = context.read<SyncProvider>();
sync.invalidateAllTaskDetails();
await sync.loadRecentTasks();
},
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildStatusCard(sync, theme),
const SizedBox(height: 8),
_buildActiveTasksSection(sync, theme),
const SizedBox(height: 8),
_buildCompletedTasksSection(sync, theme),
const SizedBox(height: 32),
],
),
),
);
}
void _navigateToSettings() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SyncSettingsPage()),
);
}
Widget _buildStatusCard(SyncProvider sync, ThemeData theme) {
final isActive = sync.isActive;
final isPaused = sync.isPaused;
final hasError = sync.hasError;
Color statusColor;
IconData statusIcon;
String statusText;
if (isActive) {
statusColor = theme.colorScheme.primary;
statusIcon = Icons.sync;
statusText = _syncModeLabel(sync);
} else if (isPaused) {
statusColor = Colors.orange;
statusIcon = Icons.pause_circle_outline;
statusText = '已暂停';
} else if (hasError) {
statusColor = theme.colorScheme.error;
statusIcon = Icons.error_outline;
statusText = '同步错误';
} else if (sync.state == SyncState.stopped) {
statusColor = theme.disabledColor;
statusIcon = Icons.stop_circle_outlined;
statusText = '已停止';
} else {
statusColor = theme.disabledColor;
statusIcon = Icons.cloud_off;
statusText = '未启动';
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 5),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(statusIcon, color: statusColor, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusText,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (sync.errorMessage != null)
Text(
sync.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (sync.activeWorkerCount > 0)
Badge(
label: Text('${sync.activeWorkerCount}'),
child: Icon(LucideIcons.loader, color: statusColor, size: 24),
),
],
),
if (isActive || isPaused) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: sync.totalFiles > 0 ? sync.progress : null,
minHeight: 6,
),
),
const SizedBox(height: 8),
Text(
sync.totalFiles > 0
? '${sync.syncedFiles} / ${sync.totalFiles} 文件'
: sync.state == SyncState.continuous
? '持续同步中'
: '正在同步...',
style: theme.textTheme.bodySmall,
),
],
if (sync.lastSummary != null) ...[
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 4,
children: [
_summaryChip(theme, '上传', sync.lastSummary!.uploaded),
_summaryChip(theme, '下载', sync.lastSummary!.downloaded),
_summaryChip(theme, '冲突', sync.lastSummary!.conflicts),
_summaryChip(theme, '失败', sync.lastSummary!.failed),
_summaryChip(theme, '重命名', sync.lastSummary!.renamed),
_summaryChip(theme, '移动', sync.lastSummary!.moved),
],
),
],
const SizedBox(height: 12),
Row(
children: [
if (!sync.isActive && !sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => _navigateToSettings(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('开始同步'),
),
),
if (sync.isActive)
Expanded(
child: OutlinedButton.icon(
onPressed: () => sync.pause(),
icon: const Icon(Icons.pause, size: 18),
label: const Text('暂停'),
),
),
if (sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => sync.resume(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('恢复'),
),
),
if (sync.isActive || sync.isPaused) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _stopSync(sync),
icon: const Icon(Icons.stop, size: 18),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
),
],
],
),
],
),
),
);
}
Widget _summaryChip(ThemeData theme, String label, int value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)),
],
);
}
String _syncModeLabel(SyncProvider sync) {
final mode = sync.persistedConfig?.syncMode ?? 'full';
return switch (mode) {
'full' => '全量同步中',
'upload_only' => '仅上传中',
'download_only' => '仅下载中',
'album' => '相册同步中',
'mirror_wcf' => '镜像同步中',
_ => '同步中',
};
}
Widget _buildActiveTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.activeTasks;
return _buildTaskSection(
theme: theme,
title: '正在同步',
icon: LucideIcons.loader,
tasks: tasks,
emptyText: '暂无正在进行的同步任务',
);
}
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.recentTasks
.where((t) =>
(t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') &&
t.totalCount > 0)
.toList();
return _buildTaskSection(
theme: theme,
title: '已完成',
icon: LucideIcons.checkCircle2,
tasks: tasks,
emptyText: '暂无已完成的同步任务',
);
}
Widget _buildTaskSection({
required ThemeData theme,
required String title,
required IconData icon,
required List<SyncTaskModel> tasks,
required String emptyText,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
if (tasks.isEmpty)
Card(
margin: const EdgeInsets.symmetric(horizontal: 5),
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(emptyText, style: TextStyle(color: theme.hintColor)),
),
),
)
else
...tasks.map((task) => _buildTaskCard(task, theme)),
],
);
}
Widget _buildTaskCard(SyncTaskModel task, ThemeData theme) {
final isExpanded = _expandedTasks.contains(task.id);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Column(
children: [
InkWell(
onTap: () => _toggleTaskExpand(task.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12),
child: Row(
children: [
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: task.id));
ToastHelper.success('已复制任务 ID');
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 72,
child: Text(
task.shortId,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.hintColor,
),
),
),
Icon(Icons.copy, size: 14, color: theme.hintColor),
],
),
),
Expanded(
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColor(task.status, theme).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
task.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: _statusColor(task.status, theme),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Text(
'${task.completedCount}/${task.totalCount}',
style: theme.textTheme.bodySmall,
),
if (task.failedCount > 0) ...[
const SizedBox(width: 8),
Text(
'失败${task.failedCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 20,
color: theme.hintColor,
),
],
),
),
),
if (isExpanded)
_buildTaskDetailList(task, theme),
],
),
);
}
Widget _buildTaskDetailList(SyncTaskModel task, ThemeData theme) {
final sync = context.watch<SyncProvider>();
final items = sync.getCachedTaskDetail(task.id);
if (_loadingDetails.contains(task.id)) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
);
}
if (items == null || items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)),
);
}
final hasMore = sync.hasMoreTaskDetail(task.id);
return Column(
children: [
const Divider(height: 1, indent: 16, endIndent: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
child: Row(
children: [
SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
],
),
),
...items.map((item) => _buildTaskItemRow(item, theme)),
if (hasMore)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextButton.icon(
onPressed: () => _loadMoreDetail(task.id),
icon: const Icon(Icons.expand_more, size: 16),
label: const Text('加载更多'),
),
),
const SizedBox(height: 4),
],
);
}
Widget _buildTaskItemRow(SyncTaskItemModel item, ThemeData theme) {
final timeStr = _formatTime(item.updatedAt);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Row(
children: [
SizedBox(
width: 70,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: _actionColor(item.actionType, theme).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
item.actionLabel,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
color: _actionColor(item.actionType, theme),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.filename,
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
SizedBox(
width: 55,
child: Text(
item.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: _itemStatusColor(item.status, theme),
),
),
),
SizedBox(
width: 110,
child: Text(
timeStr,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
],
),
);
}
Color _statusColor(String status, ThemeData theme) {
return switch (status) {
'running' => theme.colorScheme.primary,
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'cancelled' => theme.hintColor,
_ => theme.hintColor,
};
}
Color _actionColor(String actionType, ThemeData theme) {
return switch (actionType) {
'upload' => Colors.blue,
'download' => Colors.green,
'create_placeholder' => Colors.teal,
'delete_local' || 'delete_remote' => theme.colorScheme.error,
'rename' || 'move' => Colors.orange,
'conflict_resolve' => Colors.purple,
_ => theme.colorScheme.primary,
};
}
Color _itemStatusColor(String status, ThemeData theme) {
return switch (status) {
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'running' => theme.colorScheme.primary,
_ => theme.hintColor,
};
}
String _formatTime(String isoTime) {
try {
final dt = DateTime.parse(isoTime).toLocal();
return '${dt.year}/${dt.month.toString().padLeft(2, '0')}/${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
} catch (_) {
return isoTime;
}
}
void _toggleTaskExpand(String taskId) {
final sync = context.read<SyncProvider>();
if (_expandedTasks.contains(taskId)) {
setState(() => _expandedTasks.remove(taskId));
sync.unwatchTaskDetail(taskId);
return;
}
setState(() => _expandedTasks.add(taskId));
sync.watchTaskDetail(taskId);
if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) {
setState(() => _loadingDetails.add(taskId));
sync.getTaskDetail(taskId).whenComplete(() {
if (mounted) {
setState(() => _loadingDetails.remove(taskId));
}
});
}
}
void _loadMoreDetail(String taskId) {
final sync = context.read<SyncProvider>();
sync.loadMoreTaskDetail(taskId);
}
Future<void> _stopSync(SyncProvider sync) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('停止同步'),
content: const Text('确定要停止文件同步吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('停止'),
),
],
),
);
if (confirmed == true) {
await sync.stop();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -226,7 +226,7 @@ class FileManagerProvider extends ChangeNotifier {
/// ///
Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async { Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async {
try { try {
await FileService().moveFiles(uris: uris, dst: destination); await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
clearSelection(); clearSelection();
if (!copy) { if (!copy) {
@@ -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; // 1s3s
//
List<SyncTaskModel> _activeTasks = [];
List<SyncTaskModel> _recentTasks = [];
int _activeWorkerCount = 0;
// : taskId -> items
final Map<String, List<SyncTaskItemModel>> _taskDetailCache = {};
// : taskId -> hasMore
final Map<String, bool> _taskDetailHasMore = {};
// IDUI
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 1s3s
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();
}
}
@@ -0,0 +1,212 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
class AnnouncementDialog extends StatefulWidget {
final String title;
final String html;
final String baseUrl;
const AnnouncementDialog({
super.key,
required this.title,
required this.html,
required this.baseUrl,
});
static Future<void> show(
BuildContext context, {
required String title,
required String html,
required String baseUrl,
}) async {
if (html.trim().isEmpty) return;
await showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) =>
AnnouncementDialog(title: title, html: html, baseUrl: baseUrl),
);
}
@override
State<AnnouncementDialog> createState() => _AnnouncementDialogState();
}
class _AnnouncementDialogState extends State<AnnouncementDialog> {
mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
late final String _html;
late final String _baseUrl;
bool _loading = true;
@override
void initState() {
super.initState();
final origin = Uri.parse(widget.baseUrl).origin;
_html = _wrapHtml(widget.html);
_baseUrl = '$origin/';
if (!_useDesktopWebView) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
mobile.NavigationDelegate(
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
),
)
..loadHtmlString(_html, baseUrl: _baseUrl);
}
}
@override
void dispose() {
_desktopController?.dispose();
super.dispose();
}
String _wrapHtml(String body) {
final encodedTitle = const HtmlEscape().convert(widget.title);
return '''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>$encodedTitle</title>
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
font-size: 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
body {
padding: 12px 14px 18px;
box-sizing: border-box;
}
img, video {
max-width: 100% !important;
height: auto !important;
border-radius: 12px;
}
a {
color: #2563eb;
text-decoration: none;
}
fieldset, section, div {
max-width: 100% !important;
box-sizing: border-box !important;
}
</style>
</head>
<body>
$body
</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.72,
child: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(18, 14, 8, 10),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.45),
),
),
),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
IconButton(
tooltip: '关闭',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Expanded(
child: Stack(
children: [
_buildWebView(),
if (_loading)
const Center(child: CircularProgressIndicator()),
],
),
),
],
),
),
),
);
}
Widget _buildWebView() {
if (_useDesktopWebView) {
return desktop.InAppWebView(
initialData: desktop.InAppWebViewInitialData(
data: _html,
baseUrl: desktop.WebUri(_baseUrl),
),
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
supportZoom: false,
),
onWebViewCreated: (controller) {
_desktopController = controller;
},
onLoadStop: (_, url) {
if (mounted) setState(() => _loading = false);
},
onReceivedError: (_, request, error) {
if (mounted) setState(() => _loading = false);
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
}
}
+3 -13
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/file_utils.dart';
/// ///
class FileBreadcrumb extends StatelessWidget { class FileBreadcrumb extends StatelessWidget {
final String currentPath; final String currentPath;
@@ -69,19 +71,7 @@ class FileBreadcrumb extends StatelessWidget {
} }
String _decodePathSegment(String value) { String _decodePathSegment(String value) {
var decoded = value; return FileUtils.safeDecodePathSegment(value);
for (var i = 0; i < 5; i++) {
try {
final next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
} on FormatException {
break;
}
}
return decoded;
} }
Widget _buildBreadcrumbItem( Widget _buildBreadcrumbItem(
+73 -26
View File
@@ -17,6 +17,7 @@ class FileGridItem extends StatelessWidget {
final VoidCallback? onSelect; final VoidCallback? onSelect;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser; final VoidCallback? onOpenInBrowser;
final VoidCallback? onOpenInCloudreveApp;
final VoidCallback? onRename; final VoidCallback? onRename;
final VoidCallback? onMove; final VoidCallback? onMove;
final VoidCallback? onCopy; final VoidCallback? onCopy;
@@ -38,6 +39,7 @@ class FileGridItem extends StatelessWidget {
this.onSelect, this.onSelect,
this.onDownload, this.onDownload,
this.onOpenInBrowser, this.onOpenInBrowser,
this.onOpenInCloudreveApp,
this.onRename, this.onRename,
this.onMove, this.onMove,
this.onCopy, this.onCopy,
@@ -50,25 +52,27 @@ class FileGridItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Builder( return RepaintBoundary(
builder: (builderContext) => LayoutBuilder( child: Builder(
builder: (context, constraints) { builder: (builderContext) => LayoutBuilder(
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0); builder: (context, constraints) {
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
return _FileGridItemHover( return _FileGridItemHover(
file: file, file: file,
isSelected: isSelected, isSelected: isSelected,
isHighlighted: isHighlighted, isHighlighted: isHighlighted,
showCheckbox: showCheckbox, showCheckbox: showCheckbox,
contextHint: contextHint, contextHint: contextHint,
fontSize: fontSize, fontSize: fontSize,
tapToShowMenu: tapToShowMenu, tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap, onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(builderContext), onLongPress: () => _showMenu(builderContext),
onSelect: onSelect, onSelect: onSelect,
onMore: () => _showMenu(builderContext), onMore: () => _showMenu(builderContext),
); );
}, },
),
), ),
); );
} }
@@ -79,6 +83,7 @@ class FileGridItem extends StatelessWidget {
hasSelect: onSelect != null, hasSelect: onSelect != null,
hasDownload: onDownload != null, hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null, hasOpenInBrowser: onOpenInBrowser != null,
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
hasRename: onRename != null, hasRename: onRename != null,
hasMove: onMove != null, hasMove: onMove != null,
hasCopy: onCopy != null, hasCopy: onCopy != null,
@@ -95,6 +100,8 @@ class FileGridItem extends StatelessWidget {
onDownload?.call(); onDownload?.call();
case FileMenuAction.openInBrowser: case FileMenuAction.openInBrowser:
onOpenInBrowser?.call(); onOpenInBrowser?.call();
case FileMenuAction.openInCloudreveApp:
onOpenInCloudreveApp?.call();
case FileMenuAction.rename: case FileMenuAction.rename:
onRename?.call(); onRename?.call();
case FileMenuAction.move: case FileMenuAction.move:
@@ -318,10 +325,8 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
Widget _buildIconArea(BuildContext context) { Widget _buildIconArea(BuildContext context) {
final file = widget.file; final file = widget.file;
final ext = FileUtils.getFileExtension(file.name); final isThumbnailable =
final isThumbnailable = !file.isFolder !file.isFolder && FileUtils.isThumbnailableFile(file.name);
&& FileUtils.isImageFile(file.name)
&& ext != 'svg';
if (!isThumbnailable) { if (!isThumbnailable) {
return Center( return Center(
@@ -335,10 +340,52 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
); );
} }
return ThumbnailImage( return Stack(
file: file, fit: StackFit.expand,
contextHint: widget.contextHint, children: [
borderRadius: 10, ThumbnailImage(
file: file,
contextHint: widget.contextHint,
borderRadius: 10,
),
if (FileUtils.isVideoFile(file.name))
Center(
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(17),
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 18,
),
),
),
if (FileUtils.isPsdFile(file.name))
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'PSD',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
),
],
); );
} }
} }
+89 -57
View File
@@ -19,11 +19,58 @@ class FileInfoPanel extends StatefulWidget {
Scaffold.of(context).openEndDrawer(); Scaffold.of(context).openEndDrawer();
} }
/// BottomSheet
static void showAsBottomSheet(BuildContext context, FileModel file) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _FileInfoSheet(file: file),
);
}
@override @override
State<FileInfoPanel> createState() => _FileInfoPanelState(); State<FileInfoPanel> createState() => _FileInfoPanelState();
} }
class _FileInfoPanelState extends State<FileInfoPanel> { class _FileInfoPanelState extends State<FileInfoPanel> {
@override
Widget build(BuildContext context) {
return Drawer(
child: FileInfoPanelContent(file: widget.file),
);
}
}
/// BottomSheet
class _FileInfoSheet extends StatelessWidget {
final FileModel file;
const _FileInfoSheet({required this.file});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return FileInfoPanelContent(file: file);
},
);
}
}
/// FileInfoPanel Drawer
class FileInfoPanelContent extends StatefulWidget {
final FileModel file;
const FileInfoPanelContent({super.key, required this.file});
@override
State<FileInfoPanelContent> createState() => _FileInfoPanelContentState();
}
class _FileInfoPanelContentState extends State<FileInfoPanelContent> {
FileInfoModel? _fileInfo; FileInfoModel? _fileInfo;
bool _isLoading = true; bool _isLoading = true;
bool _isCalculatingFolder = false; bool _isCalculatingFolder = false;
@@ -87,58 +134,53 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Drawer( return Column(
child: SafeArea( children: [
right: false, Container(
child: Column( padding: const EdgeInsets.only(
children: [ left: 16,
Container( right: 8,
padding: const EdgeInsets.only( top: 8,
left: 16, bottom: 12,
right: 8, ),
top: 8, decoration: BoxDecoration(
bottom: 12, border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
), ),
decoration: BoxDecoration( ),
border: Border( child: Row(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)), children: [
FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 32,
iconSize: 18,
borderRadius: 8,
), ),
), const SizedBox(width: 12),
child: Row( Expanded(
children: [ child: Text(
FileIconUtils.buildIconWidget( widget.file.name,
context: context, style: theme.textTheme.titleMedium,
file: widget.file, maxLines: 1,
size: 32, overflow: TextOverflow.ellipsis,
iconSize: 18,
borderRadius: 8,
), ),
const SizedBox(width: 12), ),
Expanded( IconButton(
child: Text( icon: const Icon(LucideIcons.x),
widget.file.name, onPressed: () => Navigator.of(context).pop(),
style: theme.textTheme.titleMedium, ),
maxLines: 1, ],
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
],
),
), ),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildError(theme)
: _buildContent(theme, colorScheme),
),
],
), ),
), Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildError(theme)
: _buildContent(theme, colorScheme),
),
],
); );
} }
@@ -179,7 +221,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
//
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -196,8 +237,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
//
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)), _buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)),
if (file.isFile) if (file.isFile)
_buildInfoRow( _buildInfoRow(
@@ -210,7 +249,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
if (file.owned != null) if (file.owned != null)
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''), _buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''),
//
if (file.isFile && extendedInfo != null) ...[ if (file.isFile && extendedInfo != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)), Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
@@ -234,7 +272,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
), ),
], ],
//
if (file.isFile && versionEntities.isNotEmpty) ...[ if (file.isFile && versionEntities.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)), Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
@@ -242,7 +279,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
_buildVersionSection(theme, colorScheme, versionEntities), _buildVersionSection(theme, colorScheme, versionEntities),
], ],
//
if (file.isFolder) ...[ if (file.isFolder) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)), Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
@@ -488,8 +524,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
); );
} }
//
void _openVersion(EntityModel entity) { void _openVersion(EntityModel entity) {
final file = _fileInfo!.file; final file = _fileInfo!.file;
if (!FileTypeUtils.isPreviewable(file.name)) { if (!FileTypeUtils.isPreviewable(file.name)) {
@@ -601,8 +635,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
} }
} }
//
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) { Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
final summary = _fileInfo?.folderSummary; final summary = _fileInfo?.folderSummary;
+18 -11
View File
@@ -18,6 +18,7 @@ class FileListItem extends StatelessWidget {
final VoidCallback? onSelect; final VoidCallback? onSelect;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser; final VoidCallback? onOpenInBrowser;
final VoidCallback? onOpenInCloudreveApp;
final VoidCallback? onRename; final VoidCallback? onRename;
final VoidCallback? onMove; final VoidCallback? onMove;
final VoidCallback? onCopy; final VoidCallback? onCopy;
@@ -40,6 +41,7 @@ class FileListItem extends StatelessWidget {
this.onSelect, this.onSelect,
this.onDownload, this.onDownload,
this.onOpenInBrowser, this.onOpenInBrowser,
this.onOpenInCloudreveApp,
this.onRename, this.onRename,
this.onMove, this.onMove,
this.onCopy, this.onCopy,
@@ -51,17 +53,19 @@ class FileListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _FileListItemHover( return RepaintBoundary(
file: file, child: _FileListItemHover(
isSelected: isSelected, file: file,
isHighlighted: isHighlighted, isSelected: isSelected,
index: index, isHighlighted: isHighlighted,
isDesktop: isDesktop, index: index,
showCheckbox: showCheckbox, isDesktop: isDesktop,
tapToShowMenu: tapToShowMenu, showCheckbox: showCheckbox,
onTap: tapToShowMenu ? null : onTap, tapToShowMenu: tapToShowMenu,
onLongPress: () => _showMenu(context), onTap: tapToShowMenu ? null : onTap,
onSelect: onSelect, onLongPress: () => _showMenu(context),
onSelect: onSelect,
),
); );
} }
@@ -71,6 +75,7 @@ class FileListItem extends StatelessWidget {
hasSelect: onSelect != null, hasSelect: onSelect != null,
hasDownload: onDownload != null, hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null, hasOpenInBrowser: onOpenInBrowser != null,
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
hasRename: onRename != null, hasRename: onRename != null,
hasMove: onMove != null, hasMove: onMove != null,
hasCopy: onCopy != null, hasCopy: onCopy != null,
@@ -87,6 +92,8 @@ class FileListItem extends StatelessWidget {
onDownload?.call(); onDownload?.call();
case FileMenuAction.openInBrowser: case FileMenuAction.openInBrowser:
onOpenInBrowser?.call(); onOpenInBrowser?.call();
case FileMenuAction.openInCloudreveApp:
onOpenInCloudreveApp?.call();
case FileMenuAction.rename: case FileMenuAction.rename:
onRename?.call(); onRename?.call();
case FileMenuAction.move: case FileMenuAction.move:
@@ -7,6 +7,7 @@ enum FileMenuAction {
select, select,
download, download,
openInBrowser, openInBrowser,
openInCloudreveApp,
rename, rename,
move, move,
copy, copy,
@@ -22,6 +23,7 @@ Future<FileMenuAction?> showFileMenu({
required bool hasSelect, required bool hasSelect,
required bool hasDownload, required bool hasDownload,
required bool hasOpenInBrowser, required bool hasOpenInBrowser,
bool hasOpenInCloudreveApp = false,
required bool hasRename, required bool hasRename,
required bool hasMove, required bool hasMove,
required bool hasCopy, required bool hasCopy,
@@ -87,6 +89,17 @@ Future<FileMenuAction?> showFileMenu({
], ],
), ),
), ),
if (hasOpenInCloudreveApp)
const PopupMenuItem(
value: FileMenuAction.openInCloudreveApp,
child: Row(
children: [
Icon(Icons.web_asset, size: 20),
SizedBox(width: 12),
Text('在 Cloudreve 中打开'),
],
),
),
if (hasRename) if (hasRename)
const PopupMenuItem( const PopupMenuItem(
value: FileMenuAction.rename, value: FileMenuAction.rename,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -132,7 +132,7 @@ class _FolderPickerState extends State<FolderPicker> {
return InkWell( return InkWell(
onTap: () => _enterFolder(folder), onTap: () => _enterFolder(folder),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -163,7 +163,7 @@ class _FolderPickerState extends State<FolderPicker> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
+41 -27
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../../core/utils/date_utils.dart' as date_utils; import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart'; import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_utils.dart';
import '../../data/models/file_model.dart'; import '../../data/models/file_model.dart';
import '../../services/file_service.dart'; import '../../services/file_service.dart';
import '../../services/storage_service.dart'; import '../../services/storage_service.dart';
@@ -38,7 +39,8 @@ class SearchDialog extends StatefulWidget {
child: FadeTransition(opacity: fadeAnim, child: child), child: FadeTransition(opacity: fadeAnim, child: child),
); );
}, },
pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(), pageBuilder: (context, animation, secondaryAnimation) =>
const SearchDialog(),
); );
} }
@@ -145,8 +147,10 @@ class _SearchDialogState extends State<SearchDialog> {
// async gap // async gap
final navProvider = Provider.of<NavigationProvider>(context, listen: false); final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = final fileManager = Provider.of<FileManagerProvider>(
Provider.of<FileManagerProvider>(context, listen: false); context,
listen: false,
);
final parentPath = _extractParentPath(file.path); final parentPath = _extractParentPath(file.path);
final filePath = file.path; final filePath = file.path;
@@ -286,16 +290,20 @@ class _SearchDialogState extends State<SearchDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.alertCircle, Icon(
size: 40, color: colorScheme.error.withValues(alpha: 0.7)), LucideIcons.alertCircle,
size: 40,
color: colorScheme.error.withValues(alpha: 0.7),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(_errorMessage!, Text(
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,
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
/// ///
class SelectionToolbar extends StatelessWidget { class SelectionToolbar extends StatelessWidget {
final int selectionCount; final int selectionCount;
final int totalCount;
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback? onRename; final VoidCallback? onSelectAll;
final VoidCallback? onMore;
final VoidCallback? onMove; final VoidCallback? onMove;
final VoidCallback? onCopy; final VoidCallback? onCopy;
final VoidCallback onDelete; final VoidCallback onDelete;
@@ -12,8 +14,10 @@ class SelectionToolbar extends StatelessWidget {
const SelectionToolbar({ const SelectionToolbar({
super.key, super.key,
required this.selectionCount, required this.selectionCount,
this.totalCount = 0,
required this.onCancel, required this.onCancel,
this.onRename, this.onSelectAll,
this.onMore,
this.onMove, this.onMove,
this.onCopy, this.onCopy,
required this.onDelete, required this.onDelete,
@@ -45,11 +49,19 @@ class SelectionToolbar extends StatelessWidget {
onPressed: onCancel, onPressed: onCancel,
tooltip: '取消选择', tooltip: '取消选择',
), ),
if (selectionCount == 1 && onRename != null) if (onSelectAll != null && selectionCount < totalCount)
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.select_all),
onPressed: onRename, onPressed: onSelectAll,
tooltip: '重命名', tooltip: '全选',
),
if (selectionCount == 1 && onMore != null)
IconButton(
icon: Icon(Theme.of(context).platform == TargetPlatform.iOS
? Icons.more_horiz
: Icons.more_vert),
onPressed: onMore,
tooltip: '更多',
), ),
if (onMove != null) if (onMove != null)
IconButton( IconButton(
+100
View File
@@ -0,0 +1,100 @@
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;
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
if (!shouldShow || !mounted) return;
_dialogVisible = true;
try {
await _showUpdateDialog(update);
} finally {
_dialogVisible = false;
}
}
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
Future<void> openDownloadsPage() async {
try {
await UpdateService.instance.openDownloadsPage();
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
} catch (e) {
ToastHelper.failure('打开下载页面失败: $e');
}
}
Future<void> skipPrompt() async {
await UpdateService.instance.skipPromptForFiveDays(update);
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
}
final releaseNotes = (update.description ?? '').trim();
return AlertDialog(
title: Text('发现新版本 ${update.version}'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('新版本号:${update.version}'),
const SizedBox(height: 12),
Text(
'版本日志',
style: Theme.of(dialogContext).textTheme.titleSmall,
),
const SizedBox(height: 6),
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
],
),
),
),
actions: [
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
],
);
},
);
}
@override
Widget build(BuildContext context) => widget.child;
}
+42
View File
@@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart';
import '../presentation/pages/webdav/webdav_page.dart'; import '../presentation/pages/webdav/webdav_page.dart';
import '../presentation/pages/remote_download/remote_download_page.dart'; import '../presentation/pages/remote_download/remote_download_page.dart';
import '../presentation/pages/settings/settings_page.dart'; import '../presentation/pages/settings/settings_page.dart';
import '../presentation/pages/sync/sync_settings_page.dart';
import '../presentation/pages/preview/image_preview_page.dart'; import '../presentation/pages/preview/image_preview_page.dart';
import '../presentation/pages/preview/pdf_preview_page.dart'; import '../presentation/pages/preview/pdf_preview_page.dart';
import '../presentation/pages/preview/video_preview_page.dart'; import '../presentation/pages/preview/video_preview_page.dart';
@@ -14,6 +15,9 @@ import '../presentation/pages/preview/audio_preview_page.dart';
import '../presentation/pages/preview/document_preview_page.dart'; import '../presentation/pages/preview/document_preview_page.dart';
import '../presentation/pages/preview/markdown_preview_page.dart'; import '../presentation/pages/preview/markdown_preview_page.dart';
import '../presentation/pages/files/category_files_page.dart'; import '../presentation/pages/files/category_files_page.dart';
import '../presentation/pages/share/share_link_page.dart';
import '../presentation/pages/preview/cloudreve_file_app_page.dart';
import '../services/share_link_service.dart';
import '../data/models/file_model.dart'; import '../data/models/file_model.dart';
/// ///
@@ -35,6 +39,9 @@ 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';
static const String shareLink = '/share-link';
static const String cloudreveFileApp = '/cloudreve-file-app';
} }
/// ///
@@ -218,6 +225,41 @@ class AppRouter {
), ),
); );
case RouteNames.syncSettings:
return MaterialPageRoute(
settings: settings,
builder: (context) => const SyncSettingsPage(),
);
case RouteNames.shareLink:
final args = settings.arguments;
if (args is ShareLinkCandidate) {
return MaterialPageRoute(
settings: settings,
builder: (context) => ShareLinkPage(candidate: args),
);
}
return MaterialPageRoute(
settings: settings,
builder: (context) => const SplashPage(),
);
case RouteNames.cloudreveFileApp:
final args = settings.arguments;
if (args is Map<String, dynamic>) {
return MaterialPageRoute(
settings: settings,
builder: (context) => CloudreveFileAppPage(
file: args['file'] as FileModel,
preferredAction: args['preferredAction'] as String?,
),
);
}
return MaterialPageRoute(
settings: settings,
builder: (context) => const SplashPage(),
);
default: default:
return MaterialPageRoute( return MaterialPageRoute(
settings: settings, settings: settings,
+108
View File
@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'api_service.dart';
import 'storage_service.dart';
import '../core/constants/storage_keys.dart';
class SiteAnnouncement {
final String title;
final String html;
final String baseUrl;
final String fingerprint;
const SiteAnnouncement({
required this.title,
required this.html,
required this.baseUrl,
required this.fingerprint,
});
}
///
///
/// Cloudreve V4 SiteConfig site_notice
/// custom_html.headless_bottom / sidebar_bottom HTML
///
///
/// -
/// -
/// -
class AnnouncementService {
AnnouncementService._();
static final AnnouncementService instance = AnnouncementService._();
bool _shownInSession = false;
/// fingerprint
bool get hasShown => _shownInSession;
/// 使 [markDismissed]
void markShown() {
_shownInSession = true;
}
Future<SiteAnnouncement?> getSiteNotice() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/site/config/basic',
);
final data = _asMap(response['data']) ?? response;
final notice = data['site_notice']?.toString().trim();
if (notice == null || notice.isEmpty) {
return null;
}
final baseUrl = ApiService.instance.dio.options.baseUrl;
return SiteAnnouncement(
title: '公告',
html: notice,
baseUrl: baseUrl,
fingerprint: _fingerprint(baseUrl: baseUrl, html: notice),
);
}
///
///
///
Future<SiteAnnouncement?> getChangedSiteNotice() async {
final notice = await getSiteNotice();
if (notice == null) return null;
if (_shownInSession) return null;
final dismissedFingerprint = await StorageService.instance.getString(
StorageKeys.siteAnnouncementDismissedFingerprint,
);
if (dismissedFingerprint == notice.fingerprint) {
_shownInSession = true;
return null;
}
return notice;
}
///
Future<void> markDismissed(SiteAnnouncement announcement) async {
_shownInSession = true;
await StorageService.instance.setString(
StorageKeys.siteAnnouncementDismissedFingerprint,
announcement.fingerprint,
);
}
String _fingerprint({required String baseUrl, required String html}) {
final normalized = '${baseUrl.trim()}\n${html.trim()}';
return sha256.convert(utf8.encode(normalized)).toString();
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
+34 -8
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../services/storage_service.dart';
import '../core/exceptions/app_exception.dart'; import '../core/exceptions/app_exception.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
@@ -9,7 +10,7 @@ import '../core/utils/app_logger.dart';
class ApiResponse<T> { class ApiResponse<T> {
final int code; final int code;
final String message; final String message;
final T? data; final dynamic data;
final String? error; final String? error;
final String? correlationId; final String? correlationId;
@@ -25,7 +26,7 @@ class ApiResponse<T> {
return ApiResponse<T>( return ApiResponse<T>(
code: json['code'] as int? ?? 0, code: json['code'] as int? ?? 0,
message: json['msg'] as String? ?? '', message: json['msg'] as String? ?? '',
data: json['data'] as T?, data: json['data'],
error: json['error'] as String?, error: json['error'] as String?,
correlationId: json['correlation_id'] as String?, correlationId: json['correlation_id'] as String?,
); );
@@ -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);
}, },
); );
@@ -192,6 +198,12 @@ class ApiService {
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}'); AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
// 401 HTTP 401 JSON code: 401 // 401 HTTP 401 JSON code: 401
final silent404 =
error.requestOptions.extra['silent404'] as bool? ?? false;
if (silent404 && error.response?.statusCode == 404) {
return handler.next(error);
}
bool is401Error = error.response?.statusCode == 401; bool is401Error = error.response?.statusCode == 401;
if (!is401Error && error.response?.data is Map<String, dynamic>) { if (!is401Error && error.response?.data is Map<String, dynamic>) {
final data = error.response!.data as Map<String, dynamic>; final data = error.response!.data as Map<String, dynamic>;
@@ -314,12 +326,16 @@ class ApiService {
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
bool noAuth = false, bool noAuth = false,
bool isNoData = false, bool isNoData = false,
bool silent404 = false,
Map<String, dynamic>? headers, Map<String, dynamic>? headers,
}) async { }) async {
final response = await _dio.get<T>( final response = await _dio.get<T>(
path, path,
queryParameters: queryParameters, queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}, headers: headers), options: Options(
extra: {'noAuth': noAuth, 'silent404': silent404},
headers: headers,
),
); );
// , _parseResponse // , _parseResponse
if (isNoData) { if (isNoData) {
@@ -350,9 +366,12 @@ class ApiService {
AppLogger.d('Response Data: ${response.data}'); AppLogger.d('Response Data: ${response.data}');
var isActivEmail = 0; var isActivEmail = 0;
if (response.statusCode == 200) { if (response.statusCode == 200 && response.data is Map) {
Map<String, dynamic>? tmp = response.data as Map<String, dynamic>?; final tmp = Map<String, dynamic>.from(response.data as Map);
isActivEmail = tmp?['code'] as int; final code = tmp['code'];
if (code is int) {
isActivEmail = code;
}
} }
if (isNoData || isActivEmail == 203) { if (isNoData || isActivEmail == 203) {
@@ -441,11 +460,18 @@ class ApiService {
T _parseResponse<T>(Response response) { T _parseResponse<T>(Response response) {
final data = response.data; final data = response.data;
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final apiResponse = ApiResponse<T>.fromJson(data); final apiResponse = ApiResponse<dynamic>.fromJson(data);
if (!apiResponse.isSuccess && !apiResponse.isContinue) { if (!apiResponse.isSuccess && !apiResponse.isContinue) {
throw ServerException(apiResponse.message, code: apiResponse.code); throw ServerException(apiResponse.message, code: apiResponse.code);
} }
return apiResponse.data as T; final payload = apiResponse.data;
if (payload is T) {
return payload;
}
if (data is T) {
return data as T;
}
return payload as T;
} }
return data as T; return data as T;
} }
+144 -37
View File
@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../presentation/pages/auth/captcha_challenge_page.dart'; import '../presentation/pages/auth/captcha_challenge_page.dart';
@@ -30,6 +32,11 @@ class CaptchaService {
String? _captchaToken; String? _captchaToken;
bool _isLoadingCaptcha = false; bool _isLoadingCaptcha = false;
// Windows
CaptchaProxyConfig? _proxyConfig;
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
bool get isLoadingCaptcha => _isLoadingCaptcha; bool get isLoadingCaptcha => _isLoadingCaptcha;
String? get captchaImage => _captchaImage; String? get captchaImage => _captchaImage;
String? get captchaTicket => _captchaTicket; String? get captchaTicket => _captchaTicket;
@@ -103,13 +110,15 @@ class CaptchaService {
Map<String, dynamic> config = <String, dynamic>{}; Map<String, dynamic> config = <String, dynamic>{};
try { try {
config = await AuthService.instance.getBasicSiteConfig().timeout( config = await AuthService.instance
const Duration(seconds: 10), .getBasicSiteConfig()
); .timeout(const Duration(seconds: 10));
} catch (_) {} } catch (_) {}
final captchaType = _normalizeCaptchaType( final captchaType = _normalizeCaptchaType(
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha']) (config['captcha_type'] ??
config['captchaType'] ??
config['captcha'])
?.toString(), ?.toString(),
); );
@@ -151,8 +160,7 @@ class CaptchaService {
'capAssetServer', 'capAssetServer',
]); ]);
final isExternalCaptcha = final isExternalCaptcha = captchaType == 'turnstile' ||
captchaType == 'turnstile' ||
captchaType == 'recaptcha' || captchaType == 'recaptcha' ||
captchaType == 'cap'; captchaType == 'cap';
@@ -199,7 +207,7 @@ class CaptchaService {
} }
/// Web /// Web
Future<void> openCaptchaChallenge(BuildContext context) async { Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
final server = ServerService.instance.currentServer; final server = ServerService.instance.currentServer;
final config = captchaWebConfig; final config = captchaWebConfig;
@@ -210,14 +218,18 @@ class CaptchaService {
final token = await Navigator.of(context).push<String>( final token = await Navigator.of(context).push<String>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => builder: (_) => CaptchaChallengePage(
CaptchaChallengePage(config: config, baseUrl: server.baseUrl), config: config,
baseUrl: server.baseUrl,
proxyConfig: _isDesktop ? _proxyConfig : null,
),
), ),
); );
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
_captchaToken = token; _captchaToken = token;
ToastHelper.success('人机验证完成'); ToastHelper.success('人机验证完成');
onVerified?.call();
} }
} }
@@ -241,16 +253,21 @@ class CaptchaService {
Map<String, String> getCaptchaParams() { Map<String, String> getCaptchaParams() {
if (isWebCaptcha) { if (isWebCaptcha) {
if (_captchaToken == null || _captchaToken!.isEmpty) return {}; if (_captchaToken == null || _captchaToken!.isEmpty) return {};
return {'captcha': _captchaToken!, 'ticket': _captchaToken!}; return {
'captcha': _captchaToken!,
'ticket': _captchaToken!,
};
} }
final userInput = captchaController.text.trim(); final userInput = captchaController.text.trim();
if (userInput.isEmpty && if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) {
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
return {}; return {};
} }
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''}; return {
'captcha': userInput,
'ticket': _captchaTicket ?? '',
};
} }
/// Web /// Web
@@ -263,30 +280,44 @@ class CaptchaService {
final config = captchaWebConfig; final config = captchaWebConfig;
final displayName = config?.displayName ?? '人机验证'; final displayName = config?.displayName ?? '人机验证';
return Column( return StatefulBuilder(
crossAxisAlignment: CrossAxisAlignment.stretch, builder: (context, setState) {
children: [ final hasProxy = _proxyConfig != null;
OutlinedButton.icon( return Column(
onPressed: _isLoadingCaptcha crossAxisAlignment: CrossAxisAlignment.stretch,
? null children: [
: () => openCaptchaChallenge(context), OutlinedButton.icon(
icon: Icon( onPressed: _isLoadingCaptcha ? null : () async {
_captchaToken == null await openCaptchaChallenge(context);
? Icons.verified_user_outlined setState(() {});
: Icons.verified, },
), onLongPress: _isDesktop && Platform.isWindows
label: Text( ? () => _showProxyDialog(context, setState)
_captchaToken == null : null,
? '点击完成 $displayName' icon: Icon(
: '$displayName 已完成,点击重新验证', _captchaToken == null
), ? Icons.verified_user_outlined
), : Icons.verified,
const SizedBox(height: 8), ),
Text( label: Text(
'当前验证码类型:$displayName', _captchaToken == null
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), ? '点击完成 $displayName'
), : '$displayName 已完成,点击重新验证',
], ),
),
const SizedBox(height: 8),
Text(
_isDesktop && Platform.isWindows
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
: '当前验证码类型:$displayName',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
),
],
);
},
); );
} }
@@ -392,4 +423,80 @@ class CaptchaService {
} }
return null; return null;
} }
/// Windows
void _showProxyDialog(BuildContext context, StateSetter setState) {
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('WebView 代理设置'),
content: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'仅支持无认证代理(HTTP/SOCKS5',
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
),
const SizedBox(height: 16),
TextFormField(
controller: hostCtrl,
decoration: const InputDecoration(
labelText: '代理地址',
hintText: '127.0.0.1',
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 12),
TextFormField(
controller: portCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '端口',
hintText: '7890',
prefixIcon: Icon(Icons.numbers),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
_proxyConfig = null;
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('已清除代理配置');
},
child: const Text('清除'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () {
final host = hostCtrl.text.trim();
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
if (host.isEmpty || port <= 0) {
ToastHelper.failure('请输入有效的代理地址和端口');
return;
}
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('代理已设置: $host:$port');
},
child: const Text('确定'),
),
],
);
},
);
}
} }
+18
View File
@@ -8,6 +8,8 @@ import 'package:tray_manager/tray_manager.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../presentation/providers/theme_provider.dart'; import '../presentation/providers/theme_provider.dart';
import 'sync_service.dart';
import '../src/rust/api/ffi.dart' as ffi;
/// + /// +
class DesktopService with TrayListener, WindowListener { class DesktopService with TrayListener, WindowListener {
@@ -188,6 +190,22 @@ class DesktopService with TrayListener, WindowListener {
try { try {
AppLogger.d('DesktopService: Cleaning up before exit...'); AppLogger.d('DesktopService: Cleaning up before exit...');
// WCF sync root
try {
await SyncService.instance.stop();
AppLogger.d('DesktopService: Sync engine stopped');
} catch (e) {
AppLogger.e('DesktopService: Error stopping sync: $e');
}
// 退 WCF tokio runtime
try {
await ffi.syncShutdown();
AppLogger.d('DesktopService: Sync shutdown complete');
} catch (e) {
AppLogger.e('DesktopService: Error in sync shutdown: $e');
}
// //
windowManager.removeListener(this); windowManager.removeListener(this);
trayManager.removeListener(this); trayManager.removeListener(this);
+32
View File
@@ -0,0 +1,32 @@
import 'dart:async';
///
///
///
/// /
class DialogQueueService {
DialogQueueService._();
static final DialogQueueService instance = DialogQueueService._();
Future<void> _tail = Future<void>.value();
Future<T?> enqueue<T>(Future<T?> Function() task) {
final completer = Completer<T?>();
_tail = _tail.catchError((_) {}).then((_) async {
try {
final result = await task();
if (!completer.isCompleted) {
completer.complete(result);
}
} catch (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
}
});
return completer.future;
}
}
+338
View File
@@ -0,0 +1,338 @@
import 'package:dio/dio.dart';
import 'api_service.dart';
import '../core/utils/file_type_utils.dart';
import '../core/utils/file_utils.dart';
import '../data/models/file_model.dart';
class FileAppViewer {
final String id;
final String type;
final String displayName;
final List<String> exts;
final String? icon;
final int maxSize;
final String? url;
const FileAppViewer({
required this.id,
required this.type,
required this.displayName,
required this.exts,
this.icon,
this.maxSize = 0,
this.url,
});
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
return FileAppViewer(
id: json['id']?.toString() ?? '',
type: json['type']?.toString() ?? '',
displayName: json['display_name']?.toString() ??
json['displayName']?.toString() ??
json['name']?.toString() ??
'文件应用',
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
icon: json['icon']?.toString(),
maxSize: (json['max_size'] as num?)?.toInt() ??
(json['maxSize'] as num?)?.toInt() ??
0,
url: json['url']?.toString(),
);
}
static List<String> _parseExts(dynamic raw) {
if (raw is List) {
return raw
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (raw is String) {
return raw
.split(',')
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
.where((e) => e.isNotEmpty)
.toList();
}
return const [];
}
bool supports(FileModel file) {
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
if (ext.isEmpty || !exts.contains(ext)) return false;
if (maxSize > 0 && file.size > maxSize) return false;
return id.isNotEmpty;
}
bool get isWopi => type.toLowerCase().contains('wopi');
}
class FileAppSession {
final FileAppViewer viewer;
final String wopiSrc;
final String? accessToken;
final int? expires;
const FileAppSession({
required this.viewer,
required this.wopiSrc,
this.accessToken,
this.expires,
});
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
}
/// Cloudreve V4
///
/// Cloudreve V4 `/site/config` `/site/config/basic`
/// `/site/config/basic` file_viewers
/// `/site/config` 404 WebView
class FileAppService {
FileAppService._();
static final FileAppService instance = FileAppService._();
List<FileAppViewer>? _cachedViewers;
DateTime? _cachedAt;
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
final cached = _cachedViewers;
final cachedAt = _cachedAt;
if (!forceRefresh &&
cached != null &&
cachedAt != null &&
DateTime.now().difference(cachedAt).inMinutes < 10) {
return cached;
}
final viewers = await _loadViewersFromSiteConfig();
_cachedViewers = viewers;
_cachedAt = DateTime.now();
return viewers;
}
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
// /site/config/basic
// /site/config Cloudreve 404
const endpoints = <String>[
'/site/config/basic',
'/site/config',
];
for (final endpoint in endpoints) {
final root = await _tryGetSiteConfig(endpoint);
if (root == null) continue;
final viewers = _extractViewers(root);
if (viewers.isNotEmpty) {
return viewers;
}
}
return const [];
}
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
try {
final response = await ApiService.instance.dio.get<dynamic>(
endpoint,
options: Options(
extra: {'noAuth': true},
// 404
validateStatus: (status) => status != null && status >= 200 && status < 500,
),
);
if (response.statusCode == 404) {
return null;
}
final raw = response.data;
final map = _asMap(raw);
if (map == null) return null;
final code = map['code'];
if (code is int && code != 0 && code != 203) {
return null;
}
return _asMap(map['data']) ?? map;
} catch (_) {
return null;
}
}
Future<FileAppViewer?> findViewerForFile(
FileModel file, {
bool forceRefresh = false,
}) async {
final viewers = await getViewers(forceRefresh: forceRefresh);
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
if (candidates.isEmpty) return null;
candidates.sort((a, b) {
final aw = a.isWopi ? 0 : 1;
final bw = b.isWopi ? 0 : 1;
if (aw != bw) return aw.compareTo(bw);
final aExact = a.exts.contains(ext) ? 0 : 1;
final bExact = b.exts.contains(ext) ? 0 : 1;
return aExact.compareTo(bExact);
});
return candidates.first;
}
Future<FileAppSession> createViewerSession({
required FileModel file,
required FileAppViewer viewer,
String preferredAction = 'view',
}) async {
final uri = FileUtils.toCloudreveUri(file.relativePath);
final data = <String, dynamic>{
'uri': uri,
'viewer_id': viewer.id,
'preferred_action': preferredAction,
'parent_uri': _parentUri(uri),
};
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/file/viewerSession',
data: data,
);
final root = _asMap(response['data']) ?? response;
final session = _asMap(root['session']);
final wopiSrc = root['wopi_src']?.toString() ??
root['wopiSrc']?.toString() ??
root['url']?.toString() ??
'';
if (wopiSrc.isEmpty) {
throw Exception('文件应用没有返回可打开的 URL');
}
return FileAppSession(
viewer: viewer,
wopiSrc: _absoluteUrl(wopiSrc),
accessToken: session?['access_token']?.toString() ??
session?['accessToken']?.toString(),
expires: (session?['expires'] as num?)?.toInt(),
);
}
Future<FileAppSession> openFile({
required FileModel file,
String preferredAction = 'view',
}) async {
final viewer = await findViewerForFile(file);
if (viewer == null) {
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
}
try {
return await createViewerSession(
file: file,
viewer: viewer,
preferredAction: preferredAction,
);
} catch (_) {
if (preferredAction != 'view') {
return createViewerSession(
file: file,
viewer: viewer,
preferredAction: 'view',
);
}
rethrow;
}
}
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
final raw = _findValueByKey(root, const [
'file_viewers',
'fileViewers',
'file_viewer',
'viewers',
]);
dynamic viewerList = raw;
final rawMap = _asMap(raw);
if (rawMap != null) {
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
}
if (viewerList is! List) return const [];
return viewerList
.whereType<Map>()
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
.toList();
}
dynamic _findValueByKey(dynamic value, List<String> keys) {
if (value is Map) {
for (final key in keys) {
if (value.containsKey(key)) {
return value[key];
}
}
for (final child in value.values) {
final found = _findValueByKey(child, keys);
if (found != null) return found;
}
} else if (value is List) {
for (final child in value) {
final found = _findValueByKey(child, keys);
if (found != null) return found;
}
}
return null;
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
String _parentUri(String uri) {
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
final index = normalized.lastIndexOf('/');
if (index <= 'cloudreve://my'.length) {
return 'cloudreve://my';
}
return normalized.substring(0, index);
}
String _absoluteUrl(String url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
final origin = Uri(
scheme: base.scheme,
host: base.host,
port: base.hasPort ? base.port : null,
).toString();
if (url.startsWith('/')) {
return '$origin$url';
}
return '$origin/$url';
}
}
+906
View File
@@ -0,0 +1,906 @@
import 'package:dio/dio.dart';
import 'api_service.dart';
import '../core/utils/file_utils.dart';
class ShareLinkCandidate {
final String id;
final String url;
final String? password;
const ShareLinkCandidate({
required this.id,
required this.url,
this.password,
});
}
class ShareLinkInfo {
final String id;
final String name;
final int visited;
final bool expired;
final bool unlocked;
final bool isPrivate;
final int sourceType;
final String? sourceUri;
final int size;
final String? url;
final String? ownerName;
final String? ownerId;
final String? ownerAvatar;
final String? contextHint;
final DateTime? createdAt;
final DateTime? expires;
const ShareLinkInfo({
required this.id,
required this.name,
required this.visited,
required this.expired,
required this.unlocked,
required this.isPrivate,
required this.sourceType,
this.sourceUri,
this.size = 0,
this.url,
this.ownerName,
this.ownerId,
this.ownerAvatar,
this.contextHint,
this.createdAt,
this.expires,
});
bool get isFolder => sourceType == 1;
bool get isFile => !isFolder;
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
final owner = _asMap(json['owner']);
final source = _asMap(json['source']);
final file = _asMap(json['file']);
final object = _asMap(json['object']);
final entity = _asMap(json['entity']);
final rawSource = json['source'];
final ownerId = (owner?['id'] ??
json['owner_id'] ??
json['ownerId'] ??
source?['owner_id'] ??
file?['owner_id'])
?.toString();
final name = (json['name'] ??
file?['name'] ??
source?['name'] ??
object?['name'] ??
'分享文件')
.toString();
final parsedSourceUri = (json['source_uri'] ??
json['sourceUri'] ??
json['uri'] ??
json['path'] ??
(rawSource is String ? rawSource : null) ??
source?['uri'] ??
source?['source_uri'] ??
source?['path'] ??
source?['url'] ??
file?['uri'] ??
file?['source_uri'] ??
file?['path'] ??
object?['uri'] ??
object?['source_uri'] ??
object?['path'] ??
entity?['uri'] ??
entity?['source_uri'] ??
entity?['path'])
?.toString();
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
? parsedSourceUri
: _fallbackSourceUri(ownerId: ownerId, name: name);
final size = _asInt(json['size'] ??
json['source_size'] ??
json['sourceSize'] ??
source?['size'] ??
file?['size'] ??
object?['size'] ??
entity?['size']);
return ShareLinkInfo(
id: json['id']?.toString() ?? '',
name: name,
size: size,
visited: _asInt(json['visited']),
expired: json['expired'] as bool? ?? false,
unlocked: json['unlocked'] as bool? ?? false,
isPrivate: json['is_private'] as bool? ?? false,
sourceType: _asInt(json['source_type'] ??
source?['type'] ??
file?['type'] ??
object?['type']),
sourceUri: resolvedSourceUri,
url: json['url']?.toString(),
ownerName: owner?['nickname']?.toString() ??
owner?['name']?.toString() ??
json['owner_name']?.toString(),
ownerId: ownerId,
ownerAvatar: owner?['avatar']?.toString(),
contextHint: json['context_hint']?.toString() ??
json['contextHint']?.toString() ??
json['context']?.toString(),
createdAt: _parseDate(json['created_at']),
expires: _parseDate(json['expires']),
);
}
static bool _looksLikeCloudreveUri(String? value) {
if (value == null || value.trim().isEmpty) return false;
return value.trim().startsWith('cloudreve://');
}
static String? _fallbackSourceUri({
required String? ownerId,
required String name,
}) {
// owner@my my
// owner@my /file/url 40081 urls
// ShareLinkService.getShareInfo ID
// cloudreve://<shareId>@share/
return null;
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
static int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
static DateTime? _parseDate(dynamic value) {
final text = value?.toString();
if (text == null || text.isEmpty) return null;
return DateTime.tryParse(text);
}
}
class ShareLinkFile {
final int type;
final String id;
final String name;
final int size;
final String path;
final DateTime? createdAt;
final DateTime? updatedAt;
final String? primaryEntity;
final String? capability;
final String? contextHint;
final Map<String, dynamic>? metadata;
const ShareLinkFile({
required this.type,
required this.id,
required this.name,
required this.size,
required this.path,
this.createdAt,
this.updatedAt,
this.primaryEntity,
this.capability,
this.contextHint,
this.metadata,
});
bool get isFolder => type == 1;
bool get isFile => !isFolder;
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
return ShareLinkFile(
type: _asInt(json['type']),
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '未命名文件',
size: _asInt(json['size']),
path: json['path']?.toString() ?? '',
createdAt: _parseDate(json['created_at']),
updatedAt: _parseDate(json['updated_at']),
primaryEntity: json['primary_entity']?.toString() ??
json['entity']?.toString(),
capability: json['capability']?.toString(),
contextHint: json['context_hint']?.toString(),
metadata: _asMap(json['metadata']),
);
}
static int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
static DateTime? _parseDate(dynamic value) {
final text = value?.toString();
if (text == null || text.isEmpty) return null;
return DateTime.tryParse(text);
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
class ShareLinkFileListResult {
final List<ShareLinkFile> files;
final ShareLinkFile? parent;
final String? contextHint;
final bool hasMore;
final String? nextPageToken;
const ShareLinkFileListResult({
required this.files,
this.parent,
this.contextHint,
this.hasMore = false,
this.nextPageToken,
});
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
final rawFiles = json['files'];
final rawParent = json['parent'];
final pagination = ShareLinkService._asMap(json['pagination']);
return ShareLinkFileListResult(
files: rawFiles is List
? rawFiles
.whereType<Map>()
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
.toList()
: const [],
parent: rawParent is Map
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
: null,
contextHint: json['context_hint']?.toString(),
hasMore: pagination?['next_token'] != null,
nextPageToken: pagination?['next_token']?.toString(),
);
}
}
class ShareDownloadUrlResult {
final String url;
final DateTime? expires;
const ShareDownloadUrlResult({
required this.url,
this.expires,
});
}
class ShareLinkService {
ShareLinkService._();
static final ShareLinkService instance = ShareLinkService._();
/// Cloudreve /s/{id}/{password?}
static final RegExp _shareLinkRegExp = RegExp(
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
caseSensitive: false,
);
ShareLinkCandidate? parseShareLink(String? text) {
if (text == null || text.trim().isEmpty) return null;
final match = _shareLinkRegExp.firstMatch(text.trim());
if (match == null) return null;
final id = match.group(1);
if (id == null || id.isEmpty) return null;
final password = match.group(2);
final url = match.group(0)!;
return ShareLinkCandidate(
id: id,
url: url,
password: password == null || password.isEmpty ? null : password,
);
}
Future<ShareLinkInfo> getShareInfo(
ShareLinkCandidate candidate, {
String? password,
}) async {
final resolvedPassword = password ?? candidate.password;
final query = <String, dynamic>{
'count_views': true,
'owner_extended': true,
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
'password': resolvedPassword,
};
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/share/info/${candidate.id}',
queryParameters: query,
options: Options(
extra: const {'noAuth': true},
headers: const {
'X-Cr-Context-Hint': 'share',
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
final sourceType = ShareLinkInfo._asInt(data['source_type']);
// 访使 Cloudreve share URI
// cloudreve://{shareId}[:password]@share[/...]
// /share/info source_uri my
// /noAuth /file/url
// 40081 Entity not exist
final accessUri = shareRootUri(
shareId: candidate.id,
password: resolvedPassword,
trailingSlash: sourceType != 0,
);
return ShareLinkInfo.fromJson(<String, dynamic>{
...data,
'source_uri': accessUri,
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
headerContext != null &&
headerContext.isNotEmpty)
'context_hint': headerContext,
});
}
String? ownerAvatarUrl(ShareLinkInfo info) {
final ownerId = info.ownerId;
if (ownerId == null || ownerId.isEmpty) return null;
final avatar = info.ownerAvatar;
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
return avatar;
}
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
}
/// source_uri
///
/// Cloudreve V4 /file JWT Optional X-Cr-Context-Hint
/// `share` context_hint
/// context_hint
Future<ShareLinkFileListResult> listSharedFiles({
required String uri,
String? contextHint,
int page = 0,
int pageSize = 100,
String? nextPageToken,
}) async {
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/file',
queryParameters: <String, dynamic>{
'uri': uri,
'page': page,
'page_size': pageSize,
if (nextPageToken != null && nextPageToken.isNotEmpty)
'next_page_token': nextPageToken,
},
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'X-Cr-Context-Hint': _firstContextHint(contextHint),
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
return ShareLinkFileListResult.fromJson(<String, dynamic>{
...data,
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
headerContext != null &&
headerContext.isNotEmpty)
'context_hint': headerContext,
});
}
/// primary_entity
Future<ShareLinkFile> getSharedFileInfo({
required String uri,
String? contextHint,
String? shareId,
String? password,
}) async {
Object? lastError;
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
try {
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/file/info',
queryParameters: <String, dynamic>{
'uri': uri,
'extended': true,
},
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'X-Cr-Context-Hint': hint,
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
return ShareLinkFile.fromJson(<String, dynamic>{
...data,
'context_hint': data['context_hint'] ?? headerContext ?? hint,
});
} catch (e) {
lastError = e;
}
}
throw lastError ?? Exception('文件详情读取失败');
}
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
final sourceUri = info.sourceUri ??
ShareLinkInfo._fallbackSourceUri(
ownerId: info.ownerId,
name: info.name,
) ??
'';
return ShareLinkFile(
type: info.sourceType,
id: info.id,
name: info.name,
size: info.size,
path: sourceUri,
createdAt: info.createdAt,
updatedAt: info.createdAt,
contextHint: info.contextHint,
);
}
/// URL
///
/// Cloudreve V4 `/file/url`
/// body `uris` `archive: true`
/// 访 JWT Optional使 noAuth
/// Authorization
Future<ShareDownloadUrlResult> createShareDownloadUrl({
required String uri,
String? contextHint,
String? shareId,
String? password,
String? entity,
String? fileName,
bool archive = false,
}) async {
Object? lastError;
// Cloudreve V4 POST /file/url
// Header: X-Cr-Context-Hint
// Body: { "uris": [...], "archive": true? }
//
// token URI
// Entity not exist (40081)
for (final candidateUri in _uriCandidates(
uri,
shareId: shareId,
password: password,
fileName: fileName,
)) {
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
try {
return await _createShareDownloadUrlOnce(
uri: candidateUri,
contextHint: hint,
archive: archive,
);
} catch (e) {
lastError = e;
}
}
}
throw lastError ?? Exception('服务端没有返回可用的下载链接');
}
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
required String uri,
required String contextHint,
bool archive = false,
}) async {
final body = <String, dynamic>{
'uris': <String>[uri],
'download': true,
if (archive) 'archive': true,
};
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
'/file/url',
data: body,
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'Content-Type': 'application/json',
'X-Cr-Context-Hint': contextHint,
},
),
);
final raw = response.data ?? <String, dynamic>{};
final data = _asMap(raw['data']) ?? raw;
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
if (url != null && url.isNotEmpty) {
return ShareDownloadUrlResult(
url: url,
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
);
}
throw Exception('服务端没有返回可用的下载链接');
}
///
///
/// Cloudreve V4 使 /file/move/ copy=true
/// /
Future<void> saveSharedFiles({
required List<String> uris,
required String destination,
String? contextHint,
String? shareId,
String? password,
String? fileName,
}) async {
final dst = FileUtils.toCloudreveUri(destination);
Object? lastError;
final uriCandidateSets = uris
.map((uri) => _uriCandidates(
uri,
shareId: shareId,
password: password,
// source_uri cloudreve://{id}@share
// /file/move share
// cloudreve://{id}@share/{fileName} URI
fileName: uris.length == 1 ? fileName : null,
includeRootFallback: false,
)
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
.toList())
.where((candidates) => candidates.isNotEmpty)
.toList();
if (uriCandidateSets.isEmpty) {
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
}
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
if (candidateUris.isEmpty) continue;
try {
await ApiService.instance.post<void>(
'/file/move',
data: <String, dynamic>{
'uris': candidateUris,
'dst': dst,
'copy': true,
},
headers: <String, dynamic>{
'Content-Type': 'application/json',
'X-Cr-Context-Hint': hint,
},
);
return;
} catch (e) {
lastError = e;
}
}
}
throw lastError ?? Exception('转存失败');
}
static List<String> _uriCandidates(
String uri, {
String? shareId,
String? password,
String? fileName,
bool includeRootFallback = true,
}) {
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
final shareUriCandidates = _shareScopedUriCandidates(
uri,
shareId: shareId,
password: password,
);
// cloudreve://{shareId}@share
// /file cloudreve://my/... owner@my/...
// / share
for (final item in shareUriCandidates) {
add(item);
}
final name = fileName?.trim();
final id = shareId?.trim();
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
final root = shareRootUri(
shareId: id,
password: password,
trailingSlash: false,
);
add('$root/${Uri.encodeComponent(name)}');
add('$root/${_encodeCloudrevePath(name)}');
}
add(_withSharePassword(uri, shareId: shareId, password: password));
add(uri);
if (uri.startsWith('cloudreve://')) {
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
if (withPassword != uri) add(withPassword);
final slash = uri.indexOf('/', 'cloudreve://'.length);
if (slash > 0) {
final prefix = uri.substring(0, slash + 1);
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
if (path.isNotEmpty) {
final encodedPath = _encodeCloudrevePath(path);
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
add('$prefix$encodedPath');
}
if (!uri.endsWith('/')) {
add(_withSharePassword('$uri/', shareId: shareId, password: password));
add('$uri/');
}
}
}
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
}
return values;
}
static List<String> _shareScopedUriCandidates(
String uri, {
String? shareId,
String? password,
}) {
final id = shareId?.trim();
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
return const <String>[];
}
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
final authorityStart = 'cloudreve://'.length;
final slash = uri.indexOf('/', authorityStart);
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
final decodedPath = rawPath
.split('/')
.where((segment) => segment.isNotEmpty)
.map((segment) => Uri.decodeComponent(segment))
.join('/');
final isShareAuthority = authority.endsWith('@share');
if (isShareAuthority) {
add(_withSharePassword(uri, shareId: id, password: password));
add(uri);
return values;
}
if (decodedPath.isEmpty) return values;
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
// cloudreve://id@share/folder/file.ext
add('$root/${_encodeCloudrevePath(decodedPath)}');
// owner my share
// basename cloudreve://id@share/file.ext
if (pathParts.isNotEmpty) {
add('$root/${Uri.encodeComponent(pathParts.last)}');
}
// path Folder/Sub/File Folder share Sub/File
if (pathParts.length > 1) {
final withoutFirst = pathParts.skip(1).join('/');
add('$root/${_encodeCloudrevePath(withoutFirst)}');
}
return values;
}
static String _encodeCloudrevePath(String path) {
return path
.split('/')
.where((segment) => segment.isNotEmpty)
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
.join('/');
}
static String _withSharePassword(String uri, {String? shareId, String? password}) {
final pw = password?.trim();
final id = shareId?.trim();
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
if (!uri.startsWith('cloudreve://')) return uri;
final authorityStart = 'cloudreve://'.length;
final slash = uri.indexOf('/', authorityStart);
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
final rest = slash >= 0 ? uri.substring(slash) : '';
if (!authority.endsWith('@share')) return uri;
if (authority.contains(':')) return uri;
final encodedId = Uri.encodeComponent(id);
final encodedPw = Uri.encodeComponent(pw);
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
return 'cloudreve://$encodedId:$encodedPw@share$rest';
}
static String shareRootUri({
required String shareId,
String? password,
bool trailingSlash = true,
}) {
final id = Uri.encodeComponent(shareId.trim());
final pw = password?.trim();
final userInfo = pw == null || pw.isEmpty
? id
: '$id:${Uri.encodeComponent(pw)}';
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
}
static bool isShareRootUri(String uri, {String? shareId}) {
final text = uri.trim();
const prefix = 'cloudreve://';
if (!text.startsWith(prefix)) return false;
final authorityStart = prefix.length;
final slash = text.indexOf('/', authorityStart);
final authority = slash >= 0
? text.substring(authorityStart, slash)
: text.substring(authorityStart);
if (!authority.endsWith('@share')) return false;
final path = slash >= 0 ? text.substring(slash) : '';
if (path.isNotEmpty && path != '/') return false;
final expectedId = shareId?.trim();
if (expectedId == null || expectedId.isEmpty) return true;
final userInfo = authority.substring(0, authority.length - '@share'.length);
final rawId = userInfo.split(':').first;
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
}
static String? _extractDownloadUrl(Map<String, dynamic> data) {
final direct = data['url'] ??
data['download_url'] ??
data['downloadUrl'] ??
data['src'] ??
data['href'];
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
final rawUrls = data['urls'];
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
if (rawUrls is List) {
for (final item in rawUrls) {
if (item is String && item.trim().isNotEmpty) return item.trim();
final map = _asMap(item);
if (map == null) continue;
final url = _extractDownloadUrl(map);
if (url != null && url.isNotEmpty) return url;
}
}
if (rawUrls is Map) {
for (final item in rawUrls.values) {
if (item is String && item.trim().isNotEmpty) return item.trim();
final map = _asMap(item);
if (map == null) continue;
final url = _extractDownloadUrl(map);
if (url != null && url.isNotEmpty) return url;
}
}
return null;
}
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
if (sets.isEmpty) return const [[]];
var result = <List<String>>[const []];
for (final set in sets) {
final next = <List<String>>[];
for (final prefix in result) {
for (final item in set) {
next.add(<String>[...prefix, item]);
}
}
result = next;
}
return result;
}
static String _firstContextHint(String? contextHint) {
final value = contextHint?.trim();
return value == null || value.isEmpty ? 'share' : value;
}
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
add(contextHint);
add(shareId);
if (shareId != null && shareId.trim().isNotEmpty) {
add('share:${shareId.trim()}');
add('share_${shareId.trim()}');
}
add('share');
return values;
}
static String? _headerValue(Headers headers, String name) {
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
if (direct != null && direct.isNotEmpty) return direct;
final lower = name.toLowerCase();
for (final entry in headers.map.entries) {
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
return entry.value.first;
}
}
return null;
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
+139 -22
View File
@@ -1,7 +1,54 @@
import 'package:dio/dio.dart';
import 'api_service.dart'; import 'api_service.dart';
import '../core/exceptions/app_exception.dart';
import '../data/models/share_model.dart'; import '../data/models/share_model.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
///
enum SharePrincipalType { user, group }
/// /
class SharePrincipal {
final String id;
final String name;
final String? email;
final String? groupName;
final SharePrincipalType type;
const SharePrincipal({
required this.id,
required this.name,
required this.type,
this.email,
this.groupName,
});
factory SharePrincipal.userFromJson(Map<String, dynamic> json) {
final group = _asMap(json['group']);
return SharePrincipal(
id: json['id']?.toString() ?? '',
name: (json['nickname'] ?? json['email'] ?? json['id'] ?? '用户').toString(),
email: json['email']?.toString(),
groupName: group?['name']?.toString(),
type: SharePrincipalType.user,
);
}
factory SharePrincipal.groupFromJson(Map<String, dynamic> json) {
return SharePrincipal(
id: json['id']?.toString() ?? '',
name: (json['name'] ?? json['id'] ?? '用户组').toString(),
type: SharePrincipalType.group,
);
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
/// ///
class ShareService { class ShareService {
/// cloudreve URI /// cloudreve URI
@@ -17,33 +64,98 @@ class ShareService {
return 'cloudreve://my/$cleanPath'; return 'cloudreve://my/$cleanPath';
} }
/// ///
///
/// Cloudreve V4 PUT /share permissionsuri
/// is_privateshare_viewexpirepricepasswordshow_readme
Future<String> createShare({ Future<String> createShare({
required String uri, required String uri,
Map<String, dynamic>? permissions,
bool? isPrivate, bool? isPrivate,
bool? shareView, bool? shareView,
int? expire, int? expire,
int? downloads,
int? price, int? price,
String? password, String? password,
bool? showReadme, bool? showReadme,
}) async { }) async {
final data = <String, dynamic>{ final data = <String, dynamic>{
'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='}, 'permissions': permissions ?? {'anonymous': 'BQ==', 'everyone': 'AQ=='},
'uri': _toCloudreveUri(uri), 'uri': _toCloudreveUri(uri),
'is_private': ?isPrivate,
'share_view': ?shareView,
'expire': ?expire,
'price': ?price,
'password': ?password,
'show_readme': ?showReadme,
}; };
// , _parseResponse -> ApiResponse.fromJson
if (isPrivate != null) data['is_private'] = isPrivate;
if (shareView != null) data['share_view'] = shareView;
if (expire != null) data['expire'] = expire;
if (downloads != null) data['downloads'] = downloads;
if (price != null) data['price'] = price;
if (password != null && password.isNotEmpty) data['password'] = password;
if (showReadme != null) data['show_readme'] = showReadme;
final response = await ApiService.instance.put<Map<String, dynamic>>( final response = await ApiService.instance.put<Map<String, dynamic>>(
'/share', '/share',
data: data, data: data,
isNoData: true, isNoData: true,
); );
return response['data'] as String; final raw = response['data'];
return raw?.toString() ?? '';
}
///
Future<List<SharePrincipal>> searchUsers(String keyword) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) return const [];
final response = await ApiService.instance.get<dynamic>(
'/user/search',
queryParameters: {'keyword': trimmed},
);
return _extractList(response)
.whereType<Map>()
.map((e) => SharePrincipal.userFromJson(Map<String, dynamic>.from(e)))
.where((e) => e.id.isNotEmpty)
.toList();
}
///
///
/// Cloudreve Pro /group/list Pro null
///
Future<List<SharePrincipal>?> listGroups() async {
try {
final response = await ApiService.instance.get<dynamic>(
'/group/list',
silent404: true,
);
return _extractList(response)
.whereType<Map>()
.map((e) => SharePrincipal.groupFromJson(Map<String, dynamic>.from(e)))
.where((e) => e.id.isNotEmpty)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
} on AppException catch (e) {
if (e.code == 404) return null;
rethrow;
}
}
List<dynamic> _extractList(dynamic value) {
if (value is List) return value;
if (value is Map) {
final data = value['data'];
if (data is List) return data;
final items = value['items'];
if (items is List) return items;
final groups = value['groups'];
if (groups is List) return groups;
final users = value['users'];
if (users is List) return users;
}
return const [];
} }
/// ///
@@ -55,11 +167,11 @@ class ShareService {
}) async { }) async {
final queryParams = <String, dynamic>{ final queryParams = <String, dynamic>{
'page_size': pageSize, 'page_size': pageSize,
'order_by': ?orderBy,
'order_direction': ?orderDirection,
'next_page_token': ?nextPageToken,
}; };
// get, claude post, fixed if (orderBy != null) queryParams['order_by'] = orderBy;
if (orderDirection != null) queryParams['order_direction'] = orderDirection;
if (nextPageToken != null) queryParams['next_page_token'] = nextPageToken;
return await ApiService.instance.get<Map<String, dynamic>>( return await ApiService.instance.get<Map<String, dynamic>>(
'/share', '/share',
queryParameters: queryParams, queryParameters: queryParams,
@@ -79,13 +191,10 @@ class ShareService {
if (ownerExtended != null) { if (ownerExtended != null) {
queryParams['owner_extended'] = ownerExtended.toString(); queryParams['owner_extended'] = ownerExtended.toString();
} }
// GET
final response = await ApiService.instance.get<Map<String, dynamic>>( final response = await ApiService.instance.get<Map<String, dynamic>>(
'/share/info/$id', '/share/info/$id',
queryParameters: queryParams, queryParameters: queryParams,
); );
// response _parseResponse -> ApiResponse.fromJson , ['data']
// return ShareModel.fromJson(response['data'] as Map<String, dynamic>);
return ShareModel.fromJson(response); return ShareModel.fromJson(response);
} }
@@ -93,19 +202,26 @@ class ShareService {
Future<String> editShare({ Future<String> editShare({
required String id, required String id,
required String uri, required String uri,
Map<String, dynamic>? permissions,
bool? isPrivate, bool? isPrivate,
String? password, String? password,
bool? shareView, bool? shareView,
int? downloads, int? downloads,
int? expire, int? expire,
int? price,
bool? showReadme,
}) async { }) async {
final data = <String, dynamic>{ final data = <String, dynamic>{
'uri': uri, 'uri': uri,
'is_private': ?isPrivate,
'share_view': ?shareView,
'downloads': ?downloads,
'expire': ?expire,
}; };
if (permissions != null) data['permissions'] = permissions;
if (isPrivate != null) data['is_private'] = isPrivate;
if (shareView != null) data['share_view'] = shareView;
if (downloads != null) data['downloads'] = downloads;
if (expire != null) data['expire'] = expire;
if (price != null) data['price'] = price;
if (showReadme != null) data['show_readme'] = showReadme;
if (password != null && password.isNotEmpty) { if (password != null && password.isNotEmpty) {
data['password'] = password; data['password'] = password;
} }
@@ -116,7 +232,8 @@ class ShareService {
data: data, data: data,
isNoData: true, isNoData: true,
); );
return response['data'] as String; final raw = response['data'];
return raw?.toString() ?? '';
} }
/// ///
+52
View File
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../core/constants/storage_keys.dart'; import '../core/constants/storage_keys.dart';
import '../data/models/server_model.dart'; import '../data/models/server_model.dart';
@@ -149,4 +150,55 @@ class StorageService {
Future<void> clearSearchHistory() async { Future<void> clearSearchHistory() async {
await remove(StorageKeys.searchHistory); await remove(StorageKeys.searchHistory);
} }
// ===== =====
///
Future<bool> setSyncConfig(Map<String, dynamic> config) async {
try {
final json = jsonEncode(config);
return await setString(StorageKeys.syncConfig, json);
} catch (_) {
return false;
}
}
///
Future<Map<String, dynamic>?> getSyncConfig() async {
final json = await getString(StorageKeys.syncConfig);
if (json == null || json.isEmpty) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
///
Future<bool> setSyncState(String state) async {
return await setString(StorageKeys.syncState, state);
}
///
Future<String?> getSyncState() async {
return await getString(StorageKeys.syncState);
}
///
Future<void> clearSyncData() async {
await remove(StorageKeys.syncConfig);
await remove(StorageKeys.syncState);
}
// ===== client_id =====
/// client_id
Future<String> getOrCreateClientId() async {
var id = await getString(StorageKeys.clientId);
if (id == null || id.isEmpty) {
id = const Uuid().v4();
await setString(StorageKeys.clientId, id);
}
return id;
}
} }
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import '../core/utils/app_logger.dart';
import '../data/models/sync_config_model.dart';
import '../data/models/sync_event_model.dart';
import '../data/models/sync_status_model.dart';
import '../data/models/sync_task_model.dart';
import '../src/rust/api/ffi.dart' as ffi;
import '../src/rust/api/ffi_types.dart' as ffi_types;
/// - Flutter UI Rust
class SyncService {
static final SyncService instance = SyncService._();
SyncService._();
bool _initialized = false;
/// SyncProvider
final _eventController = StreamController<SyncEventModel>.broadcast();
Stream<SyncEventModel> get events => _eventController.stream;
///
Future<void> init(SyncConfigModel config) async {
if (_initialized) {
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok');
return;
}
AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}');
await ffi.initSyncEngine(config: config.toFfi());
_initialized = true;
AppLogger.d('[FFI] ← initSyncEngine: ok');
}
///
Future<SyncSummaryModel> startInitialSync() async {
AppLogger.d('[FFI] → startInitialSync');
final summary = await ffi.startInitialSync();
AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
return SyncSummaryModel.fromFfi(summary);
}
///
Future<void> startContinuousSync() async {
AppLogger.d('[FFI] → startContinuousSync');
await ffi.startContinuousSync();
AppLogger.d('[FFI] ← startContinuousSync: spawned');
}
///
Future<void> stop() async {
AppLogger.d('[FFI] → stopSync');
await ffi.stopSync();
AppLogger.d('[FFI] ← stopSync: ok');
}
///
Future<void> pause() async {
AppLogger.d('[FFI] → pauseSync');
await ffi.pauseSync();
AppLogger.d('[FFI] ← pauseSync: ok');
}
///
Future<void> resume() async {
AppLogger.d('[FFI] → resumeSync');
await ffi.resumeSync();
AppLogger.d('[FFI] ← resumeSync: ok');
}
///
Future<SyncSummaryModel> forceSync() async {
AppLogger.d('[FFI] → forceSync');
final summary = await ffi.forceSync();
AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
return SyncSummaryModel.fromFfi(summary);
}
/// Token Rust
Future<void> updateTokens(String accessToken) async {
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
await ffi.updateTokens(accessToken: accessToken);
AppLogger.d('[FFI] ← updateTokens: ok');
}
/// Rust
Future<void> updateConfig(SyncConfigModel config) async {
if (!_initialized) return;
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
try {
await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok');
} catch (e) {
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
}
}
// ========== / trace ==========
/// trace
Future<SyncStatusModel> getStatus() async {
final status = await ffi.getSyncStatus();
AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}');
return SyncStatusModel.fromFfi(status);
}
/// Worker trace
Future<int> getActiveWorkerCount() async {
final count = await ffi.getActiveWorkerCount();
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
return count;
}
/// trace
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
final tasks = await ffi.getActiveTasks();
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel(
id: t.id,
trigger: t.trigger,
totalCount: t.totalCount,
completedCount: t.completedCount,
failedCount: t.failedCount,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
finishedAt: t.finishedAt,
)).toList();
}
/// trace
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
final tasks = await ffi.getRecentTasks(limit: limit);
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel(
id: t.id,
trigger: t.trigger,
totalCount: t.totalCount,
completedCount: t.completedCount,
failedCount: t.failedCount,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
finishedAt: t.finishedAt,
)).toList();
}
/// trace
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
final items = await ffi.getTaskDetail(taskId: taskId);
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
return items.map((i) => SyncTaskItemModel(
id: i.id.toInt(),
taskId: i.taskId,
relativePath: i.relativePath,
actionType: i.actionType,
status: i.status,
fileSize: i.fileSize.toInt(),
errorMessage: i.errorMessage,
createdAt: i.createdAt,
updatedAt: i.updatedAt,
)).toList();
}
/// trace
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
required String taskId,
int limit = 20,
int offset = 0,
}) async {
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset');
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi(
taskId: taskId,
limit: limit,
offset: offset,
));
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
return items.map((i) => SyncTaskItemModel(
id: i.id.toInt(),
taskId: i.taskId,
relativePath: i.relativePath,
actionType: i.actionType,
status: i.status,
fileSize: i.fileSize.toInt(),
errorMessage: i.errorMessage,
createdAt: i.createdAt,
updatedAt: i.updatedAt,
)).toList();
}
// ========== debug ==========
/// (Windows CFAPi)
Future<void> hydrateFile(String localPath) async {
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
await ffi.hydrateFile(localPath: localPath);
AppLogger.d('[FFI] ← hydrateFile: ok');
}
/// (Android)
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}');
return {
'dcimExists': result.dcimExists,
'picturesExists': result.picturesExists,
'dcimUri': result.dcimUri,
'picturesUri': result.picturesUri,
};
}
/// (Android)
Future<void> createCloudAlbumDirs(String baseUri) async {
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
await ffi.createCloudAlbumDirs(baseUri: baseUri);
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
}
///
Future<void> dispose() async {
AppLogger.d('[FFI] → disposeSyncEngine');
await _eventController.close();
await ffi.disposeSyncEngine();
_initialized = false;
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
}
///
Future<void> setLogLevel(String level) async {
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
await ffi.setSyncLogLevel(level: level);
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
}
/// DB
Future<void> resetSync() async {
AppLogger.d('[FFI] → resetSync');
await ffi.resetSync();
AppLogger.d('[FFI] ← resetSync: ok');
}
}
+434
View File
@@ -0,0 +1,434 @@
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/constants/storage_keys.dart';
import '../core/utils/app_logger.dart';
import '../data/models/app_update_model.dart';
import 'storage_service.dart';
class UpdateService extends ChangeNotifier {
UpdateService._()
: _dio = Dio(_dioOptions),
_packageInfoProvider = PackageInfo.fromPlatform;
@visibleForTesting
UpdateService.test({
Dio? dio,
Future<PackageInfo> Function()? packageInfoProvider,
}) : _dio = dio ?? Dio(_dioOptions),
_packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform;
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',
);
static final Uri downloadsPageUrl = Uri.parse(
'https://www.gongyun.org/downloads.html',
);
static final BaseOptions _dioOptions = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 20),
followRedirects: true,
responseType: ResponseType.plain,
);
final Dio _dio;
final Future<PackageInfo> Function() _packageInfoProvider;
bool _checking = false;
AppUpdateInfo? _availableUpdate;
AppUpdateInfo? get availableUpdate => _availableUpdate;
bool get hasUpdate => _availableUpdate != null;
Future<AppUpdateInfo?> checkForUpdate() async {
if (_checking) return null;
_checking = true;
try {
final current = await _packageInfoProvider();
final latest = await _fetchLatestRelease(currentVersion: current.version);
if (latest == null ||
_compareVersions(latest.version, current.version) <= 0) {
_setAvailableUpdate(null);
return null;
}
_setAvailableUpdate(latest);
return latest;
} catch (e, stackTrace) {
AppLogger.e('检查更新失败: $e\n$stackTrace');
return null;
} finally {
_checking = false;
}
}
Future<bool> shouldShowPrompt(AppUpdateInfo update) async {
final storage = StorageService.instance;
final skipVersion = await storage.getString(
StorageKeys.updatePromptSkipVersion,
);
final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil);
if (skipVersion != update.version || skipUntil == null) return true;
final now = DateTime.now().millisecondsSinceEpoch;
if (skipUntil > now) return false;
await storage.remove(StorageKeys.updatePromptSkipUntil);
await storage.remove(StorageKeys.updatePromptSkipVersion);
return true;
}
Future<void> skipPromptForFiveDays(AppUpdateInfo update) async {
final skipUntil = DateTime.now()
.add(const Duration(days: 5))
.millisecondsSinceEpoch;
final storage = StorageService.instance;
await storage.setString(
StorageKeys.updatePromptSkipVersion,
update.version,
);
await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil);
}
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<void> openDownloadsPage() async {
if (!await launchUrl(
downloadsPageUrl,
mode: LaunchMode.externalApplication,
)) {
throw Exception('无法打开下载页面: $downloadsPageUrl');
}
}
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;
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 _isReleaseAttachment(Uri uri) {
return uri.path.contains('/releases/download/');
}
bool _isSupportedPackage(Uri uri) {
final name = _fileNameFromUrl(uri).toLowerCase();
if (Platform.isAndroid) {
return name.endsWith('.apk');
}
if (Platform.isWindows) {
return name.endsWith('.exe') ||
name.endsWith('.msix') ||
name.endsWith('.msi') ||
name.endsWith('.zip');
}
if (Platform.isLinux) {
return name.endsWith('.appimage') ||
name.endsWith('.deb') ||
name.endsWith('.rpm') ||
name.endsWith('.tar.gz') ||
name.endsWith('.zip');
}
if (Platform.isMacOS) {
return name.endsWith('.dmg') ||
name.endsWith('.pkg') ||
name.endsWith('.zip');
}
return false;
}
List<Uri> _extractLinks(String html) {
final matches = RegExp(
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
caseSensitive: false,
).allMatches(html);
return matches
.map((m) => m.group(1) ?? m.group(0))
.whereType<String>()
.map(_absoluteUriOrNull)
.whereType<Uri>()
.toList();
}
Uri? _absoluteUriOrNull(String value) {
final cleaned = _decodeHtmlEntities(value.trim());
final parsed = Uri.tryParse(cleaned);
if (parsed == null) return null;
if (parsed.hasScheme) return parsed;
return releasesPageUrl.resolveUri(parsed);
}
String? _elementText(XmlElement element, String name) {
for (final child in element.children.whereType<XmlElement>()) {
if (child.name.qualified == name || child.name.local == name) {
return child.innerText.trim();
}
}
return null;
}
String? _versionFromText(String text) {
final match = RegExp(
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
).firstMatch(text);
return match?.group(1);
}
String? _stripHtml(String? value) {
if (value == null) return null;
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
return _decodeHtmlEntities(
withoutTags,
).replaceAll(RegExp(r'\s+'), ' ').trim();
}
String _decodeHtmlEntities(String value) {
return value
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&#43;', '+')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
DateTime? _parseRssDate(String? value) {
if (value == null || value.isEmpty) return null;
final direct = DateTime.tryParse(value);
if (direct != null) return direct;
try {
return HttpDate.parse(value);
} catch (_) {
return null;
}
}
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
final version = _compareVersions(b.version, a.version);
if (version != 0) return version;
final left = a.publishedAt;
final right = b.publishedAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
}
int _compareVersions(String a, String b) {
final left = _versionParts(a);
final right = _versionParts(b);
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
}
List<int> _versionParts(String version) {
final core = version.split(RegExp(r'[+\-]')).first;
return core
.split('.')
.map((part) => int.tryParse(part) ?? 0)
.toList(growable: false);
}
String _fileNameFromUrl(Uri uri) {
final segment = uri.pathSegments.isEmpty
? 'app_update'
: uri.pathSegments.last;
return Uri.decodeComponent(segment);
}
void _setAvailableUpdate(AppUpdateInfo? update) {
final previous = _availableUpdate;
if (previous?.version == update?.version &&
previous?.pageUrl == update?.pageUrl) {
return;
}
_availableUpdate = update;
notifyListeners();
}
@visibleForTesting
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
}
class _RssRelease {
final String version;
final String title;
final String? description;
final Uri pageUrl;
final List<Uri> candidates;
final DateTime? publishedAt;
const _RssRelease({
required this.version,
required this.title,
required this.pageUrl,
required this.candidates,
this.description,
this.publishedAt,
});
}
+114
View File
@@ -0,0 +1,114 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'ffi_types.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `album_result_to_ffi`, `apply_log_level`, `config_from_ffi`, `config_to_ffi`, `error_to_ffi`, `get_engine`, `status_to_ffi`, `summary_to_ffi`, `task_item_to_ffi`, `task_to_ffi`
///
Future<void> initSyncEngine({required SyncConfigFfi config}) =>
RustSyncApi.instance.api.crateApiFfiInitSyncEngine(config: config);
///
Future<void> disposeSyncEngine() =>
RustSyncApi.instance.api.crateApiFfiDisposeSyncEngine();
/// 退WCF
/// tokio runtime exit(0)
Future<void> syncShutdown() =>
RustSyncApi.instance.api.crateApiFfiSyncShutdown();
///
Future<SyncSummaryFfi> startInitialSync() =>
RustSyncApi.instance.api.crateApiFfiStartInitialSync();
///
Future<void> startContinuousSync() =>
RustSyncApi.instance.api.crateApiFfiStartContinuousSync();
///
Future<void> stopSync() => RustSyncApi.instance.api.crateApiFfiStopSync();
///
Future<void> pauseSync() => RustSyncApi.instance.api.crateApiFfiPauseSync();
///
Future<void> resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync();
///
Future<SyncSummaryFfi> forceSync() =>
RustSyncApi.instance.api.crateApiFfiForceSync();
/// DB
Future<void> resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync();
///
Future<SyncStatusFfi> getSyncStatus() =>
RustSyncApi.instance.api.crateApiFfiGetSyncStatus();
/// Worker
Future<int> getActiveWorkerCount() =>
RustSyncApi.instance.api.crateApiFfiGetActiveWorkerCount();
///
Future<SyncConfigFfi> getSyncConfig() =>
RustSyncApi.instance.api.crateApiFfiGetSyncConfig();
///
Future<void> updateSyncConfig({required SyncConfigFfi config}) =>
RustSyncApi.instance.api.crateApiFfiUpdateSyncConfig(config: config);
/// Dart Token Rust
Future<void> updateTokens({required String accessToken}) =>
RustSyncApi.instance.api.crateApiFfiUpdateTokens(accessToken: accessToken);
/// Windows
Future<void> hydrateFile({required String localPath}) =>
RustSyncApi.instance.api.crateApiFfiHydrateFile(localPath: localPath);
///
Future<void> syncAlbumToCloud({
required List<String> albumPaths,
required String remoteDcimUri,
}) => RustSyncApi.instance.api.crateApiFfiSyncAlbumToCloud(
albumPaths: albumPaths,
remoteDcimUri: remoteDcimUri,
);
/// DCIM/Pictures
Future<CloudAlbumCheckResultFfi> checkCloudAlbumDirs({
required String baseUri,
}) => RustSyncApi.instance.api.crateApiFfiCheckCloudAlbumDirs(baseUri: baseUri);
/// DCIM/Pictures
Future<void> createCloudAlbumDirs({required String baseUri}) =>
RustSyncApi.instance.api.crateApiFfiCreateCloudAlbumDirs(baseUri: baseUri);
/// RustDart
Stream<SyncEventFfi> registerSyncEventSink() =>
RustSyncApi.instance.api.crateApiFfiRegisterSyncEventSink();
///
Future<void> setSyncLogLevel({required String level}) =>
RustSyncApi.instance.api.crateApiFfiSetSyncLogLevel(level: level);
///
Future<List<SyncTaskFfi>> getActiveTasks() =>
RustSyncApi.instance.api.crateApiFfiGetActiveTasks();
///
Future<List<SyncTaskFfi>> getRecentTasks({required int limit}) =>
RustSyncApi.instance.api.crateApiFfiGetRecentTasks(limit: limit);
///
Future<List<SyncTaskItemFfi>> getTaskDetail({required String taskId}) =>
RustSyncApi.instance.api.crateApiFfiGetTaskDetail(taskId: taskId);
///
Future<List<SyncTaskItemFfi>> queryTaskItems({
required TaskItemFilterFfi filter,
}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter);
+450
View File
@@ -0,0 +1,450 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'ffi_types.freezed.dart';
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`
/// Android:
class CloudAlbumCheckResultFfi {
final bool dcimExists;
final bool picturesExists;
final String? dcimUri;
final String? picturesUri;
const CloudAlbumCheckResultFfi({
required this.dcimExists,
required this.picturesExists,
this.dcimUri,
this.picturesUri,
});
@override
int get hashCode =>
dcimExists.hashCode ^
picturesExists.hashCode ^
dcimUri.hashCode ^
picturesUri.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CloudAlbumCheckResultFfi &&
runtimeType == other.runtimeType &&
dcimExists == other.dcimExists &&
picturesExists == other.picturesExists &&
dcimUri == other.dcimUri &&
picturesUri == other.picturesUri;
}
///
class SyncConfigFfi {
final String baseUrl;
final String accessToken;
final String refreshToken;
final String localRoot;
final String remoteRoot;
final String syncMode;
final String conflictStrategy;
final String wcfDeleteMode;
final int maxConcurrentTransfers;
final BigInt bandwidthLimitKbps;
final List<String> excludedPaths;
final int maxWorkers;
final String dataDir;
final String clientId;
final String logLevel;
const SyncConfigFfi({
required this.baseUrl,
required this.accessToken,
required this.refreshToken,
required this.localRoot,
required this.remoteRoot,
required this.syncMode,
required this.conflictStrategy,
required this.wcfDeleteMode,
required this.maxConcurrentTransfers,
required this.bandwidthLimitKbps,
required this.excludedPaths,
required this.maxWorkers,
required this.dataDir,
required this.clientId,
required this.logLevel,
});
@override
int get hashCode =>
baseUrl.hashCode ^
accessToken.hashCode ^
refreshToken.hashCode ^
localRoot.hashCode ^
remoteRoot.hashCode ^
syncMode.hashCode ^
conflictStrategy.hashCode ^
wcfDeleteMode.hashCode ^
maxConcurrentTransfers.hashCode ^
bandwidthLimitKbps.hashCode ^
excludedPaths.hashCode ^
maxWorkers.hashCode ^
dataDir.hashCode ^
clientId.hashCode ^
logLevel.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncConfigFfi &&
runtimeType == other.runtimeType &&
baseUrl == other.baseUrl &&
accessToken == other.accessToken &&
refreshToken == other.refreshToken &&
localRoot == other.localRoot &&
remoteRoot == other.remoteRoot &&
syncMode == other.syncMode &&
conflictStrategy == other.conflictStrategy &&
wcfDeleteMode == other.wcfDeleteMode &&
maxConcurrentTransfers == other.maxConcurrentTransfers &&
bandwidthLimitKbps == other.bandwidthLimitKbps &&
excludedPaths == other.excludedPaths &&
maxWorkers == other.maxWorkers &&
dataDir == other.dataDir &&
clientId == other.clientId &&
logLevel == other.logLevel;
}
@freezed
sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException {
const SyncErrorFfi._();
const factory SyncErrorFfi.notInitialized() = SyncErrorFfi_NotInitialized;
const factory SyncErrorFfi.networkError({required String message}) =
SyncErrorFfi_NetworkError;
const factory SyncErrorFfi.diskFull({
required BigInt needed,
required BigInt available,
}) = SyncErrorFfi_DiskFull;
const factory SyncErrorFfi.authError({required String message}) =
SyncErrorFfi_AuthError;
const factory SyncErrorFfi.conflictError({required int count}) =
SyncErrorFfi_ConflictError;
const factory SyncErrorFfi.internalError({required String message}) =
SyncErrorFfi_InternalError;
}
@freezed
sealed class SyncEventFfi with _$SyncEventFfi {
const SyncEventFfi._();
const factory SyncEventFfi.stateChanged({required String newState}) =
SyncEventFfi_StateChanged;
const factory SyncEventFfi.progress({
required BigInt synced,
required BigInt total,
required String currentFile,
}) = SyncEventFfi_Progress;
const factory SyncEventFfi.fileUploaded({
required String localPath,
required String remoteUri,
}) = SyncEventFfi_FileUploaded;
const factory SyncEventFfi.fileDownloaded({
required String localPath,
required String remoteUri,
}) = SyncEventFfi_FileDownloaded;
const factory SyncEventFfi.conflictDetected({
required String localPath,
required String conflictType,
}) = SyncEventFfi_ConflictDetected;
const factory SyncEventFfi.error({
required String message,
required bool recoverable,
}) = SyncEventFfi_Error;
const factory SyncEventFfi.tokenExpired() = SyncEventFfi_TokenExpired;
const factory SyncEventFfi.diskSpaceWarning({required BigInt availableMb}) =
SyncEventFfi_DiskSpaceWarning;
const factory SyncEventFfi.initialSyncComplete({
required SyncSummaryFfi summary,
}) = SyncEventFfi_InitialSyncComplete;
const factory SyncEventFfi.workerStarted({
required String taskId,
required String trigger,
required int uploadCount,
required int downloadCount,
}) = SyncEventFfi_WorkerStarted;
const factory SyncEventFfi.workerCompleted({
required String taskId,
required int uploaded,
required int downloaded,
required int renamed,
required int moved,
required int failed,
required BigInt durationMs,
}) = SyncEventFfi_WorkerCompleted;
const factory SyncEventFfi.workerFailed({
required String taskId,
required String message,
}) = SyncEventFfi_WorkerFailed;
const factory SyncEventFfi.taskItemUpdated({
required String taskId,
required String relativePath,
required String action,
required String status,
}) = SyncEventFfi_TaskItemUpdated;
}
///
class SyncStatusFfi {
final String state;
final BigInt syncedFiles;
final BigInt totalFiles;
final int uploadingCount;
final int downloadingCount;
final int conflictCount;
final int errorCount;
final String? lastSyncTime;
final String? errorMessage;
const SyncStatusFfi({
required this.state,
required this.syncedFiles,
required this.totalFiles,
required this.uploadingCount,
required this.downloadingCount,
required this.conflictCount,
required this.errorCount,
this.lastSyncTime,
this.errorMessage,
});
@override
int get hashCode =>
state.hashCode ^
syncedFiles.hashCode ^
totalFiles.hashCode ^
uploadingCount.hashCode ^
downloadingCount.hashCode ^
conflictCount.hashCode ^
errorCount.hashCode ^
lastSyncTime.hashCode ^
errorMessage.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncStatusFfi &&
runtimeType == other.runtimeType &&
state == other.state &&
syncedFiles == other.syncedFiles &&
totalFiles == other.totalFiles &&
uploadingCount == other.uploadingCount &&
downloadingCount == other.downloadingCount &&
conflictCount == other.conflictCount &&
errorCount == other.errorCount &&
lastSyncTime == other.lastSyncTime &&
errorMessage == other.errorMessage;
}
///
class SyncSummaryFfi {
final int uploaded;
final int downloaded;
final int renamed;
final int moved;
final int conflicts;
final int failed;
final int skipped;
final int deletedLocal;
final int deletedRemote;
final BigInt durationMs;
const SyncSummaryFfi({
required this.uploaded,
required this.downloaded,
required this.renamed,
required this.moved,
required this.conflicts,
required this.failed,
required this.skipped,
required this.deletedLocal,
required this.deletedRemote,
required this.durationMs,
});
@override
int get hashCode =>
uploaded.hashCode ^
downloaded.hashCode ^
renamed.hashCode ^
moved.hashCode ^
conflicts.hashCode ^
failed.hashCode ^
skipped.hashCode ^
deletedLocal.hashCode ^
deletedRemote.hashCode ^
durationMs.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncSummaryFfi &&
runtimeType == other.runtimeType &&
uploaded == other.uploaded &&
downloaded == other.downloaded &&
renamed == other.renamed &&
moved == other.moved &&
conflicts == other.conflicts &&
failed == other.failed &&
skipped == other.skipped &&
deletedLocal == other.deletedLocal &&
deletedRemote == other.deletedRemote &&
durationMs == other.durationMs;
}
/// FFI
class SyncTaskFfi {
final String id;
final String trigger;
final int totalCount;
final int completedCount;
final int failedCount;
final String status;
final String createdAt;
final String updatedAt;
final String? finishedAt;
const SyncTaskFfi({
required this.id,
required this.trigger,
required this.totalCount,
required this.completedCount,
required this.failedCount,
required this.status,
required this.createdAt,
required this.updatedAt,
this.finishedAt,
});
@override
int get hashCode =>
id.hashCode ^
trigger.hashCode ^
totalCount.hashCode ^
completedCount.hashCode ^
failedCount.hashCode ^
status.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
finishedAt.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncTaskFfi &&
runtimeType == other.runtimeType &&
id == other.id &&
trigger == other.trigger &&
totalCount == other.totalCount &&
completedCount == other.completedCount &&
failedCount == other.failedCount &&
status == other.status &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
finishedAt == other.finishedAt;
}
/// FFI
class SyncTaskItemFfi {
final PlatformInt64 id;
final String taskId;
final String relativePath;
final String actionType;
final String status;
final BigInt fileSize;
final String? errorMessage;
final String createdAt;
final String updatedAt;
const SyncTaskItemFfi({
required this.id,
required this.taskId,
required this.relativePath,
required this.actionType,
required this.status,
required this.fileSize,
this.errorMessage,
required this.createdAt,
required this.updatedAt,
});
@override
int get hashCode =>
id.hashCode ^
taskId.hashCode ^
relativePath.hashCode ^
actionType.hashCode ^
status.hashCode ^
fileSize.hashCode ^
errorMessage.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncTaskItemFfi &&
runtimeType == other.runtimeType &&
id == other.id &&
taskId == other.taskId &&
relativePath == other.relativePath &&
actionType == other.actionType &&
status == other.status &&
fileSize == other.fileSize &&
errorMessage == other.errorMessage &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt;
}
/// FFI
class TaskItemFilterFfi {
final String? taskId;
final String? relativePathContains;
final String? actionType;
final String? status;
final int limit;
final int offset;
const TaskItemFilterFfi({
this.taskId,
this.relativePathContains,
this.actionType,
this.status,
required this.limit,
required this.offset,
});
@override
int get hashCode =>
taskId.hashCode ^
relativePathContains.hashCode ^
actionType.hashCode ^
status.hashCode ^
limit.hashCode ^
offset.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskItemFilterFfi &&
runtimeType == other.runtimeType &&
taskId == other.taskId &&
relativePathContains == other.relativePathContains &&
actionType == other.actionType &&
status == other.status &&
limit == other.limit &&
offset == other.offset;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/ffi.dart';
import 'api/ffi_types.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
RustSyncApiApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
dynamic raw,
);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
@protected
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
@protected
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
@protected
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
@protected
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
@protected
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
@protected
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
@protected
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
@protected
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
@protected
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
@protected
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
@protected
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
SseDeserializer deserializer,
);
@protected
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
SseDeserializer deserializer,
);
@protected
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
SseDeserializer deserializer,
);
@protected
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
SseDeserializer deserializer,
);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
@protected
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
SseDeserializer deserializer,
);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
@protected
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
@protected
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
@protected
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
@protected
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
@protected
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
@protected
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
@protected
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self,
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_sync_event_ffi_Sse(
RustStreamSink<SyncEventFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_sync_config_ffi(
SyncConfigFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_sync_summary_ffi(
SyncSummaryFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_task_item_filter_ffi(
TaskItemFilterFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_cloud_album_check_result_ffi(
CloudAlbumCheckResultFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_ffi(
List<SyncTaskFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_item_ffi(
List<SyncTaskItemFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_summary_ffi(
SyncSummaryFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_task_item_ffi(
SyncTaskItemFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_task_item_filter_ffi(
TaskItemFilterFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
}
// Section: wire_class
class RustSyncApiWire implements BaseWire {
factory RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib) =>
RustSyncApiWire(lib.ffiDynamicLibrary);
/// Holds the symbol lookup function.
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustSyncApiWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}
+328
View File
@@ -0,0 +1,328 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
// Static analysis wrongly picks the IO variant, thus ignore this
// ignore_for_file: argument_type_not_assignable
import 'api/ffi.dart';
import 'api/ffi_types.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
RustSyncApiApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
dynamic raw,
);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
@protected
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
@protected
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
@protected
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
@protected
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
@protected
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
@protected
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
@protected
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
@protected
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
@protected
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
@protected
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
@protected
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
SseDeserializer deserializer,
);
@protected
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
SseDeserializer deserializer,
);
@protected
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
SseDeserializer deserializer,
);
@protected
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
SseDeserializer deserializer,
);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
@protected
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
SseDeserializer deserializer,
);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
@protected
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
@protected
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
@protected
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
@protected
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
@protected
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
@protected
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
@protected
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self,
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_sync_event_ffi_Sse(
RustStreamSink<SyncEventFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_sync_config_ffi(
SyncConfigFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_sync_summary_ffi(
SyncSummaryFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_task_item_filter_ffi(
TaskItemFilterFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_cloud_album_check_result_ffi(
CloudAlbumCheckResultFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_ffi(
List<SyncTaskFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_sync_task_item_ffi(
List<SyncTaskItemFfi> self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_summary_ffi(
SyncSummaryFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_task_item_ffi(
SyncTaskItemFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_task_item_filter_ffi(
TaskItemFilterFfi self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
}
// Section: wire_class
class RustSyncApiWire implements BaseWire {
RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib);
}
@JS('wasm_bindgen')
external RustSyncApiWasmModule get wasmModule;
@JS()
@anonymous
extension type RustSyncApiWasmModule._(JSObject _) implements JSObject {}
+33
View File
@@ -126,3 +126,36 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime) COMPONENT Runtime)
endif() endif()
# === Rust Sync Engine ===
find_program(CARGO_CMD cargo)
if(NOT CARGO_CMD)
message(WARNING "cargo not found, skipping Rust sync engine build")
else()
set(SYNC_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../native")
if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "Profile")
set(CARGO_PROFILE "--release")
set(RUST_BUILD_DIR "release")
else()
set(CARGO_PROFILE "")
set(RUST_BUILD_DIR "debug")
endif()
set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_target")
add_custom_command(
OUTPUT "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so"
COMMAND ${CARGO_CMD} build ${CARGO_PROFILE}
--manifest-path "${SYNC_CORE_DIR}/Cargo.toml"
--target-dir "${RUST_TARGET_DIR}"
COMMENT "Building Rust sync engine..."
)
add_custom_target(sync_core_build DEPENDS "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so")
add_dependencies(${BINARY_NAME} sync_core_build)
install(FILES "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
@@ -9,6 +9,7 @@
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h> #include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_inappwebview_linux/flutter_inappwebview_linux_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h> #include <open_file_linux/open_file_linux_plugin.h>
@@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar = g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar); flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar);
g_autoptr(FlPluginRegistrar) flutter_inappwebview_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterInappwebviewLinuxPlugin");
flutter_inappwebview_linux_plugin_register_with_registrar(flutter_inappwebview_linux_registrar);
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop desktop_drop
file_selector_linux file_selector_linux
flutter_acrylic flutter_acrylic
flutter_inappwebview_linux
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
open_file_linux open_file_linux
+3056
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[workspace]
resolver = "2"
members = [
"sync-core",
"sync-windows",
"sync-linux",
"sync-android",
]
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
+5
View File
@@ -0,0 +1,5 @@
rust_input: crate::api
rust_root: sync-core/
dart_output: D:/flutter_projects/cloudreve4_flutter/lib/src/rust/
dart_entrypoint_class_name: RustSyncApi
dart_enums_style: true
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "sync-android"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["lib"]
[dependencies]
# 不依赖 sync-core,避免循环依赖
jni = { version = "0.21", features = ["invocation"] }
thiserror = { workspace = true }
tracing = "0.1"
anyhow = { workspace = true }
tokio = { workspace = true }
async-trait = "0.1"
+106
View File
@@ -0,0 +1,106 @@
#![cfg(target_os = "android")]
//! Android 平台适配层
//!
//! 提供相册同步辅助功能:
//! - 文件类型过滤(仅图片/视频)
//! - 路径验证
//!
//! 实际的扫描由 Dart 侧通过平台通道完成,
//! 上传和状态管理由 sync-core 的 sync_album 处理
use std::path::Path;
/// 图片扩展名
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif", "raw", "tiff", "svg",
];
/// 视频扩展名
const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "mov", "avi", "mkv", "wmv", "flv", "3gp", "webm",
];
pub struct AndroidAdapter;
impl Default for AndroidAdapter {
fn default() -> Self {
Self::new()
}
}
impl AndroidAdapter {
pub fn new() -> Self {
Self
}
/// 判断文件是否为媒体文件(图片或视频)
pub fn is_media_file(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
}
/// 判断文件是否为图片
pub fn is_image_file(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
IMAGE_EXTENSIONS.contains(&ext.as_str())
}
/// 判断文件是否为视频
pub fn is_video_file(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
VIDEO_EXTENSIONS.contains(&ext.as_str())
}
/// 从路径列表中过滤出媒体文件
pub fn filter_media_files(paths: &[String]) -> Vec<String> {
paths.iter()
.filter(|p| Self::is_media_file(p))
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_media_file() {
assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.jpg"));
assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.JPEG"));
assert!(AndroidAdapter::is_video_file("/sdcard/DCIM/VID_001.mp4"));
assert!(!AndroidAdapter::is_media_file("/sdcard/Download/doc.pdf"));
assert!(AndroidAdapter::is_media_file("/sdcard/Pictures/photo.webp"));
assert!(AndroidAdapter::is_media_file("/sdcard/DCIM/photo.heic"));
}
#[test]
fn test_filter_media_files() {
let paths = vec![
"/sdcard/DCIM/IMG_001.jpg".to_string(),
"/sdcard/Download/doc.pdf".to_string(),
"/sdcard/DCIM/VID_001.mp4".to_string(),
"/sdcard/Music/song.mp3".to_string(),
];
let filtered = AndroidAdapter::filter_media_files(&paths);
assert_eq!(filtered.len(), 2);
assert!(filtered.contains(&"/sdcard/DCIM/IMG_001.jpg".to_string()));
assert!(filtered.contains(&"/sdcard/DCIM/VID_001.mp4".to_string()));
}
}
+83
View File
@@ -0,0 +1,83 @@
[package]
name = "sync-core"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
# FRB
flutter_rust_bridge = "=2.12.0"
# 异步运行时
tokio = { workspace = true }
# HTTP
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false }
# 序列化
serde = { workspace = true }
serde_json = { workspace = true }
# 数据库
rusqlite = { version = "0.32", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.25"
# 文件系统
walkdir = "2"
sha2 = "0.10"
hex = "0.4"
notify-debouncer-full = "0.7.0"
# 错误处理
thiserror = { workspace = true }
anyhow = { workspace = true }
# 日志
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 并发
dashmap = "6"
# 工具
uuid = { workspace = true }
chrono = { workspace = true }
parking_lot = { workspace = true }
futures = "0.3"
futures-util = "0.3"
bytes = "1"
filetime = "0.2"
async-trait = "0.1"
url = "2"
urlencoding = "2"
mime_guess = "2"
once_cell = "1"
tokio-util = { version = "0.7", features = ["compat"] }
# SSE
# (已移除 eventsource 0.5 依赖,使用内联 SSE 解析器,避免旧版 tokio/net2 警告)
# 平台依赖 (feature-gated)
[target.'cfg(windows)'.dependencies]
sync-windows = { path = "../sync-windows", optional = true }
windows = { version = "0.58", optional = true }
[target.'cfg(target_os = "linux")'.dependencies]
sync-linux = { path = "../sync-linux", optional = true }
[target.'cfg(target_os = "android")'.dependencies]
sync-android = { path = "../sync-android", optional = true }
jni = { version = "0.21", optional = true }
[features]
default = []
windows-cfapi = ["sync-windows", "windows"]
linux-notify = ["sync-linux"]
android-media = ["sync-android", "jni"]
event_sink_enabled = []
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
+622
View File
@@ -0,0 +1,622 @@
use flutter_rust_bridge::frb;
use std::sync::Arc;
use crate::api::ffi_types::*;
use crate::sync_engine::SyncEngine;
/// 全局引擎实例
static ENGINE: once_cell::sync::OnceCell<Arc<SyncEngine>> = once_cell::sync::OnceCell::new();
/// 全局日志级别重载句柄(支持运行时热修改)
static LOG_RELOAD_HANDLE: once_cell::sync::OnceCell<
tracing_subscriber::reload::Handle<tracing_subscriber::EnvFilter, tracing_subscriber::Registry>,
> = once_cell::sync::OnceCell::new();
// 内部类型 -> FFI 类型的转换函数
fn error_to_ffi(e: crate::errors::SyncError) -> SyncErrorFfi {
match e {
crate::errors::SyncError::Network(msg) => SyncErrorFfi::NetworkError { message: msg },
crate::errors::SyncError::DiskFull { needed, available } => {
SyncErrorFfi::DiskFull { needed, available }
}
crate::errors::SyncError::Auth(msg) => SyncErrorFfi::AuthError { message: msg },
crate::errors::SyncError::Conflict { count } => SyncErrorFfi::ConflictError { count },
crate::errors::SyncError::NotInitialized => SyncErrorFfi::NotInitialized,
_ => SyncErrorFfi::InternalError {
message: e.to_string(),
},
}
}
fn config_from_ffi(ffi: SyncConfigFfi) -> crate::models::SyncConfig {
use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode};
use std::path::PathBuf;
let sync_mode = match ffi.sync_mode.as_str() {
"upload_only" => SyncMode::UploadOnly,
"download_only" => SyncMode::DownloadOnly,
"album" => SyncMode::Album,
"mirror_wcf" => SyncMode::MirrorWcf,
_ => SyncMode::Full,
};
let conflict_strategy = match ffi.conflict_strategy.as_str() {
"keep_local" => ConflictStrategy::KeepLocal,
"keep_remote" => ConflictStrategy::KeepRemote,
"newest_wins" => ConflictStrategy::NewestWins,
"largest_wins" => ConflictStrategy::LargestWins,
"manual" => ConflictStrategy::Manual,
_ => ConflictStrategy::KeepBoth,
};
let wcf_delete_mode = match ffi.wcf_delete_mode.as_str() {
"wcf_delete_sync_remote" => WcfDeleteMode::SyncRemote,
_ => WcfDeleteMode::LocalOnly,
};
let bandwidth_limit = if ffi.bandwidth_limit_kbps > 0 {
Some(ffi.bandwidth_limit_kbps * 1024)
} else {
None
};
crate::models::SyncConfig {
base_url: ffi.base_url,
access_token: ffi.access_token,
refresh_token: ffi.refresh_token,
local_root: PathBuf::from(&ffi.local_root),
remote_root: ffi.remote_root,
sync_mode,
conflict_strategy,
wcf_delete_mode,
max_concurrent_transfers: ffi.max_concurrent_transfers as usize,
bandwidth_limit,
excluded_paths: ffi.excluded_paths,
max_workers: ffi.max_workers as usize,
data_dir: PathBuf::from(&ffi.data_dir),
client_id: ffi.client_id,
}
}
fn config_to_ffi(c: &crate::models::SyncConfig) -> SyncConfigFfi {
use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode};
let sync_mode = match c.sync_mode {
SyncMode::Full => "full",
SyncMode::UploadOnly => "upload_only",
SyncMode::DownloadOnly => "download_only",
SyncMode::Album => "album",
SyncMode::MirrorWcf => "mirror_wcf",
};
let conflict_strategy = match c.conflict_strategy {
ConflictStrategy::KeepLocal => "keep_local",
ConflictStrategy::KeepRemote => "keep_remote",
ConflictStrategy::KeepBoth => "keep_both",
ConflictStrategy::NewestWins => "newest_wins",
ConflictStrategy::LargestWins => "largest_wins",
ConflictStrategy::Manual => "manual",
};
let wcf_delete_mode = match c.wcf_delete_mode {
WcfDeleteMode::LocalOnly => "wcf_delete_local_only",
WcfDeleteMode::SyncRemote => "wcf_delete_sync_remote",
};
SyncConfigFfi {
base_url: c.base_url.clone(),
access_token: c.access_token.clone(),
refresh_token: c.refresh_token.clone(),
local_root: c.local_root.to_string_lossy().to_string(),
remote_root: c.remote_root.clone(),
sync_mode: sync_mode.to_string(),
conflict_strategy: conflict_strategy.to_string(),
wcf_delete_mode: wcf_delete_mode.to_string(),
max_concurrent_transfers: c.max_concurrent_transfers as u32,
bandwidth_limit_kbps: c.bandwidth_limit.map(|b| b / 1024).unwrap_or(0),
excluded_paths: c.excluded_paths.clone(),
max_workers: c.max_workers as u32,
data_dir: c.data_dir.to_string_lossy().to_string(),
client_id: c.client_id.clone(),
log_level: String::new(),
}
}
fn status_to_ffi(s: crate::models::SyncStatusSnapshot) -> SyncStatusFfi {
let error_msg = if let crate::models::SyncState::Error { ref message } = s.state {
Some(message.clone())
} else {
s.error_message
};
let state = match s.state {
crate::models::SyncState::Idle => "idle".to_string(),
crate::models::SyncState::Initializing => "initializing".to_string(),
crate::models::SyncState::InitialSync { .. } => "initialSync".to_string(),
crate::models::SyncState::Continuous => "continuous".to_string(),
crate::models::SyncState::Paused => "paused".to_string(),
crate::models::SyncState::Error { .. } => "error".to_string(),
crate::models::SyncState::Stopped => "stopped".to_string(),
};
SyncStatusFfi {
state,
synced_files: s.synced_files,
total_files: s.total_files,
uploading_count: s.uploading_count,
downloading_count: s.downloading_count,
conflict_count: s.conflict_count,
error_count: s.error_count,
last_sync_time: s.last_sync_time,
error_message: error_msg,
}
}
fn summary_to_ffi(s: crate::models::SyncSummary) -> SyncSummaryFfi {
SyncSummaryFfi {
uploaded: s.uploaded,
downloaded: s.downloaded,
renamed: s.renamed,
moved: s.moved,
conflicts: s.conflicts,
failed: s.failed,
skipped: s.skipped,
deleted_local: s.deleted_local,
deleted_remote: s.deleted_remote,
duration_ms: s.duration_ms,
}
}
fn album_result_to_ffi(r: crate::models::CloudAlbumCheckResult) -> CloudAlbumCheckResultFfi {
CloudAlbumCheckResultFfi {
dcim_exists: r.dcim_exists,
pictures_exists: r.pictures_exists,
dcim_uri: r.dcim_uri,
pictures_uri: r.pictures_uri,
}
}
/// 获取引擎引用,未初始化则返回错误
fn get_engine() -> Result<&'static SyncEngine, SyncErrorFfi> {
ENGINE.get().map(|arc| arc.as_ref()).ok_or(SyncErrorFfi::NotInitialized)
}
/// 内部:应用日志级别到 reload handle
fn apply_log_level(level: &str) {
if level.is_empty() {
return;
}
if let Some(handle) = LOG_RELOAD_HANDLE.get() {
let directive = format!("sync_core={}", level);
match handle.modify(|filter| {
*filter = tracing_subscriber::EnvFilter::new(&directive);
}) {
Ok(()) => eprintln!("[sync-core] 日志级别已切换为: {}", level),
Err(e) => eprintln!("[sync-core] 修改日志级别失败: {}", e),
}
}
}
// ========== 生命周期 ==========
/// 初始化同步引擎
#[frb]
pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> {
eprintln!("[FFI] init_sync_engine ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps, log_level={}",
config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers,
config.bandwidth_limit_kbps, config.log_level);
// 确保本地同步目录存在
let local_root = std::path::PathBuf::from(&config.local_root);
if !local_root.exists() {
std::fs::create_dir_all(&local_root).map_err(|e| SyncErrorFfi::InternalError {
message: format!("无法创建同步目录: {}", e),
})?;
}
// 确保程序数据目录存在
let data_dir = std::path::PathBuf::from(&config.data_dir);
let db_dir = data_dir.join("sync_core").join("datas");
let log_dir = data_dir.join("sync_core").join("logs");
if !db_dir.exists() {
std::fs::create_dir_all(&db_dir).map_err(|e| SyncErrorFfi::InternalError {
message: format!("无法创建数据库目录: {}", e),
})?;
}
if !log_dir.exists() {
std::fs::create_dir_all(&log_dir).map_err(|e| SyncErrorFfi::InternalError {
message: format!("无法创建日志目录: {}", e),
})?;
}
// 初始化 tracing 日志:输出到程序数据目录的 logs 和 stderr
let log_path = log_dir.join("sync_log.txt");
eprintln!("[sync-core] 日志文件: {}", log_path.display());
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
if log_file.is_none() {
eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display());
}
// 尝试初始化 subscriber(仅首次有效,后续调用忽略)
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let filter = tracing_subscriber::EnvFilter::from_default_env()
.add_directive("sync_core=debug".parse().unwrap());
let (reload_filter, reload_handle) =
tracing_subscriber::reload::Layer::<_, tracing_subscriber::Registry>::new(filter);
LOG_RELOAD_HANDLE.set(reload_handle).ok();
let registry = tracing_subscriber::registry().with(reload_filter);
if let Some(file) = log_file {
let _ = registry
.with(tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false))
.with(tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr))
.try_init();
} else {
let _ = registry
.with(tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr))
.try_init();
}
}
// 提取配置信息用于日志(在 move 之前)
let log_sync_mode = config.sync_mode.clone();
let log_conflict_strategy = config.conflict_strategy.clone();
let log_max_concurrent = config.max_concurrent_transfers;
let log_bandwidth = config.bandwidth_limit_kbps;
let log_level = config.log_level.clone();
let engine = SyncEngine::new(config_from_ffi(config)).await
.map_err(error_to_ffi)?;
ENGINE.set(Arc::new(engine))
.map_err(|_| SyncErrorFfi::InternalError {
message: "引擎已初始化".to_string(),
})?;
tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display());
tracing::info!(
"配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}kbps",
log_sync_mode, log_conflict_strategy, log_max_concurrent, log_bandwidth,
);
if log_bandwidth > 0 {
tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效");
}
// 应用配置中的日志级别(热修改覆盖默认 debug)
apply_log_level(&log_level);
Ok(())
}
/// 销毁同步引擎
#[frb]
pub async fn dispose_sync_engine() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] dispose_sync_engine ←");
let engine = get_engine()?;
engine.stop().await.map_err(error_to_ffi)?;
#[cfg(feature = "windows-cfapi")]
{
engine.cleanup_wcf();
}
tracing::info!("同步引擎已停止");
Ok(())
}
/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放)
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
#[frb]
pub fn sync_shutdown() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] sync_shutdown ←");
#[cfg(feature = "windows-cfapi")]
{
let engine = match ENGINE.get() {
Some(e) => e,
None => return Ok(()),
};
engine.cleanup_wcf();
}
tracing::info!("同步引擎已同步清理");
Ok(())
}
// ========== 同步控制 ==========
/// 执行初始全量同步
#[frb]
pub async fn start_initial_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
tracing::debug!("[FFI] start_initial_sync ←");
let engine = get_engine()?;
engine.ensure_token_fresh();
engine.run_initial_sync().await
.map(|s| {
tracing::debug!("[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
s.uploaded, s.downloaded, s.conflicts, s.failed);
summary_to_ffi(s)
})
.map_err(error_to_ffi)
}
/// 启动持续同步(后台运行,立即返回)
#[frb]
pub async fn start_continuous_sync() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] start_continuous_sync ←");
let engine = get_engine()?;
let engine = engine.clone();
tokio::spawn(async move {
if let Err(e) = engine.run_continuous().await {
tracing::error!("持续同步异常退出: {}", e);
}
});
tracing::debug!("[FFI] start_continuous_sync → spawned");
Ok(())
}
/// 停止同步
#[frb]
pub async fn stop_sync() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] stop_sync ←");
let engine = get_engine()?;
engine.stop().await.map_err(error_to_ffi)
}
/// 暂停同步
#[frb]
pub async fn pause_sync() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] pause_sync ←");
let engine = get_engine()?;
engine.pause().await.map_err(error_to_ffi)
}
/// 恢复同步
#[frb]
pub async fn resume_sync() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] resume_sync ←");
let engine = get_engine()?;
engine.resume().await.map_err(error_to_ffi)
}
/// 强制同步(重新扫描全量差异)
#[frb]
pub async fn force_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
tracing::debug!("[FFI] force_sync ←");
let engine = get_engine()?;
engine.force_sync().await
.map(|s| {
tracing::debug!("[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
s.uploaded, s.downloaded, s.conflicts, s.failed);
summary_to_ffi(s)
})
.map_err(error_to_ffi)
}
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
#[frb]
pub async fn reset_sync() -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] reset_sync ←");
let engine = get_engine()?;
engine.reset_sync().await.map_err(error_to_ffi)
}
// ========== 状态查询 ==========
/// 获取同步状态快照
#[frb]
pub async fn get_sync_status() -> Result<SyncStatusFfi, SyncErrorFfi> {
let engine = get_engine()?;
let s = engine.status();
tracing::trace!("[FFI] get_sync_status → state={:?}, synced={}, total={}", s.state, s.synced_files, s.total_files);
Ok(status_to_ffi(s))
}
/// 获取活跃 Worker 数量
#[frb]
pub async fn get_active_worker_count() -> Result<u32, SyncErrorFfi> {
let engine = get_engine()?;
let count = engine.active_worker_count();
tracing::trace!("[FFI] get_active_worker_count → {}", count);
Ok(count)
}
/// 获取同步配置
#[frb]
pub async fn get_sync_config() -> Result<SyncConfigFfi, SyncErrorFfi> {
let engine = get_engine()?;
let c = engine.config().await;
tracing::trace!("[FFI] get_sync_config → mode={:?}, conflict={:?}", c.sync_mode, c.conflict_strategy);
Ok(config_to_ffi(&c))
}
/// 更新同步配置
#[frb]
pub async fn update_sync_config(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] update_sync_config ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps",
config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, config.bandwidth_limit_kbps);
let engine = get_engine()?;
engine.update_config(config_from_ffi(config)).await.map_err(error_to_ffi)
}
// ========== Token 管理 ==========
/// Dart 推送新 Token 给 Rust
#[frb]
pub async fn update_tokens(access_token: String) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] update_tokens ← token_len={}", access_token.len());
let engine = get_engine()?;
engine.update_access_token(access_token).await;
Ok(())
}
// ========== Windows 专用 ==========
/// 水合文件(Windows 按需下载)
#[frb]
pub async fn hydrate_file(local_path: String) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] hydrate_file ← path={}", local_path);
let engine = get_engine()?;
engine.hydrate_file(&local_path).await.map_err(error_to_ffi)
}
// ========== Android 专用 ==========
/// 同步相册到云端
#[frb]
pub async fn sync_album_to_cloud(
album_paths: Vec<String>,
remote_dcim_uri: String,
) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] sync_album_to_cloud ← paths={}, uri={}", album_paths.len(), remote_dcim_uri);
let engine = get_engine()?;
engine.sync_album(album_paths, &remote_dcim_uri).await.map_err(error_to_ffi)
}
/// 检查云端是否存在 DCIM/Pictures 目录
#[frb]
pub async fn check_cloud_album_dirs(base_uri: String) -> Result<CloudAlbumCheckResultFfi, SyncErrorFfi> {
tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri);
let engine = get_engine()?;
engine.check_album_dirs(&base_uri).await
.map(|r| {
tracing::debug!("[FFI] check_cloud_album_dirs → dcim={}, pictures={}", r.dcim_exists, r.pictures_exists);
album_result_to_ffi(r)
})
.map_err(error_to_ffi)
}
/// 在云端创建 DCIM/Pictures 目录
#[frb]
pub async fn create_cloud_album_dirs(base_uri: String) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri);
let engine = get_engine()?;
engine.create_album_dirs(&base_uri).await.map_err(error_to_ffi)
}
// ========== 事件推送 ==========
/// 注册 Rust→Dart 事件推送通道
#[frb]
pub fn register_sync_event_sink(sink: crate::frb_generated::StreamSink<SyncEventFfi>) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] register_sync_event_sink ←");
let engine = get_engine()?;
// 使用 tokio runtime 注册
let rt = tokio::runtime::Handle::current();
rt.block_on(engine.register_event_sink(sink));
Ok(())
}
// ========== 日志级别 ==========
/// 运行时热修改日志级别(立即生效,无需重启)
#[frb]
pub fn set_sync_log_level(level: String) -> Result<(), SyncErrorFfi> {
eprintln!("[FFI] set_sync_log_level ← level={}", level);
let valid_levels = ["error", "warn", "info", "debug", "trace"];
let level_lower = level.to_lowercase();
if !valid_levels.contains(&level_lower.as_str()) {
return Err(SyncErrorFfi::InternalError {
message: format!("无效的日志级别: {}, 可选: {:?}", level, valid_levels),
});
}
if LOG_RELOAD_HANDLE.get().is_none() {
return Err(SyncErrorFfi::NotInitialized);
}
apply_log_level(&level_lower);
Ok(())
}
// ========== 任务查询 ==========
/// 获取活跃的同步任务列表
#[frb]
pub async fn get_active_tasks() -> Result<Vec<SyncTaskFfi>, SyncErrorFfi> {
let engine = get_engine()?;
let tasks = engine.get_active_tasks().await.map_err(error_to_ffi)?;
tracing::trace!("[FFI] get_active_tasks → count={}", tasks.len());
Ok(tasks.into_iter().map(task_to_ffi).collect())
}
/// 获取最近同步任务列表
#[frb]
pub async fn get_recent_tasks(limit: u32) -> Result<Vec<SyncTaskFfi>, SyncErrorFfi> {
tracing::trace!("[FFI] get_recent_tasks ← limit={}", limit);
let engine = get_engine()?;
let tasks = engine.get_recent_tasks(limit).await.map_err(error_to_ffi)?;
tracing::trace!("[FFI] get_recent_tasks → count={}", tasks.len());
Ok(tasks.into_iter().map(task_to_ffi).collect())
}
/// 获取任务详情(任务项列表)
#[frb]
pub async fn get_task_detail(task_id: String) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> {
tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id);
let engine = get_engine()?;
let items = engine.get_task_detail(&task_id).await.map_err(error_to_ffi)?;
tracing::trace!("[FFI] get_task_detail → count={}", items.len());
Ok(items.into_iter().map(task_item_to_ffi).collect())
}
/// 多维度查询任务项
#[frb]
pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> {
tracing::trace!("[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}",
filter.task_id, filter.action_type, filter.status, filter.limit);
let engine = get_engine()?;
let model_filter = crate::models::TaskItemFilter {
task_id: filter.task_id,
relative_path_contains: filter.relative_path_contains,
action_type: filter.action_type,
status: filter.status,
limit: filter.limit.max(1).min(1000),
offset: filter.offset,
};
let items = engine.query_task_items(&model_filter).await.map_err(error_to_ffi)?;
tracing::trace!("[FFI] query_task_items → count={}", items.len());
Ok(items.into_iter().map(task_item_to_ffi).collect())
}
fn task_to_ffi(t: crate::models::SyncTask) -> SyncTaskFfi {
SyncTaskFfi {
id: t.id,
trigger: t.trigger.as_str().to_string(),
total_count: t.total_count,
completed_count: t.completed_count,
failed_count: t.failed_count,
status: t.status.as_str().to_string(),
created_at: t.created_at,
updated_at: t.updated_at,
finished_at: t.finished_at,
}
}
fn task_item_to_ffi(i: crate::models::SyncTaskItem) -> SyncTaskItemFfi {
SyncTaskItemFfi {
id: i.id,
task_id: i.task_id,
relative_path: i.relative_path,
action_type: i.action_type.as_str().to_string(),
status: i.status.as_str().to_string(),
file_size: i.file_size,
error_message: i.error_message,
created_at: i.created_at,
updated_at: i.updated_at,
}
}
+164
View File
@@ -0,0 +1,164 @@
/// FFI 错误类型
#[derive(Debug, Clone)]
pub enum SyncErrorFfi {
NotInitialized,
NetworkError { message: String },
DiskFull { needed: u64, available: u64 },
AuthError { message: String },
ConflictError { count: u32 },
InternalError { message: String },
}
/// 同步配置
#[derive(Debug, Clone)]
pub struct SyncConfigFfi {
pub base_url: String,
pub access_token: String,
pub refresh_token: String,
pub local_root: String,
pub remote_root: String,
pub sync_mode: String,
pub conflict_strategy: String,
pub wcf_delete_mode: String,
pub max_concurrent_transfers: u32,
pub bandwidth_limit_kbps: u64,
pub excluded_paths: Vec<String>,
pub max_workers: u32,
pub data_dir: String,
pub client_id: String,
pub log_level: String,
}
/// 同步状态快照
#[derive(Debug, Clone)]
pub struct SyncStatusFfi {
pub state: String,
pub synced_files: u64,
pub total_files: u64,
pub uploading_count: u32,
pub downloading_count: u32,
pub conflict_count: u32,
pub error_count: u32,
pub last_sync_time: Option<String>,
pub error_message: Option<String>,
}
/// 初始同步摘要
#[derive(Debug, Clone)]
pub struct SyncSummaryFfi {
pub uploaded: u32,
pub downloaded: u32,
pub renamed: u32,
pub moved: u32,
pub conflicts: u32,
pub failed: u32,
pub skipped: u32,
pub deleted_local: u32,
pub deleted_remote: u32,
pub duration_ms: u64,
}
/// 同步事件(Rust → Dart 推送)
#[derive(Debug, Clone)]
pub enum SyncEventFfi {
StateChanged { new_state: String },
Progress {
synced: u64,
total: u64,
current_file: String,
},
FileUploaded {
local_path: String,
remote_uri: String,
},
FileDownloaded {
local_path: String,
remote_uri: String,
},
ConflictDetected {
local_path: String,
conflict_type: String,
},
Error {
message: String,
recoverable: bool,
},
TokenExpired,
DiskSpaceWarning { available_mb: u64 },
InitialSyncComplete { summary: SyncSummaryFfi },
// Worker 事件
WorkerStarted {
task_id: String,
trigger: String,
upload_count: u32,
download_count: u32,
},
WorkerCompleted {
task_id: String,
uploaded: u32,
downloaded: u32,
renamed: u32,
moved: u32,
failed: u32,
duration_ms: u64,
},
WorkerFailed {
task_id: String,
message: String,
},
TaskItemUpdated {
task_id: String,
relative_path: String,
action: String,
status: String,
},
}
/// Android: 云端相册目录检查结果
#[derive(Debug, Clone)]
pub struct CloudAlbumCheckResultFfi {
pub dcim_exists: bool,
pub pictures_exists: bool,
pub dcim_uri: Option<String>,
pub pictures_uri: Option<String>,
}
/// 同步任务摘要(FFI
#[derive(Debug, Clone)]
pub struct SyncTaskFfi {
pub id: String,
pub trigger: String,
pub total_count: u32,
pub completed_count: u32,
pub failed_count: u32,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub finished_at: Option<String>,
}
/// 同步任务项(FFI
#[derive(Debug, Clone)]
pub struct SyncTaskItemFfi {
pub id: i64,
pub task_id: String,
pub relative_path: String,
pub action_type: String,
pub status: String,
pub file_size: u64,
pub error_message: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 任务项查询过滤器(FFI
#[derive(Debug, Clone)]
pub struct TaskItemFilterFfi {
pub task_id: Option<String>,
pub relative_path_contains: Option<String>,
pub action_type: Option<String>,
pub status: Option<String>,
pub limit: u32,
pub offset: u32,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod ffi;
pub mod ffi_types;
+826
View File
@@ -0,0 +1,826 @@
use crate::errors::{Result, SyncError};
use crate::models::*;
use crate::server_error_code::api_code_to_error;
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 == 0 {
return Ok(api_resp.data.unwrap_or_default());
}
// 40073 锁冲突需要特殊处理 data
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 });
}
let msg = api_resp.msg
.filter(|m| !m.is_empty())
.unwrap_or_default();
Err(api_code_to_error(api_resp.code, &msg))
}
/// 发送带认证的请求,自动处理 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 = match request_builder(token)
.header("X-Cr-Client-Id", &client_id)
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
"请求发送失败: kind={:?}, url={:?}, error={}",
e.is_connect(),
e.url().map(|u| u.as_str()),
e
);
return Err(e.into());
}
};
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 = match request_builder(new_token)
.header("X-Cr-Client-Id", &client_id)
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
"重试请求发送失败: kind={:?}, url={:?}, error={}",
e.is_connect(),
e.url().map(|u| u.as_str()),
e
);
return Err(e.into());
}
};
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 {
let mut retry = 0u32;
loop {
match self.list_all_files_recursive(&dir_uri, result).await {
Ok(()) => break,
Err(e) => {
retry += 1;
if retry > 3 {
tracing::error!("递归列出目录失败,跳过: {}: {}", dir_uri, e);
break;
}
let delay = crate::utils::retry_delay_ms(retry, 2000, 30000);
tracing::warn!("递归列出目录失败 (重试 {}/3): {}: {}, {}ms后重试", retry, dir_uri, e, delay);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
}
}
Ok(())
})
}
pub async fn list_files_page(
&self,
uri: &str,
page: u32,
page_size: u32,
next_page_token: Option<&str>,
) -> Result<ListFilesResponse> {
let max_retries = 3u32;
let mut attempt = 0u32;
loop {
attempt += 1;
match self.list_files_page_inner(uri, page, page_size, next_page_token).await {
Ok(resp) => return Ok(resp),
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 2000, 30000);
tracing::warn!(
"列出文件失败 (重试 {}/{}): uri={}, error={}, {}ms后重试",
attempt, max_retries, uri, e, delay,
);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
Err(e) => return Err(e),
}
}
}
async fn list_files_page_inner(
&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);
let upload_urls: Vec<String> = data.get("upload_urls")
.and_then(|u| u.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let storage_policy_type = data.get("storage_policy")
.and_then(|sp| sp.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("local")
.to_string();
let callback_secret = data.get("callback_secret")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let file_name = uri.rsplit('/').next().unwrap_or(uri).to_string();
tracing::info!(
"[{}] 创建上传会话: policy={}, urls={}, chunk_size={}",
file_name, storage_policy_type, upload_urls.len(), chunk_size,
);
Ok(UploadSession {
session_id,
chunk_size,
upload_urls,
storage_policy_type,
callback_secret,
file_name,
})
}
pub async fn upload_chunk(
&self,
session: &UploadSession,
index: u32,
data: &[u8],
file_size: u64,
task_id: &str,
) -> Result<()> {
if let Some(url) = session.chunk_upload_url(index as usize) {
// 远程存储策略:直接上传到外部 URLOneDrive/S3/OSS 等)
self.upload_chunk_to_remote(url, data, index, file_size, session, task_id).await
} else {
// 本地存储策略:上传到 Cloudreve 服务端
self.upload_chunk_local(&session.session_id, index, data).await
}
}
/// 本地存储:上传分片到 /file/upload/{session_id}/{index}
async fn upload_chunk_local(
&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(())
}
/// 远程存储:上传分片到外部 URL(OneDrive/S3 等),无需 Cloudreve token
/// 必须带 Content-Range 头:bytes {start}-{end}/{total}
async fn upload_chunk_to_remote(
&self,
url: &str,
data: &[u8],
index: u32,
file_size: u64,
session: &UploadSession,
task_id: &str,
) -> Result<()> {
let chunk_size = session.chunk_size;
let file_name = &session.file_name;
let start = index as u64 * chunk_size;
let end = start + data.len() as u64 - 1;
let content_range = format!("bytes {}-{}/{}", start, end, file_size);
let content_len = data.len().to_string();
tracing::debug!("[{}][{}] 远程存储上传分片 {}: Content-Range={}", task_id, file_name, index, content_range);
let resp = self.client
.put(url)
.header("Content-Length", &content_len)
.header("Content-Range", &content_range)
.body(data.to_vec())
.timeout(std::time::Duration::from_secs(300))
.send()
.await
.map_err(|e| {
tracing::warn!("[{}][{}] 远程存储上传失败: error={}", task_id, file_name, e);
SyncError::from(e)
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SyncError::UploadFailed(format!(
"远程存储返回 HTTP {}: {}",
status,
body.chars().take(200).collect::<String>()
)));
}
// 202 = 分片已接收,上传未完成,继续下一个分片
// 200/201 = 上传完成,文件已创建
let status = resp.status();
if status.as_u16() == 202 {
tracing::debug!("[{}][{}] 远程存储分片 {} 已接收(202),继续上传", task_id, file_name, index);
} else if status.as_u16() == 200 || status.as_u16() == 201 {
tracing::info!("[{}][{}] 远程存储上传完成({}), 分片 {}", task_id, file_name, status, index);
}
Ok(())
}
/// 远程存储上传完成后回调 Cloudreve 服务端
/// POST /callback/{storage_policy_type}/{session_id}/{callback_secret}
pub async fn callback_upload_complete(&self, session: &UploadSession, task_id: &str) -> Result<()> {
if session.callback_secret.is_empty() {
tracing::warn!("[{}][{}] 上传回调跳过: callback_secret 为空", task_id, session.file_name);
return Ok(());
}
let url = format!(
"{}/callback/{}/{}/{}",
self.base_url,
session.storage_policy_type,
session.session_id,
session.callback_secret,
);
tracing::info!("[{}][{}] 上传完成回调: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
self.send_with_auth_retry(|token| {
self.client
.post(&url)
.bearer_auth(&token)
}).await?;
tracing::info!("[{}][{}] 上传完成回调成功: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
Ok(())
}
// ===== 下载 =====
pub async fn get_download_url(&self, uris: &[&str]) -> Result<Vec<String>> {
let body = serde_json::json!({
"uris": uris,
"download": false,
});
let data = self.send_with_auth_retry(|token| {
self.client
.post(format!("{}/file/url", self.base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
let urls = data.get("urls")
.and_then(|u| u.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.get("url")?.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok(urls)
}
pub async fn stream_download(
&self,
url: &str,
offset: u64,
) -> Result<reqwest::Response> {
let mut req = self.download_client.get(url);
if offset > 0 {
req = req.header("Range", format!("bytes={}-", offset));
}
let resp = req.send().await?;
Ok(resp)
}
// ===== 创建文件/目录 =====
pub async fn create_directory(&self, parent_uri: &str, name: &str) -> Result<RemoteFileEntry> {
let uri = format!("{}/{}", parent_uri, name);
let body = serde_json::json!({
"uri": uri,
"type": "folder",
});
let _data = self.send_with_auth_retry(|token| {
self.client
.post(format!("{}/file/create", self.base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
Ok(RemoteFileEntry {
uri,
name: name.to_string(),
size: 0,
mtime_ms: 0,
hash: None,
is_dir: true,
file_id: None,
path: String::new(),
created_at_ms: 0,
})
}
// ===== 删除 =====
pub async fn delete_files(&self, uris: &[&str]) -> Result<()> {
let body = serde_json::json!({
"uris": uris,
});
let result = self.send_with_auth_retry(|token| {
let client = &self.client;
let base_url = &self.base_url;
let body = body.clone();
client
.delete(format!("{}/file", base_url))
.bearer_auth(&token)
.json(&body)
}).await;
match result {
Ok(_) => Ok(()),
Err(SyncError::LockConflict { tokens }) => {
for item in &tokens {
tracing::warn!(
"删除异常: code(40073), 进行解锁, token: {}, path: {}",
item.token, item.path
);
}
self.force_unlock_files(&tokens).await?;
// 解锁后重试删除
self.send_with_auth_retry(|token| {
let client = &self.client;
let base_url = &self.base_url;
let body = body.clone();
client
.delete(format!("{}/file", base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
Ok(())
}
Err(e) => Err(e),
}
}
/// 强制解锁文件 — DELETE /file/lock
pub async fn force_unlock_files(&self, items: &[crate::errors::LockConflictItem]) -> Result<()> {
let tokens: Vec<&str> = items.iter().map(|i| i.token.as_str()).collect();
if tokens.is_empty() {
return Ok(());
}
let body = serde_json::json!({
"tokens": tokens,
});
self.send_with_auth_retry(|token| {
let client = &self.client;
let base_url = &self.base_url;
let body = body.clone();
client
.delete(format!("{}/file/lock", base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
tracing::info!("强制解锁完成: {} 个文件", items.len());
Ok(())
}
// ===== 移动 =====
pub async fn move_files(&self, src_uris: &[&str], dst_uri: &str, copy: bool) -> Result<()> {
let body = serde_json::json!({
"uris": src_uris,
"dst": dst_uri,
"copy": copy,
});
self.send_with_auth_retry(|token| {
self.client
.post(format!("{}/file/move", self.base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
Ok(())
}
// ===== 重命名 =====
pub async fn rename_file(&self, uri: &str, new_name: &str) -> Result<()> {
let body = serde_json::json!({
"uri": uri,
"new_name": new_name,
});
self.send_with_auth_retry(|token| {
self.client
.post(format!("{}/file/rename", self.base_url))
.bearer_auth(&token)
.json(&body)
}).await?;
Ok(())
}
// ===== 获取文件信息 =====
pub async fn get_file_info(&self, uri: &str) -> Result<RemoteFileEntry> {
let data = self.send_with_auth_retry(|token| {
self.client
.get(format!("{}/file/info", self.base_url))
.bearer_auth(&token)
.query(&[("uri", uri)])
}).await?;
Ok(RemoteFileEntry {
uri: data.get("uri").and_then(|u| u.as_str()).unwrap_or(uri).to_string(),
name: percent_decode_str(data.get("name").and_then(|n| n.as_str()).unwrap_or("")),
size: data.get("size").and_then(|s| s.as_u64()).unwrap_or(0),
mtime_ms: data.get("updated_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0),
hash: None,
is_dir: data.get("type").and_then(|t| t.as_u64()).unwrap_or(0) == 1,
file_id: data.get("id").and_then(|i| i.as_str()).map(String::from),
path: percent_decode_str(data.get("path").and_then(|p| p.as_str()).unwrap_or("")),
created_at_ms: data.get("created_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0),
})
}
}
/// 解析 Cloudreve 时间戳 (ISO 8601 或 Unix ms)
fn parse_timestamp(s: &str) -> i64 {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return dt.timestamp_millis();
}
if let Ok(ts) = s.parse::<i64>() {
if ts < 10_000_000_000 {
return ts * 1000;
}
return ts;
}
0
}
fn percent_decode_str(s: &str) -> String {
urlencoding::decode(s).unwrap_or_else(|_| s.into()).to_string()
}
+70
View File
@@ -0,0 +1,70 @@
use crate::models::*;
#[derive(Clone)]
pub struct ConflictResolver {
pub default_strategy: ConflictStrategy,
}
impl ConflictResolver {
pub fn new(strategy: ConflictStrategy) -> Self {
Self {
default_strategy: strategy,
}
}
pub fn resolve(
&self,
conflict_type: ConflictType,
local_mtime: i64,
remote_mtime: i64,
local_size: u64,
remote_size: u64,
local_name: &str,
) -> ConflictResolution {
self.resolve_with_strategy(
&self.default_strategy,
conflict_type,
local_mtime,
remote_mtime,
local_size,
remote_size,
local_name,
)
}
#[allow(clippy::too_many_arguments)]
pub fn resolve_with_strategy(
&self,
strategy: &ConflictStrategy,
_conflict_type: ConflictType,
local_mtime: i64,
remote_mtime: i64,
local_size: u64,
remote_size: u64,
local_name: &str,
) -> ConflictResolution {
match strategy {
ConflictStrategy::KeepLocal => ConflictResolution::UploadLocal,
ConflictStrategy::KeepRemote => ConflictResolution::DownloadRemote,
ConflictStrategy::KeepBoth => {
let new_name = crate::utils::generate_conflict_name(local_name);
ConflictResolution::RenameLocal { new_name }
}
ConflictStrategy::NewestWins => {
if local_mtime > remote_mtime {
ConflictResolution::UploadLocal
} else {
ConflictResolution::DownloadRemote
}
}
ConflictStrategy::LargestWins => {
if local_size > remote_size {
ConflictResolution::UploadLocal
} else {
ConflictResolution::DownloadRemote
}
}
ConflictStrategy::Manual => ConflictResolution::MarkManual,
}
}
}
+204
View File
@@ -0,0 +1,204 @@
use crate::models::*;
use std::collections::{HashMap, HashSet};
/// 三路差异计算: 本地 vs 远程 vs 数据库,按同步模式过滤
pub fn compute_diff(
local_files: &[LocalFileEntry],
remote_files: &[RemoteFileEntry],
db_mappings: &HashMap<String, FileMapping>,
remote_root: &str,
sync_mode: &SyncMode,
) -> SyncPlan {
let mut plan = SyncPlan::default();
// 构建索引: relative_path → entry(统一正斜杠)
let local_map: HashMap<String, &LocalFileEntry> = local_files
.iter()
.map(|e| (crate::utils::normalize_path(&e.relative_path.to_string_lossy()), e))
.collect();
let remote_map: HashMap<String, &RemoteFileEntry> = remote_files
.iter()
.map(|e| {
let rel = remote_relative_path(remote_root, &e.path, &e.name, e.is_dir);
(rel, e)
})
.collect();
// 收集所有路径
let mut all_paths: HashSet<String> = HashSet::new();
for k in local_map.keys() {
all_paths.insert(k.clone());
}
for k in remote_map.keys() {
all_paths.insert(k.clone());
}
for k in db_mappings.keys() {
all_paths.insert(k.clone());
}
for path in &all_paths {
let local = local_map.get(path.as_str()).copied();
let remote = remote_map.get(path.as_str()).copied();
let db = db_mappings.get(path.as_str());
match (local, remote, db) {
// 本地有,远程无 → 上传(UploadOnly、Full 和 MirrorWcf
(Some(l), None, _) => {
if matches!(sync_mode, SyncMode::DownloadOnly) {
continue;
}
if !l.is_dir && l.size == 0 {
continue;
}
if let Some(db_m) = db {
if db_m.sync_status == SyncFileStatus::Synced {
plan.uploads.push(SyncAction {
relative_path: path.clone(),
local_entry: Some((*l).clone()),
remote_entry: None,
db_mapping: Some(db_m.clone()),
});
}
} else {
plan.uploads.push(SyncAction {
relative_path: path.clone(),
local_entry: Some((*l).clone()),
remote_entry: None,
db_mapping: None,
});
}
}
// 远程有,本地无 → 下载(DownloadOnly、Full 和 MirrorWcf
(None, Some(r), _) => {
if matches!(sync_mode, SyncMode::UploadOnly) {
continue;
}
if r.is_dir {
plan.mkdirs_local.push(path.clone());
} else if r.size == 0 {
continue;
} else {
plan.downloads.push(SyncAction {
relative_path: path.clone(),
local_entry: None,
remote_entry: Some((*r).clone()),
db_mapping: db.cloned(),
});
}
}
// 两边都有
(Some(l), Some(r), db_m) => {
if l.is_dir && r.is_dir {
continue;
}
let content_match = match (&l.quick_hash, &r.hash) {
(lh, Some(rh)) if !lh.is_empty() && !rh.is_empty() => lh == rh,
_ => l.size == r.size,
};
if content_match {
// 内容一致,标记已同步
} else {
let conflict_type = if l.is_dir != r.is_dir {
ConflictType::TypeMismatch
} else {
ConflictType::BothModified
};
plan.conflicts.push(SyncConflict {
relative_path: path.clone(),
conflict_type,
local_entry: Some((*l).clone()),
remote_entry: Some((*r).clone()),
db_mapping: db_m.cloned(),
});
}
}
(None, None, Some(_)) => {}
_ => {}
}
}
// 远程目录结构(UploadOnly、Full 和 MirrorWcf
if !matches!(sync_mode, SyncMode::DownloadOnly) {
for (path, local) in &local_map {
if local.is_dir && !remote_map.contains_key(path.as_str()) {
plan.mkdirs_remote.push(path.clone());
}
}
}
// 按 sync_mode 自动解决冲突
resolve_conflicts_by_mode(&mut plan, sync_mode);
plan
}
/// 根据同步模式自动解决冲突
fn resolve_conflicts_by_mode(plan: &mut SyncPlan, sync_mode: &SyncMode) {
if plan.conflicts.is_empty() {
return;
}
let conflicts = std::mem::take(&mut plan.conflicts);
for conflict in conflicts {
match sync_mode {
SyncMode::UploadOnly | SyncMode::MirrorWcf => {
// 冲突一律覆盖上传(MirrorWcf 本地编辑优先)
let action = SyncAction {
relative_path: conflict.relative_path.clone(),
local_entry: conflict.local_entry.clone(),
remote_entry: conflict.remote_entry.clone(),
db_mapping: conflict.db_mapping.clone(),
};
plan.uploads.push(action);
}
SyncMode::DownloadOnly => {
// 冲突一律覆盖下载
let action = SyncAction {
relative_path: conflict.relative_path.clone(),
local_entry: conflict.local_entry.clone(),
remote_entry: conflict.remote_entry.clone(),
db_mapping: conflict.db_mapping.clone(),
};
plan.downloads.push(action);
}
_ => {
// Full: 保留冲突,由 Worker 用 conflict_strategy 解决
plan.conflicts.push(conflict);
}
}
}
}
/// 从远程 path 字段提取相对路径
pub fn remote_relative_path(remote_root: &str, path: &str, name: &str, is_dir: bool) -> String {
let _ = is_dir;
// 1. 尝试匹配完整 URI 前缀(remote_root = cloudreve://my/example
if let Some(rel) = path.strip_prefix(remote_root) {
return rel.trim_start_matches('/').to_string();
}
// 2. SSE 的 path 是相对路径(如 /Readest/Books/file.txt),直接 trim 前导 /
if path.starts_with('/') {
return path.trim_start_matches('/').to_string();
}
// 3. 回退:使用文件名
name.to_string()
}
/// 从字符串解析 SyncFileStatus
pub fn parse_sync_status_from_str(s: &str) -> SyncFileStatus {
match s {
"uploading" => SyncFileStatus::Uploading,
"downloading" => SyncFileStatus::Downloading,
"conflict" => SyncFileStatus::Conflict,
"placeholder" => SyncFileStatus::Placeholder,
_ => SyncFileStatus::Synced,
}
}
+308
View File
@@ -0,0 +1,308 @@
use crate::api_client::ApiClient;
use crate::errors::{Result, SyncError};
use crate::file_lock::FileLockRegistry;
use crate::models::*;
use crate::sync_db::SyncDb;
use tokio::sync::Semaphore;
/// 下载单个文件(含重试 + 断点续传),受并发信号量控制
#[allow(clippy::too_many_arguments)]
pub async fn download_file(
task_id: &str,
action: &SyncAction,
config: &WorkerConfig,
api: &ApiClient,
db: &SyncDb,
file_locks: &FileLockRegistry,
semaphore: &Semaphore,
root_id: &str,
) -> Result<()> {
let remote = action.remote_entry.as_ref().ok_or_else(|| {
SyncError::Internal("下载操作缺少远程文件信息".into())
})?;
let _lock = file_locks.acquire(&action.relative_path).await;
if remote.is_dir {
let local_path = config.local_root.join(&action.relative_path);
tokio::fs::create_dir_all(&local_path).await?;
tracing::debug!("[{}] 创建本地目录: {}", task_id, action.relative_path);
return Ok(());
}
let local_path = config.local_root.join(&action.relative_path);
let _permit = semaphore.acquire().await
.map_err(|e| SyncError::Internal(format!("获取传输信号量失败: {}", e)))?;
// 信号量获取后标记为 Running(实际开始传输)
let _ = db
.update_task_item_status_by_path(
task_id,
&action.relative_path,
"download",
&TaskItemStatus::Running,
None,
)
.await;
tracing::info!("[{}] 开始下载: {} ({}bytes)", task_id, action.relative_path, remote.size);
// 确保父目录存在
if let Some(parent) = local_path.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent).await?;
}
}
let max_retries = 3u32;
let mut attempt = 0u32;
let tmp_path = local_path.with_extension(".sync_tmp");
loop {
attempt += 1;
// 检查临时文件已有大小,用于断点续传
let resume_offset = if tmp_path.exists() {
tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0)
} else {
0
};
let urls = match api.get_download_url(&[&remote.uri]).await {
Ok(urls) => urls,
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
tracing::warn!("[{}] 下载重试 ({}/{}): {} 获取链接失败: {}", task_id, attempt, max_retries, action.relative_path, e);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
continue;
}
Err(e) => return Err(e),
};
let download_url = match urls.first() {
Some(u) => u.clone(),
None => {
tracing::error!("[{}] 未获取到下载链接, uri={}", task_id, remote.uri);
return Err(SyncError::Network("未获取到下载链接".into()));
}
};
let resp = match api.stream_download(&download_url, resume_offset).await {
Ok(r) => r,
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
tracing::warn!("[{}] 下载重试 ({}/{}): {} 连接失败: {}", task_id, attempt, max_retries, action.relative_path, e);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
continue;
}
Err(e) => return Err(e),
};
match stream_to_file(resp, &tmp_path, config.bandwidth_limit, resume_offset).await {
Ok(_) => {
tracing::debug!("[{}] 下载写入完成: {} ({}bytes)", task_id, tmp_path.display(), remote.size);
tokio::fs::rename(&tmp_path, &local_path).await?;
if remote.mtime_ms > 0 {
let mtime = std::time::UNIX_EPOCH + std::time::Duration::from_millis(remote.mtime_ms as u64);
let _ = filetime::set_file_mtime(&local_path, filetime::FileTime::from_system_time(mtime));
}
let local_hash = crate::utils::quick_hash(&local_path, remote.size).await.unwrap_or_default();
db.upsert_file_mapping(&FileMapping {
id: 0,
sync_root_id: root_id.to_string(),
local_path: std::path::PathBuf::from(&action.relative_path),
remote_uri: remote.uri.clone(),
remote_file_id: remote.file_id.clone(),
local_hash: Some(local_hash.clone()),
remote_hash: remote.hash.clone().or(Some(local_hash)),
local_mtime: Some(remote.mtime_ms),
remote_mtime: Some(remote.mtime_ms),
local_size: Some(remote.size),
remote_size: Some(remote.size),
sync_status: SyncFileStatus::Synced,
is_placeholder: false,
}).await?;
tracing::info!("[{}] 下载完成: {}", task_id, action.relative_path);
return Ok(());
}
Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
// 保留临时文件用于断点续传
let existing_size = tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0);
if existing_size > 0 {
tracing::warn!(
"[{}] 下载重试 ({}/{}): {} 写入失败(已下载{}bytes,将从断点续传): {}",
task_id, attempt, max_retries, action.relative_path, existing_size, e,
);
} else {
tracing::warn!(
"[{}] 下载重试 ({}/{}): {} 写入失败: {}",
task_id, attempt, max_retries, action.relative_path, e,
);
}
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
continue;
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(e);
}
}
}
}
/// 流式写入文件(含带宽限速 + 断点续传)
/// resume_offset > 0 时以追加模式打开文件,跳过已下载部分
pub async fn stream_to_file(
resp: reqwest::Response,
tmp_path: &std::path::Path,
bandwidth_limit: Option<u64>,
resume_offset: u64,
) -> Result<()> {
use tokio::io::{AsyncWriteExt, AsyncSeekExt};
use futures_util::StreamExt;
let mut file = if resume_offset > 0 && tmp_path.exists() {
// 断点续传:追加模式
let mut f = tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.open(tmp_path)
.await?;
f.seek(std::io::SeekFrom::End(0)).await?;
f
} else {
tokio::fs::File::create(tmp_path).await?
};
let mut stream = resp.bytes_stream();
let mut total_bytes = resume_offset;
match bandwidth_limit {
None => {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
file.write_all(&chunk).await?;
}
}
Some(limit) => {
let transfer_start = std::time::Instant::now();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
total_bytes += chunk.len() as u64;
file.write_all(&chunk).await?;
let expected_elapsed = std::time::Duration::from_micros(
total_bytes * 1_000_000 / limit
);
let actual_elapsed = transfer_start.elapsed();
if expected_elapsed > actual_elapsed {
tokio::time::sleep(expected_elapsed - actual_elapsed).await;
}
}
}
}
file.flush().await?;
Ok(())
}
/// 从 URL 下载文件到指定路径(WCF 水合使用)
pub async fn download_file_from_url(
download_url: &str,
local_path: &std::path::Path,
expected_size: u64,
bandwidth_limit: Option<u64>,
) -> Result<()> {
let client = reqwest::Client::new();
let mut request = client.get(download_url);
// 断点续传
let resume_offset = if local_path.exists() {
let metadata = tokio::fs::metadata(local_path).await?;
if metadata.len() > 0 && metadata.len() < expected_size {
request = request.header("Range", format!("bytes={}-", metadata.len()));
metadata.len()
} else {
0
}
} else {
0
};
let resp = request.send().await.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() && resp.status().as_u16() != 206 {
return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status())));
}
let tmp_path = local_path.with_extension(format!(
"{}.sync_tmp",
local_path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
));
stream_to_file(resp, &tmp_path, bandwidth_limit, resume_offset).await?;
// 重命名临时文件为最终文件
if tmp_path.exists() {
let _ = std::fs::remove_file(local_path);
std::fs::rename(&tmp_path, local_path)
.map_err(|e| SyncError::FileSystem(format!("重命名临时文件失败: {}", e)))?;
}
Ok(())
}
/// 从 URL 下载数据到内存缓冲区(WCF CfExecute 水合使用)
pub async fn download_to_buffer(
api: &ApiClient,
download_url: &str,
bandwidth_limit: Option<u64>,
) -> Result<Vec<u8>> {
use futures_util::StreamExt;
let resp = api.stream_download(download_url, 0).await?;
if !resp.status().is_success() {
return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status())));
}
let content_length = resp.content_length();
let mut buffer = Vec::with_capacity(content_length.unwrap_or(0) as usize);
let mut stream = resp.bytes_stream();
let mut total_bytes: u64 = 0;
match bandwidth_limit {
None => {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
buffer.extend_from_slice(&chunk);
}
}
Some(limit) => {
let transfer_start = std::time::Instant::now();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
total_bytes += chunk.len() as u64;
buffer.extend_from_slice(&chunk);
let expected_elapsed = std::time::Duration::from_micros(
total_bytes * 1_000_000 / limit
);
let actual_elapsed = transfer_start.elapsed();
if expected_elapsed > actual_elapsed {
tokio::time::sleep(expected_elapsed - actual_elapsed).await;
}
}
}
}
Ok(buffer)
}
+127
View File
@@ -0,0 +1,127 @@
use std::error::Error as StdError;
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("存储策略不允许: {0}")]
StoragePolicyDenied(String),
#[error("上传失败: {0}")]
UploadFailed(String),
#[error("文件未找到: {0}")]
FileNotFound(String),
#[error("权限不足: {0}")]
PermissionDenied(String),
#[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 {
let mut detail = String::new();
if e.is_connect() {
detail.push_str("连接失败");
} else if e.is_timeout() {
detail.push_str("请求超时");
} else if e.is_request() {
detail.push_str("请求构建失败");
} else if e.is_body() {
detail.push_str("请求体错误");
} else if e.is_decode() {
detail.push_str("响应解码失败");
} else if e.is_redirect() {
detail.push_str("重定向过多");
}
let url = e.url().map(|u| u.to_string()).unwrap_or_default();
let source = StdError::source(&e)
.map(|s| format!(": {s}"))
.unwrap_or_default();
let msg = e.to_string();
// 如果 detail 为空,用 reqwest 原始消息
if detail.is_empty() {
SyncError::Network(if url.is_empty() {
format!("{msg}{source}")
} else {
format!("{msg} [{url}]{source}")
})
} else {
SyncError::Network(if url.is_empty() {
format!("{detail}: {msg}{source}")
} else {
format!("{detail}: {msg} [{url}]{source}")
})
}
}
}
impl From<std::io::Error> for SyncError {
fn from(e: std::io::Error) -> Self {
SyncError::FileSystem(e.to_string())
}
}
impl From<tokio::task::JoinError> for SyncError {
fn from(e: tokio::task::JoinError) -> Self {
SyncError::Internal(e.to_string())
}
}
/// 锁冲突条目 — 来自 40073 响应的 data 数组
#[derive(Debug, Clone)]
pub struct LockConflictItem {
pub path: String,
pub token: String,
}
pub type Result<T> = std::result::Result<T, SyncError>;
+290
View File
@@ -0,0 +1,290 @@
mod sse;
use crate::api_client::ApiClient;
use crate::errors::Result;
use crate::models::{RemoteFileEntry, RemoteFileEvent};
use sse::SseFileEvent;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
pub struct EventHandler {
api: Arc<ApiClient>,
client_id: String,
}
impl EventHandler {
pub fn new(api: Arc<ApiClient>, client_id: String) -> Self {
Self { api, client_id }
}
/// 订阅服务端 SSE 事件流 (GET /file/events)
pub async fn subscribe_sse(&self, uri: &str) -> Result<mpsc::Receiver<RemoteFileEvent>> {
let (tx, rx) = mpsc::channel(256);
let api = self.api.clone();
let client_id = self.client_id.clone();
let remote_root = uri.to_string();
tokio::spawn(async move {
loop {
let token = api.token().await;
let base_url = api.base_url().to_string();
match Self::connect_sse(&base_url, &token, &client_id, &remote_root, &tx).await {
Ok(_) => {
tracing::info!("[SSE] 连接关闭,5秒后重连...");
}
Err(e) => {
tracing::warn!("[SSE] 连接错误: {}5秒后重连...", e);
}
}
tokio::time::sleep(Duration::from_secs(5)).await;
if tx.is_closed() {
break;
}
}
tracing::info!("[SSE] 订阅任务退出");
});
Ok(rx)
}
/// 建立 SSE 连接,使用 eventsource parser 正确解析事件
async fn connect_sse(
base_url: &str,
token: &str,
client_id: &str,
remote_root: &str,
tx: &mpsc::Sender<RemoteFileEvent>,
) -> Result<()> {
use sse::{SseEvent, SseParseResult, sse_parse_line};
let url = format!("{}/file/events", base_url);
tracing::info!("[SSE] 正在连接: {}?uri={}", url, remote_root);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.bearer_auth(token)
.header("X-Cr-Client-Id", client_id)
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.query(&[("uri", remote_root)])
.send()
.await?;
if !resp.status().is_success() {
return Err(crate::errors::SyncError::Network(
format!("[SSE] 连接失败: HTTP {}", resp.status()),
));
}
tracing::info!("[SSE] 连接成功,开始监听事件 (uri={})", remote_root);
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
let mut line_buf = String::new();
let mut event = SseEvent::new();
let mut event_count: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| crate::errors::SyncError::Network(e.to_string()))?;
let text = String::from_utf8_lossy(&chunk);
line_buf.push_str(&text);
while let Some(newline_pos) = line_buf.find('\n') {
let line = line_buf[..=newline_pos].to_string();
line_buf = line_buf[newline_pos + 1..].to_string();
match sse_parse_line(&line, &mut event) {
SseParseResult::Next => {}
SseParseResult::Dispatch => {
if event.is_empty() {
event.clear();
continue;
}
event_count += 1;
let event_type = event.event_type.clone().unwrap_or_default();
if event_type == "event" {
let data = event.data.trim();
let events: Vec<SseFileEvent> = if data.starts_with('[') {
serde_json::from_str(data).unwrap_or_default()
} else {
serde_json::from_str(data)
.map(|e| vec![e])
.unwrap_or_else(|_| {
tracing::warn!("[SSE] 无法解析事件数据: {}", data);
Vec::new()
})
};
for ev in &events {
tracing::info!(
"[SSE] 原始事件: type={}, file_id={}, from={}, to={}",
ev.event_type, ev.file_id, ev.from, ev.to
);
if let Some(remote_event) = Self::parse_sse_event(ev, remote_root) {
if tx.send(remote_event).await.is_err() {
return Ok(());
}
}
}
} else if event_type == "reconnect-required" {
tracing::info!("[SSE] 服务端要求重连");
return Ok(());
} else if event_type == "subscribed" {
tracing::info!("[SSE] 订阅确认成功");
} else if event_type == "keep-alive" {
} else {
tracing::debug!(
"[SSE] 忽略未知事件: type={:?}, data={}",
event.event_type,
event.data.trim()
);
}
event.clear();
}
SseParseResult::SetRetry(retry) => {
tracing::debug!("[SSE] 服务端设置重试间隔: {:?}", retry);
}
}
}
}
tracing::info!("[SSE] 流结束,共处理 {} 个事件", event_count);
Ok(())
}
/// 将 SSE 文件事件转为内部 RemoteFileEvent
fn parse_sse_event(ev: &SseFileEvent, remote_root: &str) -> Option<RemoteFileEvent> {
let full_uri = |path: &str| -> String {
if path.starts_with("cloudreve://") || path.starts_with("http") {
path.to_string()
} else {
format!("{}{}", remote_root.trim_end_matches('/'), path)
}
};
match ev.event_type.as_str() {
"create" | "modify" => {
let uri = full_uri(&ev.from);
Some(RemoteFileEvent::Modified(RemoteFileEntry {
name: ev.from.split('/').next_back()
.unwrap_or("").to_string(),
path: ev.from.clone(),
uri,
size: 0,
mtime_ms: 0,
hash: None,
is_dir: false,
file_id: Some(ev.file_id.clone()),
created_at_ms: 0,
}))
}
"delete" => {
let uri = full_uri(&ev.from);
Some(RemoteFileEvent::Deleted {
uri,
name: ev.from.split('/').next_back()
.unwrap_or("").to_string(),
})
}
"rename" | "move" => {
let old_uri = full_uri(&ev.from);
let new_uri = full_uri(&ev.to);
let new_entry = RemoteFileEntry {
name: ev.to.split('/').next_back()
.unwrap_or("").to_string(),
path: ev.to.clone(),
uri: new_uri,
size: 0,
mtime_ms: 0,
hash: None,
is_dir: false,
file_id: Some(ev.file_id.clone()),
created_at_ms: 0,
};
let from_parent = ev.from.rfind('/').map(|i| &ev.from[..i]).unwrap_or("");
let to_parent = ev.to.rfind('/').map(|i| &ev.to[..i]).unwrap_or("");
if from_parent == to_parent {
Some(RemoteFileEvent::Renamed { old_uri, new_entry })
} else {
Some(RemoteFileEvent::Moved { old_uri, new_entry })
}
}
_ => {
tracing::debug!("[SSE] 忽略未知事件类型: {}", ev.event_type);
None
}
}
}
}
/// 事件防抖器:同一文件在 debounce_window 内的多次变更合并为一次
pub struct EventDebouncer {
pending: HashMap<PathBuf, Instant>,
debounce_window: Duration,
}
impl EventDebouncer {
pub fn new(debounce_window: Duration) -> Self {
Self {
pending: HashMap::new(),
debounce_window,
}
}
pub fn should_process(&mut self, path: &PathBuf) -> bool {
let now = Instant::now();
if let Some(last) = self.pending.get(path) {
if now.duration_since(*last) < self.debounce_window {
self.pending.insert(path.clone(), now);
return false;
}
}
self.pending.insert(path.clone(), now);
true
}
pub fn cleanup(&mut self) {
let now = Instant::now();
self.pending.retain(|_, last| now.duration_since(*last) < self.debounce_window);
}
}
/// 收集时间窗口内的批量远程事件
pub async fn batch_remote_events(
rx: &mut mpsc::Receiver<RemoteFileEvent>,
window: Duration,
) -> Vec<RemoteFileEvent> {
let mut events = Vec::new();
match tokio::time::timeout(window, rx.recv()).await {
Ok(Some(event)) => events.push(event),
_ => return events,
}
let deadline = Instant::now() + window;
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Some(event)) => events.push(event),
_ => break,
}
}
events
}
+85
View File
@@ -0,0 +1,85 @@
use std::time::Duration;
/// SSE 事件缓冲区
#[derive(Default)]
pub(crate) struct SseEvent {
pub event_type: Option<String>,
pub data: String,
pub id: Option<String>,
}
impl SseEvent {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.event_type.is_none() && self.data.is_empty()
}
pub fn clear(&mut self) {
self.event_type = None;
self.data.clear();
self.id = None;
}
}
pub(crate) enum SseParseResult {
Next,
Dispatch,
SetRetry(Duration),
}
pub(crate) fn sse_parse_line(line: &str, event: &mut SseEvent) -> SseParseResult {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
if event.is_empty() {
return SseParseResult::Next;
}
return SseParseResult::Dispatch;
}
if line.starts_with(':') {
return SseParseResult::Next;
}
let (field, value) = if let Some(colon_pos) = line.find(':') {
let field = &line[..colon_pos];
let value = line[colon_pos + 1..].strip_prefix(' ').unwrap_or(&line[colon_pos + 1..]);
(field, value)
} else {
(line, "")
};
match field {
"event" => event.event_type = Some(value.to_string()),
"data" => {
if !event.data.is_empty() {
event.data.push('\n');
}
event.data.push_str(value);
}
"id" => event.id = Some(value.to_string()),
"retry" => {
if let Ok(ms) = value.parse::<u64>() {
return SseParseResult::SetRetry(Duration::from_millis(ms));
}
}
_ => {}
}
SseParseResult::Next
}
/// SSE 事件中的文件变更条目
#[derive(Debug, serde::Deserialize)]
pub(crate) struct SseFileEvent {
#[serde(rename = "type")]
pub event_type: String,
pub file_id: String,
pub from: String,
#[serde(default)]
#[allow(dead_code)]
pub to: String,
}
+57
View File
@@ -0,0 +1,57 @@
use crate::api::ffi_types::SyncEventFfi;
use std::sync::Arc;
use tokio::sync::Mutex;
/// Rust→Dart 事件推送 — 封装 FRB StreamSink
///
/// **重要**: StreamSink.add() 需要 SyncEventFfi 实现 SseEncode trait
/// 该 trait 由 flutter_rust_bridge_codegen 自动生成。
/// 在运行 codegen 之前,emit() 仅写日志不实际推送。
/// 运行 codegen 后,StreamSink 可用,emit() 将实际推送到 Dart。
pub struct EventSink {
sink: Arc<Mutex<Option<crate::frb_generated::StreamSink<SyncEventFfi>>>>,
available: std::sync::atomic::AtomicBool,
}
impl Default for EventSink {
fn default() -> Self {
Self::new()
}
}
impl EventSink {
pub fn new() -> Self {
Self {
sink: Arc::new(Mutex::new(None)),
available: std::sync::atomic::AtomicBool::new(false),
}
}
/// 注册 StreamSink
pub async fn register(&self, sink: crate::frb_generated::StreamSink<SyncEventFfi>) {
self.available.store(true, std::sync::atomic::Ordering::Relaxed);
*self.sink.lock().await = Some(sink);
}
/// 推送事件到 Dart
pub async fn emit(&self, event: SyncEventFfi) {
if self.available.load(std::sync::atomic::Ordering::Relaxed) {
self.emit_inner(event).await;
}
}
/// 实际推送 — 仅在 StreamSink 可用时调用
/// 此方法在 FRB codegen 生成 SseEncode 实现后才编译通过
#[cfg(feature = "event_sink_enabled")]
async fn emit_inner(&self, event: SyncEventFfi) {
if let Some(sink) = self.sink.lock().await.as_ref() {
let _ = sink.add(event);
}
}
#[cfg(not(feature = "event_sink_enabled"))]
async fn emit_inner(&self, _event: SyncEventFfi) {
// codegen 生成前为空操作,仅写日志
tracing::debug!("EventSink: 事件未推送(FRB codegen 未运行)");
}
}
+71
View File
@@ -0,0 +1,71 @@
use dashmap::DashMap;
use std::sync::Arc;
use std::sync::Weak;
use tokio::sync::{Mutex, OwnedMutexGuard};
/// 文件锁注册表 — 防止同一文件被并发操作(如上传中删除)
pub struct FileLockRegistry {
locks: DashMap<String, Arc<Mutex<()>>>,
}
impl Default for FileLockRegistry {
fn default() -> Self {
Self::new()
}
}
impl FileLockRegistry {
pub fn new() -> Self {
Self {
locks: DashMap::new(),
}
}
/// 阻塞等待获取文件锁,返回守卫
pub async fn acquire(&self, path: &str) -> FileLockGuard<'_> {
let lock = self
.locks
.entry(path.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let guard = lock.lock_owned().await;
FileLockGuard {
registry: self,
path: path.to_string(),
_guard: guard,
}
}
/// 移除无人等待的锁条目(守卫 Drop 时调用)
fn cleanup_if_unused(&self, path: &str) {
if let Some(entry) = self.locks.get(path) {
if Arc::strong_count(entry.value()) == 1 {
drop(entry);
self.locks.remove(path);
}
}
}
/// 供外部引用计数判断
pub fn get_weak(&self, path: &str) -> Weak<Mutex<()>> {
let lock = self
.locks
.entry(path.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
Arc::downgrade(&lock)
}
}
/// 文件锁守卫 — Drop 时自动清理无人等待的锁条目
pub struct FileLockGuard<'a> {
registry: &'a FileLockRegistry,
path: String,
_guard: OwnedMutexGuard<()>,
}
impl Drop for FileLockGuard<'_> {
fn drop(&mut self) {
self.registry.cleanup_if_unused(&self.path);
}
}
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
#[cfg(unix)]
use std::collections::HashSet;
use crate::errors::Result;
use crate::models::LocalFileEntry;
use crate::utils::quick_hash;
use std::path::Path;
use walkdir::WalkDir;
/// 需要跳过的文件/目录名前缀和名称
pub const SKIP_NAMES: &[&str] = &[
".DS_Store",
"Thumbs.db",
"desktop.ini",
];
/// 需要跳过的文件扩展名(同步临时文件)
pub const SKIP_EXTENSIONS: &[&str] = &[
"sync_tmp",
"sync_temp",
];
pub struct FsScanner;
impl Default for FsScanner {
fn default() -> Self {
Self::new()
}
}
impl FsScanner {
pub fn new() -> Self {
Self
}
/// 递归扫描本地目录
/// `compute_hash`: 是否计算文件 quick_hashMirrorWcf 模式下可跳过以加速扫描)
pub async fn scan(
&self,
root: &Path,
depth_limit: u32,
follow_symlinks: bool,
compute_hash: bool,
) -> Result<Vec<LocalFileEntry>> {
let mut entries = Vec::new();
#[cfg(unix)]
let mut visited_inodes: HashSet<(u64, u64)> = HashSet::new();
let walker = WalkDir::new(root)
.max_depth(depth_limit as usize)
.follow_links(follow_symlinks);
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!("扫描跳过: {}", e);
continue;
}
};
// 符号链接处理
if entry.path_is_symlink() && !follow_symlinks {
continue;
}
// 跳过同步元数据文件和系统文件
let file_name = entry.file_name().to_string_lossy();
if SKIP_NAMES.iter().any(|s| file_name == *s) {
continue;
}
if file_name.starts_with(".sync_") {
continue;
}
// 跳过临时文件扩展名
if let Some(ext) = entry.path().extension() {
if SKIP_EXTENSIONS.iter().any(|e| ext == *e) {
continue;
}
}
// 跳过冲突副本文件
if crate::utils::is_conflict_file(&file_name) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!("无法读取元数据 {}: {}", entry.path().display(), e);
continue;
}
};
// inode 去重(防止硬链接/符号链接循环)
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let key = (metadata.dev(), metadata.ino());
if !visited_inodes.insert(key) {
continue;
}
}
let relative_path = entry.path().strip_prefix(root)
.unwrap_or(entry.path())
.to_path_buf();
// 跳过根目录自身(relative_path 为空)
if relative_path.to_string_lossy().is_empty() {
continue;
}
if metadata.is_dir() {
entries.push(LocalFileEntry {
relative_path,
size: 0,
mtime_ms: 0,
quick_hash: String::new(),
is_dir: true,
mime_type: None,
});
} else if metadata.is_file() {
let size = metadata.len();
let mtime_ms = metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let hash = if compute_hash {
quick_hash(entry.path(), size).await.unwrap_or_default()
} else {
String::new()
};
let mime_type = guess_mime_type(entry.path());
entries.push(LocalFileEntry {
relative_path,
size,
mtime_ms,
quick_hash: hash,
is_dir: false,
mime_type,
});
}
}
Ok(entries)
}
}
/// 根据文件扩展名推断 MIME 类型
pub fn guess_mime_type(path: &Path) -> Option<String> {
mime_guess::from_path(path)
.first()
.map(|m| m.to_string())
}
+20
View File
@@ -0,0 +1,20 @@
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 server_error_code;
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;
+630
View File
@@ -0,0 +1,630 @@
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,
/// 远程存储的上传 URL 列表(每个分片一个 URL)
/// 仅当 storage_policy_type != "local" 时有值
pub upload_urls: Vec<String>,
/// 存储策略类型:local / onedrive / s3 / oss / cos / etc.
pub storage_policy_type: String,
/// 回调密钥(远程存储上传完成后需要回调通知服务端)
pub callback_secret: String,
/// 上传文件名(仅用于日志标识,并发时区分不同文件)
pub file_name: String,
}
impl UploadSession {
/// 是否使用远程存储直接上传(非本地策略)
pub fn is_remote_storage(&self) -> bool {
self.storage_policy_type != "local" && !self.upload_urls.is_empty()
}
/// 获取第 index 个分片的上传 URL
/// 远程存储:返回 upload_urls[index](不足则用最后一个)
/// 本地存储:返回 None,由调用方拼接 /file/upload/{session_id}/{index}
pub fn chunk_upload_url(&self, index: usize) -> Option<&str> {
if !self.is_remote_storage() {
return None;
}
if self.upload_urls.is_empty() {
return None;
}
Some(self.upload_urls[index.min(self.upload_urls.len() - 1)].as_str())
}
}
// ===== API 分页响应 =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListFilesResponse {
pub files: Vec<RemoteFileEntry>,
pub pagination: Pagination,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub next_page_token: Option<String>,
pub is_cursor: bool,
pub total: Option<u64>,
}
// ===== 同步计划 =====
/// 重命名操作(本地触发 → 远程重命名,同目录下改文件名)
#[derive(Debug, Clone)]
pub struct RenameAction {
pub old_relative_path: String,
pub new_relative_path: String,
pub remote_uri: String,
pub new_name: String,
}
/// 移动操作(本地触发 → 远程移动,跨目录)
#[derive(Debug, Clone)]
pub struct MoveAction {
pub old_relative_path: String,
pub new_relative_path: String,
pub remote_uri: String,
pub dst_remote_dir_uri: String,
}
/// 本地重命名/移动操作(远程触发 → 本地执行)
#[derive(Debug, Clone)]
pub struct LocalRenameAction {
pub old_relative_path: String,
pub new_relative_path: String,
pub new_remote_uri: String,
}
#[derive(Debug, Clone, Default)]
pub struct SyncPlan {
pub uploads: Vec<SyncAction>,
pub downloads: Vec<SyncAction>,
pub delete_local: Vec<SyncAction>,
pub delete_remote: Vec<SyncAction>,
pub rename_remote: Vec<RenameAction>,
pub move_remote: Vec<MoveAction>,
pub rename_local: Vec<LocalRenameAction>,
pub move_local: Vec<LocalRenameAction>,
pub conflicts: Vec<SyncConflict>,
pub mkdirs_local: Vec<String>,
pub mkdirs_remote: Vec<String>,
/// 需要递归扫描的顶层目录(持续同步场景,目录整体提交给 Worker)
pub scan_dirs: Vec<String>,
}
impl SyncPlan {
/// 判断是否有实际操作需要执行(含 scan_dirs 则为非空,因为扫描可能发现新文件)
pub fn has_work(&self) -> bool {
!self.uploads.is_empty()
|| !self.downloads.is_empty()
|| !self.delete_local.is_empty()
|| !self.delete_remote.is_empty()
|| !self.rename_remote.is_empty()
|| !self.move_remote.is_empty()
|| !self.rename_local.is_empty()
|| !self.move_local.is_empty()
|| !self.conflicts.is_empty()
|| !self.scan_dirs.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct SyncAction {
pub relative_path: String,
pub local_entry: Option<LocalFileEntry>,
pub remote_entry: Option<RemoteFileEntry>,
pub db_mapping: Option<FileMapping>,
}
#[derive(Debug, Clone)]
pub struct SyncConflict {
pub relative_path: String,
pub conflict_type: ConflictType,
pub local_entry: Option<LocalFileEntry>,
pub remote_entry: Option<RemoteFileEntry>,
pub db_mapping: Option<FileMapping>,
}
impl SyncPlan {
pub fn total_actions(&self) -> u64 {
self.uploads.len() as u64
+ self.downloads.len() as u64
+ self.delete_local.len() as u64
+ self.delete_remote.len() as u64
+ self.rename_remote.len() as u64
+ self.move_remote.len() as u64
}
}
// ===== Worker / Task 类型 =====
/// Worker 配置快照 — 创建时一次性读取,运行中不读任何锁
#[derive(Debug, Clone)]
pub struct WorkerConfig {
pub local_root: PathBuf,
pub remote_root: String,
pub max_concurrent_transfers: usize,
pub bandwidth_limit: Option<u64>,
pub conflict_strategy: ConflictStrategy,
pub wcf_delete_mode: WcfDeleteMode,
pub sync_root_id: String,
pub sync_mode: SyncMode,
}
/// Worker 触发来源
#[derive(Debug, Clone, PartialEq)]
pub enum WorkerTrigger {
InitialSync,
Continuous,
Manual,
}
impl WorkerTrigger {
pub fn as_str(&self) -> &'static str {
match self {
WorkerTrigger::InitialSync => "initial_sync",
WorkerTrigger::Continuous => "continuous",
WorkerTrigger::Manual => "manual",
}
}
}
/// Worker 级别状态
#[derive(Debug, Clone, PartialEq)]
pub enum WorkerStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
impl WorkerStatus {
pub fn as_str(&self) -> &'static str {
match self {
WorkerStatus::Pending => "pending",
WorkerStatus::Running => "running",
WorkerStatus::Completed => "completed",
WorkerStatus::Failed => "failed",
WorkerStatus::Cancelled => "cancelled",
}
}
}
impl std::str::FromStr for WorkerStatus {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"pending" => Ok(WorkerStatus::Pending),
"running" => Ok(WorkerStatus::Running),
"completed" => Ok(WorkerStatus::Completed),
"failed" => Ok(WorkerStatus::Failed),
"cancelled" => Ok(WorkerStatus::Cancelled),
_ => Err(()),
}
}
}
/// 单文件操作状态
#[derive(Debug, Clone, PartialEq)]
pub enum TaskItemStatus {
Pending,
Running,
Completed,
Failed,
Skipped,
}
impl TaskItemStatus {
pub fn as_str(&self) -> &'static str {
match self {
TaskItemStatus::Pending => "pending",
TaskItemStatus::Running => "running",
TaskItemStatus::Completed => "completed",
TaskItemStatus::Failed => "failed",
TaskItemStatus::Skipped => "skipped",
}
}
}
impl std::str::FromStr for TaskItemStatus {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"pending" => Ok(TaskItemStatus::Pending),
"running" => Ok(TaskItemStatus::Running),
"completed" => Ok(TaskItemStatus::Completed),
"failed" => Ok(TaskItemStatus::Failed),
"skipped" => Ok(TaskItemStatus::Skipped),
_ => Err(()),
}
}
}
/// 操作类型 — 精确记录每个文件的操作
#[derive(Debug, Clone, PartialEq)]
pub enum TaskActionType {
Upload,
Download,
DeleteLocal,
DeleteRemote,
Rename,
Move,
MkdirRemote,
MkdirLocal,
ConflictResolve,
CreatePlaceholder,
}
impl TaskActionType {
pub fn as_str(&self) -> &'static str {
match self {
TaskActionType::Upload => "upload",
TaskActionType::Download => "download",
TaskActionType::DeleteLocal => "delete_local",
TaskActionType::DeleteRemote => "delete_remote",
TaskActionType::Rename => "rename",
TaskActionType::Move => "move",
TaskActionType::MkdirRemote => "mkdir_remote",
TaskActionType::MkdirLocal => "mkdir_local",
TaskActionType::ConflictResolve => "conflict_resolve",
TaskActionType::CreatePlaceholder => "create_placeholder",
}
}
}
impl std::str::FromStr for TaskActionType {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"upload" => Ok(TaskActionType::Upload),
"download" => Ok(TaskActionType::Download),
"delete_local" => Ok(TaskActionType::DeleteLocal),
"delete_remote" => Ok(TaskActionType::DeleteRemote),
"rename" => Ok(TaskActionType::Rename),
"move" => Ok(TaskActionType::Move),
"mkdir_remote" => Ok(TaskActionType::MkdirRemote),
"mkdir_local" => Ok(TaskActionType::MkdirLocal),
"conflict_resolve" => Ok(TaskActionType::ConflictResolve),
"create_placeholder" => Ok(TaskActionType::CreatePlaceholder),
_ => Err(()),
}
}
}
/// Worker 级别的任务摘要 — DB 持久化,供 UI 查询
#[derive(Debug, Clone)]
pub struct SyncTask {
pub id: String,
pub trigger: WorkerTrigger,
pub total_count: u32,
pub completed_count: u32,
pub failed_count: u32,
pub status: WorkerStatus,
pub created_at: String,
pub updated_at: String,
pub finished_at: Option<String>,
}
/// 单文件操作记录 — DB 持久化,支持多维度查询
#[derive(Debug, Clone)]
pub struct SyncTaskItem {
pub id: i64,
pub task_id: String,
pub relative_path: String,
pub action_type: TaskActionType,
pub status: TaskItemStatus,
pub file_size: u64,
pub error_message: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 任务项查询过滤器
#[derive(Debug, Clone, Default)]
pub struct TaskItemFilter {
pub task_id: Option<String>,
pub relative_path_contains: Option<String>,
pub action_type: Option<String>,
pub status: Option<String>,
pub limit: u32,
pub offset: u32,
}
// ===== 云端相册检查结果 =====
#[derive(Debug, Clone)]
pub struct CloudAlbumCheckResult {
pub dcim_exists: bool,
pub pictures_exists: bool,
pub dcim_uri: Option<String>,
pub pictures_uri: Option<String>,
}
+36
View File
@@ -0,0 +1,36 @@
/// 平台适配器 trait — 各平台(Windows WCF / Linux / Android)实现此接口
#[cfg(feature = "windows-cfapi")]
use async_trait::async_trait;
#[cfg(feature = "windows-cfapi")]
use crate::errors::Result;
#[cfg(feature = "windows-cfapi")]
use crate::models::{LocalFileEvent, RemoteFileEntry};
#[cfg(feature = "windows-cfapi")]
use std::path::Path;
#[cfg(feature = "windows-cfapi")]
use tokio::sync::mpsc;
#[cfg(feature = "windows-cfapi")]
#[async_trait]
pub trait PlatformAdapter: Send + Sync {
/// 初始同步后的平台初始化
async fn post_initial_sync(&self, config: &crate::models::SyncConfig) -> Result<()>;
/// 监听本地文件变化,返回事件接收器
fn watch_local_changes(&self) -> Result<mpsc::Receiver<LocalFileEvent>>;
/// 创建本地文件占位符(Windows)或实际文件(Linux
async fn create_local_entry(&self, entry: &RemoteFileEntry, local_path: &Path) -> Result<()>;
/// 水合文件
async fn hydrate_file(&self, local_path: &Path, remote_url: &str) -> Result<()>;
/// 脱水文件
async fn dehydrate_file(&self, local_path: &Path) -> Result<()>;
/// 关闭平台监听
async fn shutdown(&self) -> Result<()>;
}
#[cfg(feature = "windows-cfapi")]
pub mod wcf;
+152
View File
@@ -0,0 +1,152 @@
/// Windows Cloud Filter API 平台适配器
/// 仅在 windows-cfapi feature 启用时编译
#[cfg(feature = "windows-cfapi")]
use std::path::Path;
#[cfg(feature = "windows-cfapi")]
use std::sync::Arc;
#[cfg(feature = "windows-cfapi")]
use tokio::sync::mpsc;
#[cfg(feature = "windows-cfapi")]
use crate::api_client::ApiClient;
#[cfg(feature = "windows-cfapi")]
use crate::errors::Result;
#[cfg(feature = "windows-cfapi")]
use crate::models::SyncConfig;
#[cfg(feature = "windows-cfapi")]
use crate::sync_db::SyncDb;
#[cfg(feature = "windows-cfapi")]
use crate::worker::PlaceholderCreator;
#[cfg(feature = "windows-cfapi")]
pub struct WcfPlatformAdapter {
adapter: std::sync::Mutex<sync_windows::WindowsAdapter>,
fetch_rx: std::sync::Mutex<Option<mpsc::Receiver<sync_windows::FetchDataRequest>>>,
#[allow(dead_code)]
db: Arc<SyncDb>,
#[allow(dead_code)]
api: Arc<ApiClient>,
#[allow(dead_code)]
config: SyncConfig,
}
#[cfg(feature = "windows-cfapi")]
impl WcfPlatformAdapter {
pub fn new(
db: Arc<SyncDb>,
api: Arc<ApiClient>,
config: SyncConfig,
) -> anyhow::Result<Self> {
let mut adapter = sync_windows::WindowsAdapter::new();
// 注册同步根目录
adapter.register_sync_root(
&config.local_root,
"Cloudreve4",
"1.0",
)?;
// 连接同步根,注册回调
adapter.connect_sync_root(&config.local_root)?;
let fetch_rx = adapter.take_callback_receiver();
tracing::info!("WcfPlatformAdapter 初始化完成: {}", config.local_root.display());
Ok(Self {
adapter: std::sync::Mutex::new(adapter),
fetch_rx: std::sync::Mutex::new(fetch_rx),
db,
api,
config,
})
}
/// 取走 FETCH_DATA 回调接收端(供 SyncEngine 持续同步消费)
pub fn take_fetch_receiver(&self) -> Option<mpsc::Receiver<sync_windows::FetchDataRequest>> {
self.fetch_rx.lock().unwrap().take()
}
/// 创建占位符文件
pub fn create_placeholder_for_remote(
&self,
base_dir: &Path,
file_name: &str,
file_size: u64,
remote_uri: &str,
remote_hash: Option<&str>,
remote_mtime_ms: i64,
) -> Result<()> {
let file_identity = serde_json::to_vec(&serde_json::json!({
"uri": remote_uri,
"size": file_size,
"hash": remote_hash.unwrap_or(""),
"mtime_ms": remote_mtime_ms,
})).unwrap_or_default();
self.adapter.lock().unwrap().create_single_placeholder(
base_dir,
file_name,
file_size,
&file_identity,
).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))?;
Ok(())
}
/// 水合文件(按需下载)
pub fn hydrate_file(&self, local_path: &Path) -> Result<()> {
self.adapter.lock().unwrap().hydrate_placeholder(local_path)
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
/// 脱水文件(释放本地空间)
pub fn dehydrate_file(&self, local_path: &Path) -> Result<()> {
self.adapter.lock().unwrap().dehydrate_placeholder(local_path)
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
/// 通过 CfExecute 将数据推送回 CFApi(内核层写入,绕过文件锁)
pub fn fulfill_fetch_data(
connection_key: i64,
transfer_key: i64,
data: &[u8],
offset: i64,
) -> Result<()> {
sync_windows::WindowsAdapter::fulfill_fetch_data(connection_key, transfer_key, data, offset)
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
/// 通过 CfExecute 报告水合失败
pub fn reject_fetch_data(
connection_key: i64,
transfer_key: i64,
) -> Result<()> {
sync_windows::WindowsAdapter::reject_fetch_data(connection_key, transfer_key)
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
/// 断开连接
pub fn disconnect(&self) -> Result<()> {
self.adapter.lock().unwrap().disconnect()
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
}
#[cfg(feature = "windows-cfapi")]
#[async_trait::async_trait]
impl PlaceholderCreator for WcfPlatformAdapter {
async fn create_placeholder_file(
&self,
base_dir: &Path,
file_name: String,
file_size: u64,
file_identity: &[u8],
) -> Result<()> {
self.adapter.lock().unwrap().create_single_placeholder(
base_dir,
&file_name,
file_size,
file_identity,
).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
}
}
+92
View File
@@ -0,0 +1,92 @@
use crate::errors::SyncError;
/// Cloudreve 服务端错误码 → 中文描述映射
pub fn server_code_desc(code: i32) -> Option<&'static str> {
Some(match code {
// HTTP 语义码
203 => "部分操作未成功",
401 => "未登录",
403 => "无权限访问",
404 => "资源不存在",
409 => "资源冲突",
// 4xxxx 业务码
40001 => "参数错误",
40002 => "上传失败",
40003 => "文件夹创建失败",
40004 => "对象已存在",
40005 => "签名已过期",
40006 => "当前存储策略不允许",
40007 => "用户组不允许此操作",
40008 => "需要管理员权限",
40009 => "主节点未注册",
40010 => "需要绑定手机",
40011 => "上传会话已过期",
40012 => "无效的分片序号",
40013 => "无效的 Content-Length",
40014 => "批量源大小超限",
40016 => "父目录不存在",
40017 => "用户被封禁",
40018 => "用户未激活",
40019 => "功能未启用",
40020 => "凭据无效",
40021 => "用户不存在",
40022 => "两步验证码错误",
40023 => "登录会话不存在",
40026 => "验证码错误",
40027 => "验证码需要刷新",
40035 => "存储策略不存在",
40044 => "文件未找到",
40045 => "列出文件失败",
40049 => "文件过大",
40050 => "文件类型不允许",
40051 => "用户容量不足",
40052 => "非法对象名",
40053 => "根目录受保护",
40054 => "同名文件正在上传",
40055 => "元数据不匹配",
40057 => "可用存储策略已变更",
40071 => "签名无效",
40073 => "文件锁定冲突",
40074 => "URI 数量过多",
40075 => "锁令牌已过期",
40077 => "实体不存在",
40078 => "文件在回收站中",
40079 => "文件数量已达上限",
40081 => "批量操作未完全完成",
40082 => "仅所有者可操作",
// 5xxxx 服务端内部错误
50001 => "数据库操作失败",
50002 => "加密失败",
50004 => "IO 操作失败",
50006 => "缓存操作失败",
50007 => "回调失败",
50010 => "节点离线",
_ => return None,
})
}
/// 将服务端业务错误码转为 SyncError
pub fn api_code_to_error(code: i32, msg: &str) -> SyncError {
let desc = server_code_desc(code);
let detail = if msg.is_empty() {
desc.unwrap_or("未知错误").to_string()
} else if let Some(d) = desc {
if msg != d {
format!("{}: {}", d, msg)
} else {
msg.to_string()
}
} else {
msg.to_string()
};
match code {
401 => SyncError::Auth(detail),
40004 => SyncError::ObjectExisted,
40006 | 40035 | 40057 => SyncError::StoragePolicyDenied(detail),
40002 | 40011 | 40012 | 40013 | 40054 => SyncError::UploadFailed(detail),
40044 | 40077 => SyncError::FileNotFound(detail),
40017 | 40018 | 40007 | 40008 => SyncError::PermissionDenied(detail),
_ => SyncError::Network(format!("[{}] {}", code, detail)),
}
}
+972
View File
@@ -0,0 +1,972 @@
use r2d2::CustomizeConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::Connection;
use std::path::Path;
use tokio::sync::Mutex;
use crate::errors::Result;
use crate::models::*;
pub struct SyncDb {
write_conn: Mutex<Connection>,
read_pool: r2d2::Pool<SqliteConnectionManager>,
}
#[derive(Debug)]
struct SyncDbConnectionCustomizer;
impl CustomizeConnection<Connection, rusqlite::Error> for SyncDbConnectionCustomizer {
fn on_acquire(&self, conn: &mut Connection) -> std::result::Result<(), rusqlite::Error> {
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;",
)?;
Ok(())
}
}
impl SyncDb {
pub fn read_pool(&self) -> r2d2::Pool<SqliteConnectionManager> {
self.read_pool.clone()
}
pub fn open(db_path: &Path) -> Result<Self> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let write_conn = Connection::open(db_path)?;
write_conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA temp_store=MEMORY;",
)?;
Self::run_migrations(&write_conn)?;
let manager = SqliteConnectionManager::file(db_path);
let read_pool = r2d2::Pool::builder()
.max_size(4)
.connection_customizer(Box::new(SyncDbConnectionCustomizer))
.build(manager)?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_pool,
})
}
fn run_migrations(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sync_root (
id TEXT PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uri TEXT NOT NULL,
sync_mode TEXT NOT NULL DEFAULT 'full',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS file_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_root_id TEXT NOT NULL REFERENCES sync_root(id),
local_path TEXT NOT NULL,
remote_uri TEXT NOT NULL,
remote_file_id TEXT,
local_hash TEXT,
remote_hash TEXT,
local_mtime INTEGER,
remote_mtime INTEGER,
local_size INTEGER,
remote_size INTEGER,
sync_status TEXT NOT NULL DEFAULT 'synced',
is_placeholder INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
UNIQUE(sync_root_id, local_path)
);
CREATE INDEX IF NOT EXISTS idx_file_mapping_remote ON file_mapping(sync_root_id, remote_uri);
CREATE INDEX IF NOT EXISTS idx_file_mapping_status ON file_mapping(sync_status);
CREATE TABLE IF NOT EXISTS conflict (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_root_id TEXT NOT NULL REFERENCES sync_root(id),
local_path TEXT NOT NULL,
conflict_type TEXT NOT NULL,
resolution TEXT,
local_hash TEXT,
remote_hash TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
);
CREATE TABLE IF NOT EXISTS transfer_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_root_id TEXT NOT NULL REFERENCES sync_root(id),
file_mapping_id INTEGER REFERENCES file_mapping(id),
direction TEXT NOT NULL,
local_path TEXT NOT NULL,
remote_uri TEXT NOT NULL,
file_size INTEGER NOT NULL,
bytes_done INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 5,
error_message TEXT,
session_id TEXT,
chunk_index INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transfer_status ON transfer_queue(status);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_root_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS album_sync_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_path TEXT NOT NULL UNIQUE,
remote_uri TEXT NOT NULL,
file_hash TEXT,
synced_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_task (
id TEXT PRIMARY KEY,
trigger TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
completed_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS sync_task_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES sync_task(id),
relative_path TEXT NOT NULL,
action_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
file_size INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_item_task ON sync_task_item(task_id);
CREATE INDEX IF NOT EXISTS idx_task_item_path ON sync_task_item(relative_path);
CREATE INDEX IF NOT EXISTS idx_task_item_action ON sync_task_item(action_type);
CREATE INDEX IF NOT EXISTS idx_task_item_status ON sync_task_item(status);
CREATE INDEX IF NOT EXISTS idx_task_status ON sync_task(status);",
)?;
// 添加 task_item 唯一索引(去重:保留 id 最小的记录)
// 先清理重复数据,再建唯一索引
let existing: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_task_item_unique'")?
.query_map([], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
if existing.is_empty() {
conn.execute_batch(
"DELETE FROM sync_task_item WHERE id NOT IN (
SELECT MIN(id) FROM sync_task_item GROUP BY task_id, relative_path, action_type
);
CREATE UNIQUE INDEX idx_task_item_unique ON sync_task_item(task_id, relative_path, action_type);"
)?;
}
Ok(())
}
// ===== sync_root 操作 =====
pub async fn upsert_sync_root(&self, root: &SyncConfig) -> Result<String> {
let local_path = root.local_root.to_string_lossy().to_string();
let now = chrono::Utc::now().to_rfc3339();
let sync_mode = match root.sync_mode {
SyncMode::Full => "full",
SyncMode::UploadOnly => "upload_only",
SyncMode::DownloadOnly => "download_only",
SyncMode::Album => "album",
SyncMode::MirrorWcf => "mirror_wcf",
};
let conn = self.write_conn.lock().await;
// 先查找是否已存在
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM sync_root WHERE local_path = ?1",
rusqlite::params![local_path],
|row| row.get(0),
)
.ok();
if let Some(id) = existing_id {
// 更新已有记录,保留 id 和 created_at
conn.execute(
"UPDATE sync_root SET remote_uri = ?1, sync_mode = ?2, updated_at = ?3 WHERE id = ?4",
rusqlite::params![root.remote_root, sync_mode, now, id],
)?;
Ok(id)
} else {
// 新建记录
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO sync_root (id, local_path, remote_uri, sync_mode, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)",
rusqlite::params![id, local_path, root.remote_root, sync_mode, now],
)?;
Ok(id)
}
}
// ===== file_mapping 操作 =====
pub async fn get_file_mapping(
&self,
sync_root_id: &str,
local_path: &str,
) -> Result<Option<FileMapping>> {
let pool = self.read_pool.clone();
let sync_root_id = sync_root_id.to_string();
let local_path = local_path.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Option<FileMapping>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1 AND local_path = ?2",
)?;
let mapping = stmt
.query_row(rusqlite::params![sync_root_id, local_path], |row| {
Ok(FileMapping {
id: row.get(0)?,
sync_root_id: row.get(1)?,
local_path: std::path::PathBuf::from(row.get::<_, String>(2)?),
remote_uri: row.get(3)?,
remote_file_id: row.get(4)?,
local_hash: row.get(5)?,
remote_hash: row.get(6)?,
local_mtime: row.get(7)?,
remote_mtime: row.get(8)?,
local_size: row.get(9)?,
remote_size: row.get(10)?,
sync_status: parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0,
})
})
.ok();
Ok(mapping)
})
.await??;
Ok(result)
}
/// 通过远程 URI 查找文件映射(WCF 水合时使用)
pub async fn find_mapping_by_remote_uri(
&self,
sync_root_id: &str,
remote_uri: &str,
) -> Result<Option<FileMapping>> {
let pool = self.read_pool.clone();
let sync_root_id = sync_root_id.to_string();
let remote_uri = remote_uri.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Option<FileMapping>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1 AND remote_uri = ?2",
)?;
let mapping = stmt
.query_row(rusqlite::params![sync_root_id, remote_uri], |row| {
Ok(FileMapping {
id: row.get(0)?,
sync_root_id: row.get(1)?,
local_path: std::path::PathBuf::from(row.get::<_, String>(2)?),
remote_uri: row.get(3)?,
remote_file_id: row.get(4)?,
local_hash: row.get(5)?,
remote_hash: row.get(6)?,
local_mtime: row.get(7)?,
remote_mtime: row.get(8)?,
local_size: row.get(9)?,
remote_size: row.get(10)?,
sync_status: parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0,
})
})
.ok();
Ok(mapping)
})
.await??;
Ok(result)
}
/// 查询所有占位符文件映射(用于 WCF 退出时清理)
#[cfg(feature = "windows-cfapi")]
pub async fn list_placeholder_mappings(
&self,
sync_root_id: &str,
) -> Result<Vec<FileMapping>> {
let pool = self.read_pool.clone();
let sync_root_id = sync_root_id.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<FileMapping>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1",
)?;
let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| {
Ok(FileMapping {
id: row.get(0)?,
sync_root_id: row.get(1)?,
local_path: std::path::PathBuf::from(row.get::<_, String>(2)?),
remote_uri: row.get(3)?,
remote_file_id: row.get(4)?,
local_hash: row.get(5)?,
remote_hash: row.get(6)?,
local_mtime: row.get(7)?,
remote_mtime: row.get(8)?,
local_size: row.get(9)?,
remote_size: row.get(10)?,
sync_status: parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0,
})
})?.filter_map(|m| m.ok()).collect();
Ok(mappings)
}).await??;
Ok(result)
}
pub async fn upsert_file_mapping(&self, mapping: &FileMapping) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
let status_str = sync_status_str(&mapping.sync_status);
let local_path = crate::utils::normalize_path(&mapping.local_path.to_string_lossy());
let is_placeholder = if mapping.is_placeholder { 1 } else { 0 };
conn.execute(
"INSERT OR REPLACE INTO file_mapping
(sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
mapping.sync_root_id,
local_path,
mapping.remote_uri,
mapping.remote_file_id,
mapping.local_hash,
mapping.remote_hash,
mapping.local_mtime,
mapping.remote_mtime,
mapping.local_size,
mapping.remote_size,
status_str,
is_placeholder,
now,
],
)?;
Ok(())
}
pub async fn delete_file_mapping(&self, sync_root_id: &str, local_path: &str) -> Result<bool> {
let conn = self.write_conn.lock().await;
let rows = conn.execute(
"DELETE FROM file_mapping WHERE sync_root_id = ?1 AND local_path = ?2",
rusqlite::params![sync_root_id, local_path],
)?;
Ok(rows > 0)
}
/// 更新文件映射的路径(重命名时使用)
pub async fn update_file_mapping_path(
&self,
sync_root_id: &str,
old_relative_path: &str,
new_relative_path: &str,
new_remote_uri: &str,
) -> Result<bool> {
let conn = self.write_conn.lock().await;
let rows = conn.execute(
"UPDATE file_mapping SET local_path = ?1, remote_uri = ?2 WHERE sync_root_id = ?3 AND local_path = ?4",
rusqlite::params![new_relative_path, new_remote_uri, sync_root_id, old_relative_path],
)?;
Ok(rows > 0)
}
/// 删除指定目录前缀下的所有文件映射(含目录自身)
pub async fn delete_file_mapping_prefix(
&self,
sync_root_id: &str,
dir_prefix: &str,
) -> Result<u64> {
let conn = self.write_conn.lock().await;
// 匹配: dir_prefix 自身、dir_prefix/... 子路径
let exact = dir_prefix;
let child_prefix = format!("{}/", dir_prefix);
let rows = conn.execute(
"DELETE FROM file_mapping WHERE sync_root_id = ?1 AND (local_path = ?2 OR local_path LIKE ?3 || '%')",
rusqlite::params![sync_root_id, exact, child_prefix],
)?;
Ok(rows as u64)
}
// ===== transfer_queue 操作 =====
pub async fn add_transfer(&self, task: &TransferTask) -> Result<i64> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
let direction = match task.direction {
TransferDirection::Upload => "upload",
TransferDirection::Download => "download",
};
let status = transfer_status_str(&task.status);
let local_path = task.local_path.to_string_lossy().to_string();
conn.execute(
"INSERT INTO transfer_queue
(sync_root_id, file_mapping_id, direction, local_path, remote_uri,
file_size, bytes_done, status, retry_count, max_retries,
error_message, session_id, chunk_index, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14)",
rusqlite::params![
task.sync_root_id,
task.file_mapping_id,
direction,
local_path,
task.remote_uri,
task.file_size,
task.bytes_done,
status,
task.retry_count,
task.max_retries,
task.error_message,
task.session_id,
task.chunk_index,
now,
],
)?;
Ok(conn.last_insert_rowid())
}
pub async fn update_transfer_progress(&self, id: i64, bytes_done: u64) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE transfer_queue SET bytes_done = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![bytes_done, now, id],
)?;
Ok(())
}
pub async fn complete_transfer(&self, id: i64) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE transfer_queue SET status = 'completed', updated_at = ?1 WHERE id = ?2",
rusqlite::params![now, id],
)?;
Ok(())
}
pub async fn fail_transfer(&self, id: i64, error: &str) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE transfer_queue SET status = 'failed', error_message = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![error, now, id],
)?;
Ok(())
}
pub async fn get_interrupted_transfers(&self) -> Result<Vec<TransferTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<TransferTask>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, file_mapping_id, direction, local_path, remote_uri,
file_size, bytes_done, status, retry_count, max_retries,
error_message, session_id, chunk_index
FROM transfer_queue WHERE status IN ('active', 'pending')",
)?;
let tasks = stmt
.query_map([], |row| Ok(transfer_task_from_row(row)))?
.filter_map(|t| t.ok())
.collect();
Ok(tasks)
})
.await??;
Ok(result)
}
// ===== album_sync_record 操作 =====
pub async fn get_album_sync_records(
&self,
) -> Result<std::collections::HashMap<String, String>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(
move || -> Result<std::collections::HashMap<String, String>> {
let conn = pool.get()?;
let mut stmt =
conn.prepare("SELECT local_path, remote_uri FROM album_sync_record")?;
let records = stmt
.query_map([], |row| {
let local: String = row.get(0)?;
let remote: String = row.get(1)?;
Ok((local, remote))
})?
.filter_map(|r| r.ok())
.collect();
Ok(records)
},
)
.await??;
Ok(result)
}
pub async fn add_album_sync_record(
&self,
local_path: &str,
remote_uri: &str,
file_hash: &str,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT OR REPLACE INTO album_sync_record (local_path, remote_uri, file_hash, synced_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![local_path, remote_uri, file_hash, now],
)?;
Ok(())
}
// ===== sync_log 操作 =====
pub async fn add_log(
&self,
sync_root_id: &str,
event_type: &str,
details: Option<&str>,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sync_log (sync_root_id, event_type, details, created_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![sync_root_id, event_type, details, now],
)?;
Ok(())
}
// ===== sync_task 操作 =====
pub async fn create_sync_task(&self, task: &SyncTask) -> Result<()> {
let conn = self.write_conn.lock().await;
conn.execute(
"INSERT INTO sync_task (id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
task.id,
task.trigger.as_str(),
task.total_count,
task.completed_count,
task.failed_count,
task.status.as_str(),
task.created_at,
task.updated_at,
task.finished_at,
],
)?;
Ok(())
}
pub async fn update_sync_task_status(
&self,
task_id: &str,
status: &WorkerStatus,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task SET status = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![status.as_str(), now, task_id],
)?;
Ok(())
}
pub async fn update_sync_task_total_count(
&self,
task_id: &str,
total_count: u32,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task SET total_count = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![total_count, now, task_id],
)?;
Ok(())
}
pub async fn finish_sync_task(
&self,
task_id: &str,
status: &WorkerStatus,
completed: u32,
failed: u32,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task SET status = ?1, completed_count = ?2, failed_count = ?3, updated_at = ?4, finished_at = ?5 WHERE id = ?6",
rusqlite::params![status.as_str(), completed, failed, now, now, task_id],
)?;
Ok(())
}
pub async fn increment_task_completed(&self, task_id: &str) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task SET completed_count = completed_count + 1, updated_at = ?1 WHERE id = ?2",
rusqlite::params![now, task_id],
)?;
Ok(())
}
pub async fn increment_task_failed(&self, task_id: &str) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task SET failed_count = failed_count + 1, updated_at = ?1 WHERE id = ?2",
rusqlite::params![now, task_id],
)?;
Ok(())
}
pub async fn get_active_sync_tasks(&self) -> Result<Vec<SyncTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTask>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at
FROM sync_task WHERE status IN ('pending', 'running')
ORDER BY created_at DESC"
)?;
let tasks = stmt.query_map([], sync_task_from_row)?
.filter_map(|t| t.ok()).collect();
Ok(tasks)
}).await??;
Ok(result)
}
pub async fn get_recent_sync_tasks(&self, limit: u32) -> Result<Vec<SyncTask>> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTask>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at
FROM sync_task ORDER BY created_at DESC LIMIT ?1"
)?;
let tasks = stmt.query_map(rusqlite::params![limit], sync_task_from_row)?
.filter_map(|t| t.ok()).collect();
Ok(tasks)
}).await??;
Ok(result)
}
// ===== sync_task_item 操作 =====
pub async fn create_sync_task_item(&self, item: &SyncTaskItem) -> Result<i64> {
let conn = self.write_conn.lock().await;
conn.execute(
"INSERT INTO sync_task_item (task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
item.task_id,
item.relative_path,
item.action_type.as_str(),
item.status.as_str(),
item.file_size,
item.error_message,
item.created_at,
item.updated_at,
],
)?;
Ok(conn.last_insert_rowid())
}
/// 批量插入 task_item(单次持锁,用事务包裹)
pub async fn create_sync_task_items_batch(&self, items: &[SyncTaskItem]) -> Result<()> {
let conn = self.write_conn.lock().await;
let tx = conn.unchecked_transaction()?;
for item in items {
tx.execute(
"INSERT INTO sync_task_item (task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
item.task_id,
item.relative_path,
item.action_type.as_str(),
item.status.as_str(),
item.file_size,
item.error_message,
item.created_at,
item.updated_at,
],
)?;
}
tx.commit()?;
Ok(())
}
pub async fn update_sync_task_item_status(
&self,
item_id: i64,
status: &TaskItemStatus,
error_message: Option<&str>,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task_item SET status = ?1, error_message = ?2, updated_at = ?3 WHERE id = ?4",
rusqlite::params![status.as_str(), error_message, now, item_id],
)?;
Ok(())
}
/// 按 task_id + relative_path + action_type 更新 task_item 状态
pub async fn update_task_item_status_by_path(
&self,
task_id: &str,
relative_path: &str,
action_type: &str,
status: &TaskItemStatus,
error_message: Option<&str>,
) -> Result<()> {
let conn = self.write_conn.lock().await;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE sync_task_item SET status = ?1, error_message = ?2, updated_at = ?3 WHERE task_id = ?4 AND relative_path = ?5 AND action_type = ?6",
rusqlite::params![status.as_str(), error_message, now, task_id, relative_path, action_type],
)?;
Ok(())
}
pub async fn get_sync_task_items(&self, task_id: &str) -> Result<Vec<SyncTaskItem>> {
let pool = self.read_pool.clone();
let task_id = task_id.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTaskItem>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at
FROM sync_task_item WHERE task_id = ?1
ORDER BY CASE status
WHEN 'running' THEN 0
WHEN 'pending' THEN 1
WHEN 'failed' THEN 2
WHEN 'completed' THEN 3
WHEN 'skipped' THEN 4
ELSE 5 END, id"
)?;
let items = stmt.query_map(rusqlite::params![task_id], sync_task_item_from_row)?
.filter_map(|i| i.ok()).collect();
Ok(items)
}).await??;
Ok(result)
}
pub async fn query_task_items(&self, filter: &TaskItemFilter) -> Result<Vec<SyncTaskItem>> {
let pool = self.read_pool.clone();
let filter = filter.clone();
let result = tokio::task::spawn_blocking(move || -> Result<Vec<SyncTaskItem>> {
let conn = pool.get()?;
let mut sql = String::from(
"SELECT id, task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at
FROM sync_task_item WHERE 1=1"
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(ref task_id) = filter.task_id {
sql.push_str(" AND task_id = ?");
params.push(Box::new(task_id.clone()));
}
if let Some(ref path_contains) = filter.relative_path_contains {
sql.push_str(" AND relative_path LIKE ?");
params.push(Box::new(format!("%{}%", path_contains)));
}
if let Some(ref action_type) = filter.action_type {
sql.push_str(" AND action_type = ?");
params.push(Box::new(action_type.clone()));
}
if let Some(ref status) = filter.status {
sql.push_str(" AND status = ?");
params.push(Box::new(status.clone()));
}
sql.push_str(" ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'pending' THEN 1 WHEN 'failed' THEN 2 WHEN 'completed' THEN 3 WHEN 'skipped' THEN 4 ELSE 5 END, id DESC LIMIT ? OFFSET ?");
params.push(Box::new(filter.limit));
params.push(Box::new(filter.offset));
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let items = stmt.query_map(param_refs.as_slice(), sync_task_item_from_row)?
.filter_map(|i| i.ok()).collect();
Ok(items)
}).await??;
Ok(result)
}
/// 清空所有同步数据(保留 sync_root 和 album_sync_record
pub async fn reset_sync_data(&self) -> Result<()> {
let conn = self.write_conn.lock().await;
conn.execute_batch(
"DELETE FROM sync_task_item;
DELETE FROM sync_task;
DELETE FROM file_mapping;
DELETE FROM conflict;
DELETE FROM transfer_queue;
DELETE FROM sync_log;",
)?;
Ok(())
}
}
pub fn parse_sync_status(s: &str) -> SyncFileStatus {
match s {
"uploading" => SyncFileStatus::Uploading,
"downloading" => SyncFileStatus::Downloading,
"conflict" => SyncFileStatus::Conflict,
"placeholder" => SyncFileStatus::Placeholder,
_ => SyncFileStatus::Synced,
}
}
fn sync_status_str(s: &SyncFileStatus) -> &'static str {
match s {
SyncFileStatus::Synced => "synced",
SyncFileStatus::Uploading => "uploading",
SyncFileStatus::Downloading => "downloading",
SyncFileStatus::Conflict => "conflict",
SyncFileStatus::Placeholder => "placeholder",
}
}
fn transfer_status_str(s: &TransferStatus) -> &'static str {
match s {
TransferStatus::Pending => "pending",
TransferStatus::Active => "active",
TransferStatus::Paused => "paused",
TransferStatus::Completed => "completed",
TransferStatus::Failed => "failed",
}
}
fn transfer_task_from_row(row: &rusqlite::Row<'_>) -> TransferTask {
let direction_str: String = row.get(3).unwrap_or_default();
let direction = if direction_str == "upload" {
TransferDirection::Upload
} else {
TransferDirection::Download
};
let status_str: String = row.get(8).unwrap_or_default();
let status = match status_str.as_str() {
"active" => TransferStatus::Active,
"paused" => TransferStatus::Paused,
"completed" => TransferStatus::Completed,
"failed" => TransferStatus::Failed,
_ => TransferStatus::Pending,
};
TransferTask {
id: row.get(0).unwrap_or(0),
sync_root_id: row.get(1).unwrap_or_default(),
file_mapping_id: row.get(2).ok(),
direction,
local_path: std::path::PathBuf::from(row.get::<_, String>(4).unwrap_or_default()),
remote_uri: row.get(5).unwrap_or_default(),
file_size: row.get(6).unwrap_or(0),
bytes_done: row.get(7).unwrap_or(0),
status,
retry_count: row.get(9).unwrap_or(0),
max_retries: row.get(10).unwrap_or(5),
error_message: row.get(11).ok(),
session_id: row.get(12).ok(),
chunk_index: row.get(13).ok(),
}
}
fn sync_task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SyncTask> {
let trigger_str: String = row.get(1)?;
let trigger = match trigger_str.as_str() {
"initial_sync" => WorkerTrigger::InitialSync,
"continuous" => WorkerTrigger::Continuous,
"manual" => WorkerTrigger::Manual,
_ => WorkerTrigger::Manual,
};
let status_str: String = row.get(5)?;
Ok(SyncTask {
id: row.get(0)?,
trigger,
total_count: row.get(2)?,
completed_count: row.get(3)?,
failed_count: row.get(4)?,
status: status_str
.parse::<WorkerStatus>()
.unwrap_or(WorkerStatus::Pending),
created_at: row.get(6)?,
updated_at: row.get(7)?,
finished_at: row.get(8)?,
})
}
fn sync_task_item_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SyncTaskItem> {
let action_str: String = row.get(3)?;
let status_str: String = row.get(4)?;
Ok(SyncTaskItem {
id: row.get(0)?,
task_id: row.get(1)?,
relative_path: row.get(2)?,
action_type: action_str
.parse::<TaskActionType>()
.unwrap_or(TaskActionType::Upload),
status: status_str
.parse::<TaskItemStatus>()
.unwrap_or(TaskItemStatus::Pending),
file_size: row.get(5)?,
error_message: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
}
+63
View File
@@ -0,0 +1,63 @@
use crate::errors::Result;
use crate::models::*;
use std::path::Path;
use super::SyncEngine;
impl SyncEngine {
pub async fn sync_album(&self, album_paths: Vec<String>, remote_dcim_uri: &str) -> Result<()> {
let synced = self.db.get_album_sync_records().await?;
let new_photos: Vec<_> = album_paths.iter().filter(|p| !synced.contains_key(*p)).collect();
let total = new_photos.len();
if total == 0 { return Ok(()); }
for (i, photo_path) in new_photos.iter().enumerate() {
let local_path = Path::new(photo_path);
let file_name = local_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| format!("photo_{}", i));
match tokio::fs::metadata(photo_path).await {
Ok(metadata) => {
let file_size = metadata.len();
match self.api.create_upload_session(remote_dcim_uri, file_size, false, None, None, None).await {
Ok(session) => {
match crate::uploader::upload_file_chunked(&self.api, local_path, &session, "album").await {
Ok(_) => {
let remote_uri = format!("{}/{}", remote_dcim_uri, file_name);
let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default();
if let Err(e) = self.db.add_album_sync_record(photo_path, &remote_uri, &hash).await {
tracing::warn!("记录同步状态失败: {}", e);
}
tracing::info!("照片上传完成 ({}/{}): {}", i + 1, total, file_name);
}
Err(e) => tracing::error!("上传照片失败 {}: {}", file_name, e),
}
}
Err(e) => tracing::error!("创建上传会话失败 {}: {}", file_name, e),
}
}
Err(e) => tracing::warn!("无法读取照片元数据 {}: {}", photo_path, e),
}
}
Ok(())
}
pub async fn check_album_dirs(&self, base_uri: &str) -> Result<CloudAlbumCheckResult> {
let files = self.api.list_files_page(base_uri, 0, 200, None).await?;
let dcim_exists = files.files.iter().any(|f| f.name == "DCIM" && f.is_dir);
let pictures_exists = files.files.iter().any(|f| f.name == "Pictures" && f.is_dir);
Ok(CloudAlbumCheckResult {
dcim_exists,
pictures_exists,
dcim_uri: if dcim_exists { Some(format!("{}/DCIM", base_uri)) } else { None },
pictures_uri: if pictures_exists { Some(format!("{}/Pictures", base_uri)) } else { None },
})
}
pub async fn create_album_dirs(&self, base_uri: &str) -> Result<()> {
self.api.create_directory(base_uri, "DCIM").await?;
self.api.create_directory(base_uri, "Pictures").await?;
Ok(())
}
}
@@ -0,0 +1,215 @@
use crate::errors::Result;
use crate::event_handler::EventHandler;
use crate::models::*;
use super::SyncEngine;
impl SyncEngine {
/// 持续同步:双事件源驱动 (SSE + 本地文件监听),按 sync_mode 选择事件源
pub async fn run_continuous(&self) -> Result<()> {
let event_handler = EventHandler::new(
self.api.clone(),
self.api.client_id().to_string(),
);
let (local_root, remote_root, sync_mode) = {
let config = self.config.read().await;
(config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone())
};
// 仅 DownloadOnly、Full 和 MirrorWcf 订阅 SSE
let mut remote_rx = if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf) {
Some(event_handler.subscribe_sse(&remote_root).await?)
} else {
tracing::info!("仅上传模式: 不订阅 SSE 远程事件");
None
};
// 仅 UploadOnly、Full 和 MirrorWcf 启动本地文件监听
let mut local_rx = if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf) {
Some(spawn_local_watcher(&local_root, self.shutdown_token.lock().unwrap().clone()))
} else {
tracing::info!("仅下载模式: 不启动本地文件监听");
None
};
*self.state.write().await = SyncState::Continuous;
tracing::info!("持续同步已启动, 模式={:?}", sync_mode);
// MirrorWcf: 取走 WCF 回调接收端
#[cfg(feature = "windows-cfapi")]
let mut wcf_fetch_rx = if matches!(sync_mode, SyncMode::MirrorWcf) {
self.wcf_fetch_rx.lock().unwrap().take()
} else {
None
};
#[cfg(not(feature = "windows-cfapi"))]
let _wcf_fetch_rx: Option<()> = None;
let mut debounce = crate::event_handler::EventDebouncer::new(
std::time::Duration::from_millis(500),
);
let shutdown_token = self.shutdown_token.lock().unwrap().clone();
loop {
tokio::select! {
_ = shutdown_token.cancelled() => {
tracing::info!("持续同步收到停止信号");
break;
}
// 本地文件变化(仅 UploadOnly / Full / MirrorWcf
Some(event) = async {
match &mut local_rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
let mut all_events = vec![event];
let idle_timeout = std::time::Duration::from_secs(3);
if let Some(rx) = &mut local_rx {
while let Ok(Some(e)) = tokio::time::timeout(idle_timeout, rx.recv()).await {
all_events.push(e)
}
}
self.handle_local_events(all_events, &local_root, &mut debounce).await;
}
// 远程文件变化(DownloadOnly / Full / MirrorWcf
Some(event) = async {
match &mut remote_rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
self.handle_remote_event(event, &local_root, &remote_root).await;
}
// WCF 水合请求(仅 MirrorWcf
request = async {
#[cfg(feature = "windows-cfapi")]
{
match &mut wcf_fetch_rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
}
#[cfg(not(feature = "windows-cfapi"))]
{
let _: Option<()> = std::future::pending().await;
None::<()>
}
} => {
if let Some(req) = request {
#[cfg(feature = "windows-cfapi")]
self.handle_wcf_fetch(req, &local_root).await;
#[cfg(not(feature = "windows-cfapi"))]
let _: () = req;
}
}
// 定期心跳
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
tracing::trace!("持续同步心跳");
debounce.cleanup();
}
}
}
Ok(())
}
}
/// 启动本地文件监听器,返回事件接收端
fn spawn_local_watcher(
watch_root: &std::path::Path,
shutdown_token: tokio_util::sync::CancellationToken,
) -> tokio::sync::mpsc::Receiver<LocalFileEvent> {
let (local_tx, rx) = tokio::sync::mpsc::channel::<LocalFileEvent>(256);
let watch_root = watch_root.to_path_buf();
std::thread::spawn(move || {
use notify_debouncer_full::notify::{RecursiveMode, EventKind};
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::new_debouncer;
let tx = local_tx.clone();
let shutdown = shutdown_token.clone();
let mut debouncer = match new_debouncer(
std::time::Duration::from_millis(500),
None,
move |result: notify_debouncer_full::DebounceEventResult| {
match result {
Ok(events) => {
for event in events {
if shutdown.is_cancelled() { return; }
let kind = event.kind;
let paths = &event.paths;
let filtered: Vec<_> = paths.iter()
.filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false))
.cloned()
.collect();
if filtered.is_empty() { continue; }
match kind {
EventKind::Create(_) => {
let _ = tx.blocking_send(LocalFileEvent::Created(filtered));
}
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {}
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {}
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
if filtered.len() == 2 {
let _ = tx.blocking_send(LocalFileEvent::Renamed {
old_paths: vec![filtered[0].clone()],
new_paths: vec![filtered[1].clone()],
});
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => {
let _ = tx.blocking_send(LocalFileEvent::Modified(filtered));
}
EventKind::Modify(_) => {
let _ = tx.blocking_send(LocalFileEvent::Modified(filtered));
}
EventKind::Remove(_) => {
let _ = tx.blocking_send(LocalFileEvent::Deleted(filtered));
}
_ => {}
}
}
}
Err(errors) => {
for e in errors {
tracing::warn!("文件监听去抖错误: {}", e);
}
}
}
},
) {
Ok(d) => d,
Err(e) => {
tracing::error!("无法启动文件监听: {}", e);
return;
}
};
if let Err(e) = debouncer.watch(&watch_root, RecursiveMode::Recursive) {
tracing::error!("文件监听启动失败: {}", e);
return;
}
tracing::info!("本地文件监听已启动(debouncer): {}", watch_root.display());
while !shutdown_token.is_cancelled() {
std::thread::sleep(std::time::Duration::from_millis(500));
}
let _ = debouncer.unwatch(&watch_root);
tracing::info!("本地文件监听已停止");
});
rx
}
@@ -0,0 +1,124 @@
use crate::errors::{Result, SyncError};
use crate::fs_scanner::FsScanner;
use crate::models::*;
use std::time::Instant;
use super::SyncEngine;
impl SyncEngine {
/// 初始全量同步(受 sync_lock 保护,同一时间只允许一个同步操作)
pub async fn run_initial_sync(&self) -> Result<SyncSummary> {
// 获取互斥锁:如果旧同步还在运行,等待其退出(旧同步的 token 已被取消,worker 会快速退出)
let _guard = self.sync_lock.lock().await;
// 检查是否已被取消
if self.shutdown_token.lock().unwrap().is_cancelled() {
tracing::info!("初始同步已取消,跳过");
return Err(SyncError::Internal("同步已被取消".into()));
}
let start = Instant::now();
*self.state.write().await = SyncState::Initializing;
let (local_root, remote_root, sync_mode) = {
let config = self.config.read().await;
(config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone())
};
tracing::info!("开始初始同步, 模式={:?}", sync_mode);
let scanner = FsScanner::new();
tracing::info!("开始扫描本地文件系统: {}", local_root.display());
// MirrorWcf 模式跳过 hash 计算(占位符 size=0 被 diff 跳过,水合文件 hash 在需要时再算)
let compute_hash = !matches!(sync_mode, SyncMode::MirrorWcf);
let local_files = scanner.scan(&local_root, 50, false, compute_hash).await?;
tracing::info!("本地扫描完成: {} 个条目", local_files.len());
if self.shutdown_token.lock().unwrap().is_cancelled() {
return Err(SyncError::Internal("同步已被取消".into()));
}
tracing::info!("开始扫描远程文件树: {}", remote_root);
let remote_files = self.api.list_all_files(&remote_root).await?;
tracing::info!("远程扫描完成: {} 个条目", remote_files.len());
if self.shutdown_token.lock().unwrap().is_cancelled() {
return Err(SyncError::Internal("同步已被取消".into()));
}
let db_mappings = self.load_all_mappings().await?;
let plan = crate::diff::compute_diff(&local_files, &remote_files, &db_mappings, &remote_root, &sync_mode);
tracing::info!(
"差异计算完成: 上传={}, 下载={}, 删本地={}, 删远程={}, 冲突={}",
plan.uploads.len(),
plan.downloads.len(),
plan.delete_local.len(),
plan.delete_remote.len(),
plan.conflicts.len(),
);
if self.shutdown_token.lock().unwrap().is_cancelled() {
return Err(SyncError::Internal("同步已被取消".into()));
}
*self.state.write().await = SyncState::InitialSync {
progress: InitialSyncProgress {
scanned_local: local_files.len() as u64,
scanned_remote: remote_files.len() as u64,
total_to_sync: plan.total_actions(),
..Default::default()
},
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
// MirrorWcf 模式:初始化 WCF 平台适配器(仅首次,重复初始化会触发 CFApi 重新水合)
#[cfg(feature = "windows-cfapi")]
if matches!(sync_mode, SyncMode::MirrorWcf) {
let already_initialized = self.platform_adapter.lock().unwrap().is_some();
if !already_initialized {
let config = self.config.read().await;
let adapter = crate::platform::wcf::WcfPlatformAdapter::new(
self.db.clone(),
self.api.clone(),
config.clone(),
).map_err(|e| crate::errors::SyncError::Internal(e.to_string()))?;
let fetch_rx = adapter.take_fetch_receiver();
*self.wcf_fetch_rx.lock().unwrap() = fetch_rx;
let adapter_arc = std::sync::Arc::new(adapter);
*self.platform_adapter.lock().unwrap() = Some(adapter_arc.clone());
self.worker_pool.set_platform_adapter(adapter_arc);
*self.cached_local_root.lock().unwrap() = config.local_root.clone();
tracing::info!("MirrorWcf: WCF 平台适配器已初始化");
} else {
tracing::info!("MirrorWcf: WCF 平台适配器已存在,跳过重复初始化");
}
}
let result = self.worker_pool.submit(
plan, worker_config, WorkerTrigger::InitialSync, conflict_resolver,
).await;
match result {
Ok(summary) => {
*self.state.write().await = SyncState::Continuous;
tracing::info!("初始同步完成, 耗时 {}ms", start.elapsed().as_millis());
Ok(SyncSummary {
duration_ms: start.elapsed().as_millis() as u64,
..summary
})
}
Err(e) => {
// 被取消时不需要设 Error 状态
if self.shutdown_token.lock().unwrap().is_cancelled() {
tracing::info!("初始同步已取消");
Err(SyncError::Internal("同步已被取消".into()))
} else {
Err(e)
}
}
}
// _guard 在此处 drop,释放 sync_lock
}
}
@@ -0,0 +1,476 @@
use crate::models::*;
use std::path::Path;
use super::SyncEngine;
impl SyncEngine {
/// 处理本地事件批次
pub(crate) async fn handle_local_events(
&self,
all_events: Vec<LocalFileEvent>,
local_root: &std::path::Path,
debounce: &mut crate::event_handler::EventDebouncer,
) {
// DownloadOnly: 忽略所有本地事件
let (sync_mode, wcf_delete_mode) = {
let config = self.config.read().await;
(config.sync_mode.clone(), config.wcf_delete_mode.clone())
};
if matches!(sync_mode, SyncMode::DownloadOnly) {
return;
}
let root_id = self.sync_root_id.clone().unwrap_or_default();
// 清理过期的 suppress 记录(超过 30 秒)
let now = std::time::Instant::now();
self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30);
// === 第一步:提取 Renamed/Moved 事件,查 DB 构建操作 ===
let mut rename_remote: Vec<RenameAction> = Vec::new();
let mut move_remote: Vec<MoveAction> = Vec::new();
let mut handled_old_rels: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut handled_new_rels: std::collections::HashSet<String> = std::collections::HashSet::new();
for event in &all_events {
match event {
LocalFileEvent::Renamed { old_paths, new_paths } => {
for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) {
if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) {
if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) {
tracing::trace!("本地重命名被抑制(远程操作导致): {} -> {}", old_rel, new_rel);
continue;
}
let new_name = new_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await {
tracing::info!("检测到本地重命名: {} -> {}", old_rel, new_rel);
rename_remote.push(RenameAction {
old_relative_path: old_rel.clone(),
new_relative_path: new_rel.clone(),
remote_uri: mapping.remote_uri.clone(),
new_name,
});
handled_old_rels.insert(old_rel);
handled_new_rels.insert(new_rel);
} else {
tracing::info!("本地重命名但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel);
}
}
}
}
LocalFileEvent::Moved { old_paths, new_paths } => {
for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) {
if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) {
if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) {
tracing::trace!("本地移动被抑制(远程操作导致): {} -> {}", old_rel, new_rel);
continue;
}
if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await {
let remote_root = { self.config.read().await.remote_root.clone() };
let new_rel_path = std::path::PathBuf::from(&new_rel);
let dst_dir_rel = new_rel_path.parent()
.map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default();
let dst_remote_dir_uri = format!("{}/{}",
remote_root.trim_end_matches('/'),
dst_dir_rel.trim_start_matches('/'));
tracing::info!("检测到本地移动: {} -> {}", old_rel, new_rel);
move_remote.push(MoveAction {
old_relative_path: old_rel.clone(),
new_relative_path: new_rel.clone(),
remote_uri: mapping.remote_uri.clone(),
dst_remote_dir_uri,
});
handled_old_rels.insert(old_rel);
handled_new_rels.insert(new_rel);
} else {
tracing::info!("本地移动但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel);
}
}
}
}
_ => {}
}
}
// === 第二步:按事件类型分类路径,跳过已识别为 rename/move 的路径 ===
let mut create_paths: std::collections::BTreeMap<String, std::path::PathBuf> = std::collections::BTreeMap::new();
let mut delete_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for event in &all_events {
for path in event.paths() {
if !debounce.should_process(path) {
continue;
}
let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if crate::fs_scanner::SKIP_NAMES.iter().any(|s| file_name == *s)
|| file_name.starts_with(".sync_")
|| crate::utils::is_conflict_file(&file_name) {
continue;
}
let relative = path.strip_prefix(local_root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let relative = crate::utils::normalize_path(&relative);
if handled_old_rels.contains(&relative) || handled_new_rels.contains(&relative)
|| self.suppress_paths.contains_key(&relative) {
continue;
}
match event {
LocalFileEvent::Created(_) | LocalFileEvent::Modified(_) => {
delete_paths.remove(relative.as_str());
create_paths.insert(relative, path.clone());
}
LocalFileEvent::Deleted(_) => {
if crate::utils::is_conflict_file(&relative) {
continue;
}
create_paths.remove(relative.as_str());
delete_paths.insert(relative);
}
LocalFileEvent::Renamed { .. } | LocalFileEvent::Moved { .. } => {}
}
}
}
debounce.cleanup();
// === hash 匹配回退:检测 delete+create 为 rename 的情况 ===
// MirrorWcf: 跳过 hash 匹配回退,因为读取占位符文件会被 CFApi 拦截导致 426 超时
if !delete_paths.is_empty() && !create_paths.is_empty() && !matches!(sync_mode, SyncMode::MirrorWcf) {
let mut matched_deletes: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut matched_creates: std::collections::HashSet<String> = std::collections::HashSet::new();
for (new_rel, new_path) in &create_paths {
if let Ok(metadata) = tokio::fs::metadata(new_path).await {
if metadata.is_dir() || metadata.len() == 0 { continue; }
let new_hash = crate::utils::quick_hash(new_path, metadata.len()).await.unwrap_or_default();
if new_hash.is_empty() { continue; }
for del_rel in &delete_paths {
if matched_deletes.contains(del_rel.as_str()) { continue; }
if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, del_rel).await {
if mapping.local_hash.as_deref() == Some(&new_hash) {
let new_name = new_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let old_dir = std::path::PathBuf::from(del_rel).parent()
.map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default();
let new_dir = std::path::PathBuf::from(new_rel.as_str()).parent()
.map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default();
if old_dir == new_dir {
tracing::info!("hash匹配检测到重命名: {} -> {}", del_rel, new_rel);
rename_remote.push(RenameAction {
old_relative_path: del_rel.clone(),
new_relative_path: new_rel.clone(),
remote_uri: mapping.remote_uri.clone(),
new_name,
});
} else {
let remote_root = { self.config.read().await.remote_root.clone() };
let dst_remote_dir_uri = format!("{}/{}",
remote_root.trim_end_matches('/'),
new_dir.trim_start_matches('/'));
tracing::info!("hash匹配检测到移动: {} -> {}", del_rel, new_rel);
move_remote.push(MoveAction {
old_relative_path: del_rel.clone(),
new_relative_path: new_rel.clone(),
remote_uri: mapping.remote_uri.clone(),
dst_remote_dir_uri,
});
}
matched_deletes.insert(del_rel.clone());
matched_creates.insert(new_rel.clone());
break;
}
}
}
}
}
create_paths.retain(|rel, _| !matched_creates.contains(rel.as_str()));
delete_paths.retain(|rel| !matched_deletes.contains(rel.as_str()));
}
// === 提交重命名任务 ===
if !rename_remote.is_empty() {
let plan = SyncPlan {
rename_remote,
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
// === 提交移动任务 ===
if !move_remote.is_empty() {
let plan = SyncPlan {
move_remote,
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
// === 提交上传任务 (Create/Modify) ===
if !create_paths.is_empty() {
let mut uploads = Vec::new();
let mut dir_paths: Vec<String> = Vec::new();
let mut skipped_unstable = 0usize;
let mut skipped_uploading = 0usize;
for (relative, path) in &create_paths {
if let Ok(metadata) = tokio::fs::metadata(path).await {
if !metadata.is_dir() && metadata.len() == 0 {
continue;
}
if metadata.is_dir() {
dir_paths.push(relative.clone());
continue;
}
if self.worker_pool.is_uploading(relative) {
skipped_uploading += 1;
tracing::debug!("文件正在上传中,跳过: {}", relative);
continue;
}
if !is_file_stable(path).await {
skipped_unstable += 1;
tracing::debug!("文件尚未稳定(可能正在写入),跳过: {}", relative);
continue;
}
let size = metadata.len();
// MirrorWcf: 跳过 quick_hash,因为读取占位符文件会被 CFApi 拦截导致 426 超时
let quick_hash = if matches!(sync_mode, SyncMode::MirrorWcf) {
String::new()
} else {
crate::utils::quick_hash(path, size).await.unwrap_or_default()
};
let db_mapping = self.db.get_file_mapping(&root_id, relative).await.ok().flatten();
if let Some(ref mapping) = db_mapping {
if !quick_hash.is_empty() && mapping.local_hash.as_deref() == Some(&quick_hash) {
continue;
}
if mapping.is_placeholder {
continue;
}
}
let mtime_ms = metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let mime_type = crate::fs_scanner::guess_mime_type(path);
uploads.push(SyncAction {
relative_path: relative.clone(),
local_entry: Some(LocalFileEntry {
relative_path: std::path::PathBuf::from(relative),
size,
mtime_ms,
quick_hash,
is_dir: false,
mime_type,
}),
remote_entry: None,
db_mapping,
});
}
}
let scan_dirs = find_top_level_dirs(&dir_paths);
let all_handled: std::collections::HashSet<&String> = handled_old_rels.iter()
.chain(handled_new_rels.iter())
.collect();
let filtered_scan_dirs: Vec<String> = scan_dirs.into_iter().filter(|dir| {
if all_handled.iter().any(|rel| {
rel.starts_with(dir.as_str())
&& rel.as_bytes().get(dir.len()) == Some(&b'/')
}) {
return false;
}
for entry in self.suppress_paths.iter() {
let rel = entry.key();
if rel.starts_with(dir.as_str())
&& rel.as_bytes().get(dir.len()) == Some(&b'/') {
return false;
}
if dir.as_str() == rel.as_str() {
return false;
}
}
true
}).collect();
if !filtered_scan_dirs.is_empty() {
uploads.retain(|action| {
!filtered_scan_dirs.iter().any(|dir| {
action.relative_path.starts_with(dir)
&& action.relative_path.as_bytes().get(dir.len()) == Some(&b'/')
})
});
}
if !uploads.is_empty() || !filtered_scan_dirs.is_empty() {
tracing::info!(
"本地事件收集完成: 上传={}, 目录扫描={:?}, 跳过(未稳定)={}, 跳过(重复上传)={}",
uploads.len(), filtered_scan_dirs,
skipped_unstable, skipped_uploading,
);
let plan = SyncPlan {
uploads,
scan_dirs: filtered_scan_dirs,
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
}
// === 提交删除远程任务 (本地删除 → 删除远程) ===
// Full 模式:始终同步删除远程
// MirrorWcf 模式:仅在 wcf_delete_mode == SyncRemote 时删除远程,否则仅删除本地(保留远程以便重新水合)
let wcf_should_delete_remote = matches!(sync_mode, SyncMode::MirrorWcf)
&& matches!(wcf_delete_mode, WcfDeleteMode::SyncRemote);
if !delete_paths.is_empty() && (matches!(sync_mode, SyncMode::Full) || wcf_should_delete_remote) {
let mut delete_remote: Vec<SyncAction> = Vec::new();
for relative in &delete_paths {
tracing::info!("检测到本地文件删除: {}", relative);
if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, relative).await {
delete_remote.push(SyncAction {
relative_path: relative.clone(),
local_entry: None,
remote_entry: Some(RemoteFileEntry {
uri: mapping.remote_uri.clone(),
name: String::new(),
size: 0,
mtime_ms: 0,
hash: None,
is_dir: false,
file_id: mapping.remote_file_id.clone(),
path: String::new(),
created_at_ms: 0,
}),
db_mapping: Some(mapping),
});
let _ = self.db.delete_file_mapping(&root_id, relative).await;
}
}
if !delete_remote.is_empty() {
tracing::info!("本地删除事件收集完成: 删远程={}", delete_remote.len());
let plan = SyncPlan {
delete_remote,
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
}
// UploadOnly 模式下本地删除仅清理 DB mapping,不删除远程
if !delete_paths.is_empty() && matches!(sync_mode, SyncMode::UploadOnly) {
for relative in &delete_paths {
let _ = self.db.delete_file_mapping(&root_id, relative).await;
tracing::debug!("仅上传模式: 本地删除仅清理映射,不删除远程: {}", relative);
}
}
}
}
/// 从两个绝对路径生成相对于 local_root 的相对路径对
fn rel_pair(local_root: &std::path::Path, old_path: &std::path::Path, new_path: &std::path::Path) -> Option<(String, String)> {
let old_rel = old_path.strip_prefix(local_root).ok()?
.to_string_lossy().to_string();
let new_rel = new_path.strip_prefix(local_root).ok()?
.to_string_lossy().to_string();
Some((crate::utils::normalize_path(&old_rel), crate::utils::normalize_path(&new_rel)))
}
fn find_top_level_dirs(dirs: &[String]) -> Vec<String> {
if dirs.is_empty() { return Vec::new(); }
let mut sorted: Vec<&String> = dirs.iter().collect();
sorted.sort();
let mut top_level = Vec::new();
for dir in &sorted {
let dominated = top_level.iter().any(|parent: &String| {
dir.starts_with(parent.as_str())
&& dir.as_bytes().get(parent.len()) == Some(&b'/')
});
if !dominated {
top_level.retain(|existing: &String| {
!existing.starts_with(dir.as_str())
|| existing.as_bytes().get(dir.len()) != Some(&b'/')
});
top_level.push((*dir).clone());
}
}
top_level
}
/// 检测文件是否稳定(不在被写入中)
async fn is_file_stable(path: &Path) -> bool {
let size1 = match tokio::fs::metadata(path).await {
Ok(m) if !m.is_dir() => m.len(),
_ => return false,
};
let path_display = path.display().to_string();
let can_open = tokio::task::spawn_blocking(move || {
match std::fs::File::open(&path_display) {
Ok(_) => true,
Err(e) => {
tracing::trace!("文件稳定性检测打开失败: {}", e);
false
}
}
})
.await
.unwrap_or(false);
if !can_open {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let size2 = match tokio::fs::metadata(path).await {
Ok(m) if !m.is_dir() => m.len(),
_ => return false,
};
size1 == size2 && size1 > 0
}
+382
View File
@@ -0,0 +1,382 @@
mod initial_sync;
mod continuous_sync;
mod local_events;
mod remote_events;
mod album;
#[cfg(feature = "windows-cfapi")]
mod wcf;
// 非 WCF feature 下的 stub 方法,供 remote_events.rs 编译通过
#[cfg(not(feature = "windows-cfapi"))]
impl SyncEngine {
async fn _create_placeholder_for_remote(
&self,
_relative: &str,
_remote: &RemoteFileEntry,
_local_root: &std::path::Path,
_root_id: &str,
) {
// MirrorWcf 模式在非 Windows 平台不可用,此方法不应被调用
}
}
use crate::api_client::ApiClient;
use crate::conflict_resolver::ConflictResolver;
use crate::errors::Result;
use crate::event_sink::EventSink;
use crate::file_lock::FileLockRegistry;
use crate::models::*;
use crate::sync_db::SyncDb;
use crate::worker::WorkerPool;
use dashmap::DashMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[cfg(feature = "windows-cfapi")]
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub struct SyncEngine {
state: RwLock<SyncState>,
db: Arc<SyncDb>,
api: Arc<ApiClient>,
config: RwLock<SyncConfig>,
conflict: RwLock<ConflictResolver>,
sync_root_id: Option<String>,
shutdown_token: std::sync::Mutex<CancellationToken>,
/// 同步操作互斥锁:防止 force_sync / run_initial_sync 并发
sync_lock: tokio::sync::Mutex<()>,
worker_pool: WorkerPool,
#[allow(dead_code)]
file_locks: Arc<FileLockRegistry>,
#[allow(dead_code)]
ensured_dirs: Arc<DashMap<String, ()>>,
event_sink: Arc<EventSink>,
/// 远程操作导致的本地路径变化,抑制本地 debouncer 自触发事件
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
/// WCF 平台适配器(仅 MirrorWcf 模式下初始化)
#[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex<Option<Arc<crate::platform::wcf::WcfPlatformAdapter>>>,
/// WCF FETCH_DATA 回调接收端(在适配器初始化时提取)
#[cfg(feature = "windows-cfapi")]
wcf_fetch_rx: std::sync::Mutex<Option<mpsc::Receiver<sync_windows::FetchDataRequest>>>,
/// WCF 水合缓存:uri → 已下载的完整文件数据,避免同一文件重复下载
#[cfg(feature = "windows-cfapi")]
hydration_cache: Arc<DashMap<String, (Vec<u8>, std::time::Instant)>>,
/// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await)
#[cfg(feature = "windows-cfapi")]
cached_local_root: std::sync::Mutex<std::path::PathBuf>,
}
impl SyncEngine {
pub async fn new(config: SyncConfig) -> Result<Self> {
let db_path = config.data_dir.join("sync_core").join("datas").join(".sync_db.sqlite3");
let db_path_clone = db_path.clone();
let db = Arc::new(tokio::task::spawn_blocking(move || SyncDb::open(&db_path_clone)).await??);
let api = Arc::new(ApiClient::new(&config.base_url, &config.access_token, &config.refresh_token, &config.client_id));
let conflict = ConflictResolver::new(config.conflict_strategy.clone());
let sync_root_id = match db.upsert_sync_root(&config).await {
Ok(id) => Some(id),
Err(e) => {
tracing::warn!("写入 sync_root 失败: {}", e);
None
}
};
let shutdown_token = CancellationToken::new();
let file_locks = Arc::new(FileLockRegistry::new());
let ensured_dirs = Arc::new(DashMap::new());
let event_sink = Arc::new(EventSink::new());
let suppress_paths = Arc::new(DashMap::new());
let max_workers = config.max_workers;
let client_id = config.client_id.clone();
let worker_pool = WorkerPool::new(
db.clone(),
api.clone(),
file_locks.clone(),
ensured_dirs.clone(),
event_sink.clone(),
shutdown_token.clone(),
max_workers,
&client_id,
);
Ok(Self {
state: RwLock::new(SyncState::Idle),
db,
api,
config: RwLock::new(config),
conflict: RwLock::new(conflict),
sync_root_id,
shutdown_token: std::sync::Mutex::new(shutdown_token),
sync_lock: tokio::sync::Mutex::new(()),
worker_pool,
file_locks,
ensured_dirs,
event_sink,
suppress_paths,
#[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex::new(None),
#[cfg(feature = "windows-cfapi")]
wcf_fetch_rx: std::sync::Mutex::new(None),
#[cfg(feature = "windows-cfapi")]
hydration_cache: Arc::new(DashMap::new()),
#[cfg(feature = "windows-cfapi")]
cached_local_root: std::sync::Mutex::new(std::path::PathBuf::new()),
})
}
async fn snapshot_worker_config(&self) -> WorkerConfig {
let config = self.config.read().await;
WorkerConfig {
local_root: config.local_root.clone(),
remote_root: config.remote_root.clone(),
max_concurrent_transfers: config.max_concurrent_transfers,
bandwidth_limit: config.bandwidth_limit,
conflict_strategy: config.conflict_strategy.clone(),
wcf_delete_mode: config.wcf_delete_mode.clone(),
sync_root_id: self.sync_root_id.clone().unwrap_or_default(),
sync_mode: config.sync_mode.clone(),
}
}
/// 确保 shutdown token 未被取消(stop 后重新启动时使用)
pub fn ensure_token_fresh(&self) {
let token = self.shutdown_token.lock().unwrap().clone();
if token.is_cancelled() {
let new_token = tokio_util::sync::CancellationToken::new();
self.worker_pool.update_shutdown_token(new_token.clone());
*self.shutdown_token.lock().unwrap() = new_token;
}
}
pub async fn stop(&self) -> Result<()> {
self.shutdown_token.lock().unwrap().cancel();
*self.state.write().await = SyncState::Stopped;
Ok(())
}
pub async fn pause(&self) -> Result<()> {
*self.state.write().await = SyncState::Paused;
Ok(())
}
pub async fn resume(&self) -> Result<()> {
*self.state.write().await = SyncState::Continuous;
Ok(())
}
pub async fn force_sync(&self) -> Result<SyncSummary> {
// 取消当前所有操作(持续同步 + 正在运行的初始同步)
self.shutdown_token.lock().unwrap().cancel();
// 创建新 token,供接下来的 run_initial_sync 使用
let new_token = tokio_util::sync::CancellationToken::new();
*self.shutdown_token.lock().unwrap() = new_token.clone();
self.worker_pool.update_shutdown_token(new_token);
// run_initial_sync 会等待 sync_lock(旧同步的 worker 检测到取消后快速退出,释放锁)
self.run_initial_sync().await
}
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
pub async fn reset_sync(&self) -> Result<()> {
tracing::info!("开始重置同步...");
// 1. 停止同步
self.stop().await?;
// 2. 清理 WCF(重置时需要彻底清理)
#[cfg(feature = "windows-cfapi")]
{
self.cleanup_wcf();
}
// 3. 终止所有活跃 Worker
// 2. 终止所有活跃 Worker
self.worker_pool.abort_all_workers().await;
// 3. 清空 DB 业务数据
self.db.reset_sync_data().await?;
tracing::info!("同步数据库已清空");
// 4. 清空本地同步目录(保留目录本身,只删内容)
let local_root = self.config.read().await.local_root.clone();
if local_root.exists() {
let entries = std::fs::read_dir(&local_root)
.map_err(|_| crate::errors::SyncError::DiskFull { needed: 0, available: 0 })?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let _ = std::fs::remove_dir_all(&path);
} else {
let _ = std::fs::remove_file(&path);
}
}
tracing::info!("本地同步目录已清空: {}", local_root.display());
}
// 5. 清空内存缓存
self.ensured_dirs.clear();
self.suppress_paths.clear();
// 6. 重置状态
*self.state.write().await = SyncState::Idle;
tracing::info!("同步重置完成,已回到初始状态");
Ok(())
}
pub fn status(&self) -> SyncStatusSnapshot {
let state = self.state.try_read().map(|g| g.clone()).unwrap_or(SyncState::Idle);
let (synced_files, total_files) = match &state {
SyncState::InitialSync { progress } => {
let done = progress.uploaded + progress.downloaded;
(done, progress.total_to_sync)
}
_ => (0, 0),
};
SyncStatusSnapshot {
state,
synced_files,
total_files,
uploading_count: 0,
downloading_count: 0,
conflict_count: 0,
error_count: 0,
last_sync_time: None,
error_message: None,
}
}
pub fn active_worker_count(&self) -> u32 {
self.worker_pool.active_worker_count() as u32
}
pub async fn config(&self) -> SyncConfig {
self.config.read().await.clone()
}
pub async fn update_config(&self, new_config: SyncConfig) -> Result<()> {
let old_access_token = {
let config = self.config.read().await;
config.access_token.clone()
};
*self.conflict.write().await = ConflictResolver::new(new_config.conflict_strategy.clone());
if new_config.access_token != old_access_token {
self.api.update_token(new_config.access_token.clone()).await;
}
let new_bandwidth = new_config.bandwidth_limit;
let new_conflict = format!("{:?}", new_config.conflict_strategy);
let new_wcf_delete = format!("{:?}", new_config.wcf_delete_mode);
let new_mode = format!("{:?}", new_config.sync_mode);
let new_max_concurrent = new_config.max_concurrent_transfers;
*self.config.write().await = new_config;
if new_bandwidth.is_some() {
tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效");
}
tracing::info!(
"同步配置已更新: 模式={}, 冲突策略={}, WCF删除={}, 并发={}, 带宽限制={:?}",
new_mode, new_conflict, new_wcf_delete, new_max_concurrent, new_bandwidth
);
Ok(())
}
pub async fn update_access_token(&self, token: String) {
self.api.update_token(token).await;
}
pub async fn register_event_sink(&self, sink: crate::frb_generated::StreamSink<crate::api::ffi_types::SyncEventFfi>) {
self.event_sink.register(sink).await;
}
pub async fn get_active_tasks(&self) -> Result<Vec<SyncTask>> {
self.db.get_active_sync_tasks().await
}
pub async fn get_recent_tasks(&self, limit: u32) -> Result<Vec<SyncTask>> {
self.db.get_recent_sync_tasks(limit).await
}
pub async fn get_task_detail(&self, task_id: &str) -> Result<Vec<SyncTaskItem>> {
self.db.get_sync_task_items(task_id).await
}
pub async fn query_task_items(&self, filter: &TaskItemFilter) -> Result<Vec<SyncTaskItem>> {
self.db.query_task_items(filter).await
}
pub async fn hydrate_file(&self, local_path: &str) -> Result<()> {
#[cfg(feature = "windows-cfapi")]
{
let path = std::path::PathBuf::from(local_path);
if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() {
adapter.hydrate_file(&path)?;
}
}
let _ = local_path;
Ok(())
}
pub async fn shutdown(self) -> Result<()> {
self.stop().await
}
async fn load_all_mappings(&self) -> Result<HashMap<String, FileMapping>> {
let root_id = match &self.sync_root_id {
Some(id) => id.clone(),
None => return Ok(HashMap::new()),
};
let pool = self.db.read_pool();
let result = tokio::task::spawn_blocking(move || -> Result<HashMap<String, FileMapping>> {
let conn = pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1"
)?;
let mappings: HashMap<String, FileMapping> = stmt.query_map(
rusqlite::params![root_id],
|row| {
let local_path: String = row.get(2)?;
Ok((
crate::utils::normalize_path(&local_path),
FileMapping {
id: row.get(0)?,
sync_root_id: row.get(1)?,
local_path: std::path::PathBuf::from(local_path),
remote_uri: row.get(3)?,
remote_file_id: row.get(4)?,
local_hash: row.get(5)?,
remote_hash: row.get(6)?,
local_mtime: row.get(7)?,
remote_mtime: row.get(8)?,
local_size: row.get(9)?,
remote_size: row.get(10)?,
sync_status: crate::diff::parse_sync_status_from_str(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0,
},
))
},
)?.filter_map(|r| r.ok()).collect();
Ok(mappings)
}).await??;
Ok(result)
}
}
@@ -0,0 +1,252 @@
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 now = std::time::Instant::now();
self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30);
let relative = crate::diff::remote_relative_path(
remote_root,
uri,
name,
false,
);
tracing::info!("[远程事件] 删除: {}", relative);
// 被抑制的路径:上传失败清理远端碎片等场景,不应删除本地文件
if self.suppress_paths.contains_key(&relative) {
tracing::info!("[远程事件] 删除已抑制,跳过本地删除: {}", relative);
self.suppress_paths.remove(&relative);
return;
}
let local_path = local_root.join(&relative);
if local_path.exists() {
if local_path.is_dir() {
let _ = tokio::fs::remove_dir_all(&local_path).await;
} else {
let _ = tokio::fs::remove_file(&local_path).await;
}
tracing::info!("[远程事件] 已删除本地文件: {}", relative);
}
let _ = self.db.delete_file_mapping(&root_id, &relative).await;
self.suppress_paths.insert(relative.clone(), std::time::Instant::now());
}
RemoteFileEvent::Renamed { old_uri, new_entry } => {
let old_relative = crate::diff::remote_relative_path(
remote_root,
old_uri,
&new_entry.name,
false,
);
let new_relative = crate::diff::remote_relative_path(
remote_root,
&new_entry.path,
&new_entry.name,
new_entry.is_dir,
);
tracing::info!("[远程事件] 重命名: {} -> {}", old_relative, new_relative);
let now = std::time::Instant::now();
self.suppress_paths.insert(old_relative.clone(), now);
self.suppress_paths.insert(new_relative.clone(), now);
let old_local_path = local_root.join(&old_relative);
if old_local_path.exists() {
let plan = SyncPlan {
rename_local: vec![LocalRenameAction {
old_relative_path: old_relative,
new_relative_path: new_relative,
new_remote_uri: new_entry.uri.clone(),
}],
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
} else if is_mirror_wcf {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
self._create_placeholder_for_remote(
&new_relative, &remote_entry, local_root, &root_id,
).await;
} else {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
let plan = SyncPlan {
downloads: vec![SyncAction {
relative_path: new_relative,
local_entry: None,
remote_entry: Some(remote_entry),
db_mapping: None,
}],
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
}
RemoteFileEvent::Moved { old_uri, new_entry } => {
let old_relative = crate::diff::remote_relative_path(
remote_root,
old_uri,
&new_entry.name,
false,
);
let new_relative = crate::diff::remote_relative_path(
remote_root,
&new_entry.path,
&new_entry.name,
new_entry.is_dir,
);
tracing::info!("[远程事件] 移动: {} -> {}", old_relative, new_relative);
let now = std::time::Instant::now();
self.suppress_paths.insert(old_relative.clone(), now);
self.suppress_paths.insert(new_relative.clone(), now);
let old_local_path = local_root.join(&old_relative);
if old_local_path.exists() {
let plan = SyncPlan {
move_local: vec![LocalRenameAction {
old_relative_path: old_relative,
new_relative_path: new_relative,
new_remote_uri: new_entry.uri.clone(),
}],
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
} else if is_mirror_wcf {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
self._create_placeholder_for_remote(
&new_relative, &remote_entry, local_root, &root_id,
).await;
} else {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
let plan = SyncPlan {
downloads: vec![SyncAction {
relative_path: new_relative,
local_entry: None,
remote_entry: Some(remote_entry),
db_mapping: None,
}],
..Default::default()
};
let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background(
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver,
).await;
}
}
}
}
/// 获取远程文件详情,失败则使用 SSE 数据回退
pub(crate) async fn get_remote_entry_or_fallback(&self, entry: &RemoteFileEntry) -> RemoteFileEntry {
if entry.size == 0 && !entry.is_dir {
match self.api.get_file_info(&entry.uri).await {
Ok(info) => info,
Err(_) => entry.clone(),
}
} else {
entry.clone()
}
}
}
fn event_type_name(event: &RemoteFileEvent) -> &'static str {
match event {
RemoteFileEvent::Created(_) => "create",
RemoteFileEvent::Modified(_) => "modify",
RemoteFileEvent::Deleted { .. } => "delete",
RemoteFileEvent::Renamed { .. } => "rename",
RemoteFileEvent::Moved { .. } => "move",
}
}
+272
View File
@@ -0,0 +1,272 @@
//! WCF (Windows Cloud Filter API) 相关的 SyncEngine 方法
//! 仅在 windows-cfapi feature 启用时编译
use crate::models::*;
use super::SyncEngine;
#[cfg(feature = "windows-cfapi")]
impl SyncEngine {
/// MirrorWcf: 处理 CFApi FETCH_DATA 回调(按需水合)
pub(crate) async fn handle_wcf_fetch(
&self,
request: sync_windows::FetchDataRequest,
_local_root: &std::path::Path,
) {
let identity: serde_json::Value = match serde_json::from_slice(&request.file_identity) {
Ok(v) => v,
Err(e) => {
tracing::error!("WCF 水合: FileIdentity 反序列化失败: {}", e);
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key,
);
return;
}
};
let remote_uri = identity["uri"].as_str().unwrap_or("").to_string();
let remote_size = identity["size"].as_u64().unwrap_or(0);
let remote_hash = identity["hash"].as_str().unwrap_or("").to_string();
if remote_uri.is_empty() {
tracing::error!("WCF 水合: FileIdentity 中 uri 为空");
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key,
);
return;
}
tracing::debug!("WCF 水合请求: uri={}, size={}, offset={}, length={}",
remote_uri, remote_size, request.required_offset, request.required_length);
let root_id = match &self.sync_root_id {
Some(id) => id.clone(),
None => {
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key,
);
return;
}
};
// 清理过期缓存(超过 5 分钟)
let now = std::time::Instant::now();
self.hydration_cache.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 300);
// 尝试从缓存获取已下载的数据
let data = if let Some(cached) = self.hydration_cache.get(&remote_uri) {
tracing::debug!("WCF 水合缓存命中: {}", remote_uri);
cached.0.clone()
} else {
tracing::info!("WCF 水合下载: {} ({}bytes)", remote_uri, remote_size);
let config = self.snapshot_worker_config().await;
let download_result = async {
let urls = self.api.get_download_url(&[&remote_uri]).await;
let urls = match urls {
Ok(u) => u,
Err(crate::errors::SyncError::Auth(_)) => {
tracing::info!("WCF 水合: token 过期,尝试刷新后重试");
self.api.refresh_access_token().await?;
self.api.get_download_url(&[&remote_uri]).await?
}
Err(e) => return Err(e),
};
let download_url = urls.into_iter().next()
.ok_or_else(|| crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()))?;
let data = crate::downloader::download_to_buffer(
&self.api,
&download_url,
config.bandwidth_limit,
).await?;
Ok::<Vec<u8>, crate::errors::SyncError>(data)
}.await;
match download_result {
Ok(data) => {
self.hydration_cache.insert(remote_uri.clone(), (data.clone(), std::time::Instant::now()));
data
}
Err(e) => {
tracing::error!("WCF 水合下载失败: {}: {}", remote_uri, e);
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key,
);
return;
}
}
};
// 计算实际需要传输的范围
let offset = request.required_offset.max(0) as usize;
let end = if request.required_length < 0 {
data.len()
} else {
(offset + request.required_length as usize).min(data.len())
};
let transfer_data = if offset < data.len() && offset < end {
&data[offset..end]
} else {
&data[..]
};
match crate::platform::wcf::WcfPlatformAdapter::fulfill_fetch_data(
request.connection_key,
request.transfer_key,
transfer_data,
offset as i64,
) {
Ok(_) => {
tracing::debug!("WCF 水合数据推送: {} offset={} len={}", remote_uri, offset, transfer_data.len());
if let Ok(Some(mapping)) = self.db.find_mapping_by_remote_uri(&root_id, &remote_uri).await {
self.suppress_paths.insert(mapping.local_path.to_string_lossy().into_owned(), std::time::Instant::now());
let _ = self.db.upsert_file_mapping(&FileMapping {
id: mapping.id,
sync_root_id: mapping.sync_root_id,
local_path: mapping.local_path.clone(),
remote_uri: mapping.remote_uri.clone(),
remote_file_id: mapping.remote_file_id.clone(),
local_hash: None,
remote_hash: if remote_hash.is_empty() { mapping.remote_hash.clone() } else { Some(remote_hash.clone()) },
local_mtime: mapping.local_mtime,
remote_mtime: mapping.remote_mtime,
local_size: mapping.local_size,
remote_size: Some(remote_size),
sync_status: SyncFileStatus::Synced,
is_placeholder: false,
}).await;
}
}
Err(e) => {
tracing::error!("WCF CfExecute 传输数据失败: {}: {}", remote_uri, e);
}
}
}
/// MirrorWcf: 为远程文件创建占位符(持续同步时远程新建/修改文件调用)
pub(crate) async fn _create_placeholder_for_remote(
&self,
relative: &str,
remote: &RemoteFileEntry,
local_root: &std::path::Path,
root_id: &str,
) {
let local_path = local_root.join(relative);
if let Some(parent) = local_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if remote.is_dir {
let _ = tokio::fs::create_dir_all(&local_path).await;
} else {
#[cfg(feature = "windows-cfapi")]
{
if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() {
match adapter.create_placeholder_for_remote(
local_path.parent().unwrap_or(local_root),
local_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str(),
remote.size,
&remote.uri,
remote.hash.as_deref(),
remote.mtime_ms,
) {
Ok(_) => tracing::info!("[WCF] 创建占位符: {}", relative),
Err(e) => tracing::warn!("[WCF] 创建占位符失败 {}: {}", relative, e),
}
}
}
let _ = self.db.upsert_file_mapping(&FileMapping {
id: 0,
sync_root_id: root_id.to_string(),
local_path: std::path::PathBuf::from(relative),
remote_uri: remote.uri.clone(),
remote_file_id: remote.file_id.clone(),
local_hash: None,
remote_hash: remote.hash.clone(),
local_mtime: None,
remote_mtime: Some(remote.mtime_ms),
local_size: None,
remote_size: Some(remote.size),
sync_status: SyncFileStatus::Placeholder,
is_placeholder: true,
}).await;
}
}
/// WCF 清理(同步,可安全在 exit 前调用)
pub(crate) fn cleanup_wcf(&self) {
let adapter_opt = self.platform_adapter.lock().unwrap().take();
if let Some(adapter) = adapter_opt {
if let Err(e) = adapter.disconnect() {
tracing::warn!("WCF 断开连接失败: {}", e);
}
let local_root = self.cached_local_root.lock().unwrap().clone();
if !local_root.as_os_str().is_empty() {
unsafe {
use std::os::windows::ffi::OsStrExt;
let path_w: Vec<u16> = std::ffi::OsStr::new(&local_root)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let _ = windows::Win32::Storage::CloudFilters::CfUnregisterSyncRoot(
windows::core::PCWSTR(path_w.as_ptr()),
);
}
tracing::info!("WCF sync root 已注销: {}", local_root.display());
}
let root_id = self.sync_root_id.clone().unwrap_or_default();
if let Ok(mappings) = self.list_placeholders_sync(&root_id) {
for mapping in &mappings {
let local_path = local_root.join(&mapping.local_path);
if local_path.exists() {
let _ = std::fs::remove_file(&local_path);
}
}
if !mappings.is_empty() {
tracing::info!("已清理 {} 个占位符文件", mappings.len());
}
}
}
}
/// 同步查询占位符映射(避免 await,可在 exit 前安全调用)
fn list_placeholders_sync(&self, sync_root_id: &str) -> anyhow::Result<Vec<FileMapping>> {
let pool = self.db.read_pool();
let conn = pool.get().map_err(|e| anyhow::anyhow!("{}", e))?;
let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1",
).map_err(|e| anyhow::anyhow!("{}", e))?;
let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| {
Ok(FileMapping {
id: row.get(0)?,
sync_root_id: row.get(1)?,
local_path: std::path::PathBuf::from(row.get::<_, String>(2)?),
remote_uri: row.get(3)?,
remote_file_id: row.get(4)?,
local_hash: row.get(5)?,
remote_hash: row.get(6)?,
local_mtime: row.get(7)?,
remote_mtime: row.get(8)?,
local_size: row.get(9)?,
remote_size: row.get(10)?,
sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0,
})
}).map_err(|e| anyhow::anyhow!("{}", e))?
.filter_map(|m| m.ok()).collect();
Ok(mappings)
}
}
+2
View File
@@ -0,0 +1,2 @@
// 保留 TransferConfig / TransferTask / TransferDirection / TransferStatus 模型
// TransferManager 的 download/upload/retry_with_backoff 已迁移到 uploader.rs / downloader.rs / worker.rs
+326
View File
@@ -0,0 +1,326 @@
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, index, chunk, local.size, task_id).await {
Ok(_) => break,
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
// 业务错误,重试无意义,直接失败
Err(e @ SyncError::StoragePolicyDenied(_))
| Err(e @ SyncError::UploadFailed(_))
| Err(e @ SyncError::FileNotFound(_))
| Err(e @ SyncError::PermissionDenied(_))
| Err(e @ SyncError::ObjectExisted) => return Err(e),
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;
}
// 远程存储策略:上传完成后回调 Cloudreve 服务端
if session.is_remote_storage() {
if let Err(e) = api.callback_upload_complete(&session, task_id).await {
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
}
}
// 上传完成后获取远程文件信息
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 @ SyncError::StoragePolicyDenied(_))
| Err(e @ SyncError::UploadFailed(_))
| Err(e @ SyncError::FileNotFound(_))
| Err(e @ SyncError::PermissionDenied(_)) => return Err(e),
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,
task_id: &str,
) -> Result<()> {
let chunk_size = session.chunk_size as usize;
let file = tokio::fs::File::open(local_path).await?;
let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
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, index, &buf[..filled], file_size, task_id).await?;
index += 1;
}
// 远程存储策略:上传完成后回调 Cloudreve 服务端
if session.is_remote_storage() {
if let Err(e) = api.callback_upload_complete(session, task_id).await {
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
}
}
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More