From d20f7790e31f7bf2c6cc359d9819ad09e9874045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E4=BA=91=E5=AD=98=E5=82=A8?= <1+gongyun@git.saont.net> Date: Tue, 26 May 2026 16:49:14 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev分支README文件修改 --- README.md | 110 +++--------------------------------------------------- 1 file changed, 5 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 62f1ac8..edc0e7c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# app +# app-dev [![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) @@ -6,6 +6,10 @@ > 🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验 +::: danger 警告 +本分支含有未验证功能!非开发环境请勿使用! +::: + --- ## 📖 项目简介 @@ -49,103 +53,6 @@ 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支持拖拽上传 | - ---- - ## 🚀 快速开始 ### 环境要求 @@ -179,13 +86,6 @@ flutter build -d windows --release --- -## 📬 联系方式 - -- 📧 问题反馈:提交 Issue -- 💬 讨论交流:无 - ---- - ## ⚖️ 开源协议 (License) 本项目采用 **AGPL-3.0 (GNU Affero General Public License v3.0)** 协议开源。 From 886046a72b8ada87256751553bc82ff9e1f7b977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E4=BA=91=E5=AD=98=E5=82=A8?= <1+gongyun@git.saont.net> Date: Tue, 26 May 2026 17:02:18 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index edc0e7c..62f1ac8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# app-dev +# app [![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) @@ -6,10 +6,6 @@ > 🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验 -::: danger 警告 -本分支含有未验证功能!非开发环境请勿使用! -::: - --- ## 📖 项目简介 @@ -53,6 +49,103 @@ 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支持拖拽上传 | + +--- + ## 🚀 快速开始 ### 环境要求 @@ -86,6 +179,13 @@ flutter build -d windows --release --- +## 📬 联系方式 + +- 📧 问题反馈:提交 Issue +- 💬 讨论交流:无 + +--- + ## ⚖️ 开源协议 (License) 本项目采用 **AGPL-3.0 (GNU Affero General Public License v3.0)** 协议开源。 From 98af110531876fefad6b7b19eb8804e51ff5160a Mon Sep 17 00:00:00 2001 From: gongyun Date: Wed, 27 May 2026 19:19:11 +0800 Subject: [PATCH 3/6] merge new features --- .gitignore | 2 + android/app/build.gradle.kts | 12 +- lib/core/constants/storage_keys.dart | 4 + lib/core/utils/file_utils.dart | 63 +- lib/core/utils/win_env.dart | 26 + .../pages/auth/captcha_challenge_page.dart | 675 ++++--- .../pages/files/category_files_page.dart | 606 ++++++- lib/presentation/pages/files/files_page.dart | 352 ++-- .../preview/cloudreve_file_app_page.dart | 265 +++ .../widgets/quick_functions_section.dart | 59 +- .../pages/settings/app_settings_page.dart | 4 +- .../pages/share/share_link_page.dart | 1591 +++++++++++++++++ lib/presentation/pages/shell/app_shell.dart | 99 +- .../providers/file_manager_provider.dart | 2 +- .../widgets/announcement_dialog.dart | 177 ++ lib/presentation/widgets/file_grid_item.dart | 99 +- .../widgets/file_info_dialog.dart | 299 ++-- lib/presentation/widgets/file_list_item.dart | 29 +- .../widgets/file_menu_helper.dart | 13 + .../widgets/file_operation_dialogs.dart | 1091 +++++++++-- lib/presentation/widgets/folder_picker.dart | 4 +- .../widgets/selection_toolbar.dart | 24 +- lib/router/app_router.dart | 34 + lib/services/announcement_service.dart | 108 ++ lib/services/api_service.dart | 36 +- lib/services/captcha_service.dart | 181 +- lib/services/dialog_queue_service.dart | 32 + lib/services/file_app_service.dart | 338 ++++ lib/services/share_link_service.dart | 906 ++++++++++ lib/services/share_service.dart | 161 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 74 +- pubspec.yaml | 2 + windows/CMakeLists.txt | 21 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 37 files changed, 6405 insertions(+), 993 deletions(-) create mode 100644 lib/core/utils/win_env.dart create mode 100644 lib/presentation/pages/preview/cloudreve_file_app_page.dart create mode 100644 lib/presentation/pages/share/share_link_page.dart create mode 100644 lib/presentation/widgets/announcement_dialog.dart create mode 100644 lib/services/announcement_service.dart create mode 100644 lib/services/dialog_queue_service.dart create mode 100644 lib/services/file_app_service.dart create mode 100644 lib/services/share_link_service.dart diff --git a/.gitignore b/.gitignore index a0462f3..d6ed355 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ migrate_working_dir/ .frontend/ # custom +.claude/ CLAUDE.md claude_project.md PROJECT_REQUIREMENTS.md @@ -67,6 +68,7 @@ native/logs sync_refactory.md tools/* *.diff +android/app/src/main/jniLibs/* # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c15f2d..73f5882 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,15 +54,25 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 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. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = getLocalProperty("flutter.minSdkVersion", 24) targetSdk = getLocalProperty("flutter.targetSdkVersion", 35) versionCode = flutter.versionCode versionName = flutter.versionName + + ndk { + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) + } } packaging { diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index 564f5f2..e76a5ed 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -35,4 +35,8 @@ class StorageKeys { // 日志级别 static const String logLevel = 'app_log_level'; + + // 公告 + static const String siteAnnouncementDismissedFingerprint = + 'site_announcement_dismissed_fingerprint'; } diff --git a/lib/core/utils/file_utils.dart b/lib/core/utils/file_utils.dart index ca09858..80a6fc2 100644 --- a/lib/core/utils/file_utils.dart +++ b/lib/core/utils/file_utils.dart @@ -55,12 +55,42 @@ class FileUtils { 'bmp', 'svg', 'heic', + 'heif', + 'avif', + 'tif', + 'tiff', ]; 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) { - const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv']; + const videoExtensions = [ + 'mp4', + 'webm', + 'mkv', + 'avi', + 'mov', + 'flv', + 'wmv', + 'm4v', + 'mpg', + 'mpeg', + '3gp', + 'ts', + 'm2ts', + ]; return videoExtensions.contains(getFileExtension(fileName)); } @@ -69,6 +99,17 @@ class FileUtils { return audioExtensions.contains(getFileExtension(fileName)); } + 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) { return getFileExtension(fileName) == 'pdf'; } @@ -155,13 +196,33 @@ class FileUtils { 'gif': 'image/gif', 'webp': 'image/webp', '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', 'webm': 'video/webm', 'mkv': 'video/x-matroska', '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', 'wav': 'audio/wav', 'ogg': 'audio/ogg', + 'flac': 'audio/flac', + 'aac': 'audio/aac', + 'm4a': 'audio/mp4', 'pdf': 'application/pdf', 'txt': 'text/plain', 'json': 'application/json', diff --git a/lib/core/utils/win_env.dart b/lib/core/utils/win_env.dart new file mode 100644 index 0000000..397050b --- /dev/null +++ b/lib/core/utils/win_env.dart @@ -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, Pointer), + int Function(Pointer, Pointer)>( + '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); + } +} diff --git a/lib/presentation/pages/auth/captcha_challenge_page.dart b/lib/presentation/pages/auth/captcha_challenge_page.dart index b0b0545..5f55c89 100644 --- a/lib/presentation/pages/auth/captcha_challenge_page.dart +++ b/lib/presentation/pages/auth/captcha_challenge_page.dart @@ -1,7 +1,59 @@ 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: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 { final String type; @@ -21,12 +73,20 @@ class CaptchaWebConfig { const CaptchaWebConfig.recaptchaV2({ required String siteKey, String displayName = 'reCAPTCHA V2', - }) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey); + }) : this._( + type: 'recaptcha', + displayName: displayName, + siteKey: siteKey, + ); const CaptchaWebConfig.turnstile({ required String siteKey, String displayName = 'Cloudflare Turnstile', - }) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey); + }) : this._( + type: 'turnstile', + displayName: displayName, + siteKey: siteKey, + ); const CaptchaWebConfig.cap({ required String instanceUrl, @@ -34,22 +94,36 @@ class CaptchaWebConfig { String? assetServer, String displayName = 'Cap', }) : this._( - type: 'cap', - displayName: displayName, - instanceUrl: instanceUrl, - siteKey: siteKey, - assetServer: assetServer, - ); + type: 'cap', + displayName: displayName, + instanceUrl: instanceUrl, + siteKey: siteKey, + 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 { final CaptchaWebConfig config; final String baseUrl; + final CaptchaProxyConfig? proxyConfig; const CaptchaChallengePage({ super.key, required this.config, required this.baseUrl, + this.proxyConfig, }); @override @@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget { } class _CaptchaChallengePageState extends State { - late final WebViewController _controller; + // ── 移动端 ── + mobile.WebViewController? _mobileController; + + // ── 桌面端 ── + InAppWebViewController? _desktopController; + Key _desktopKey = UniqueKey(); + + // ── 共享状态 ── bool _isLoading = true; int _progress = 0; String? _errorMessage; String? _statusText; + bool _disposed = false; + + // ── 是否设置了代理环境变量(用于清理时判断)── + bool _proxyEnvSet = false; + + // ── HTML ── + late String _currentHtml; @override void initState() { super.initState(); + _currentHtml = _buildHtml(widget.config); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(Colors.transparent) - ..addJavaScriptChannel( - '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(); - }); - } - }, - ), - ); + // Windows: 在 WebView2 创建前设置代理环境变量 + if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) { + _applyWebView2Proxy(widget.proxyConfig); + _proxyEnvSet = true; + } - _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 _loadCaptcha() async { + if (_disposed) return; setState(() { _isLoading = true; _errorMessage = null; _statusText = null; _progress = 0; + _currentHtml = _buildHtml(widget.config); }); - final html = _buildHtml(widget.config); - await _controller.loadHtmlString(html, baseUrl: widget.baseUrl); + if (!_isDesktop && _mobileController != null) { + await _mobileController!.loadHtmlString( + _currentHtml, + baseUrl: widget.baseUrl, + ); + } else if (_isDesktop) { + setState(() => _desktopKey = UniqueKey()); + } } + // ─── Bridge 消息处理 ───────────────────────────────── + void _handleBridgeMessage(String rawMessage) { + AppLogger.d( + 'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}', + ); try { final decoded = jsonDecode(rawMessage); if (decoded is! Map) return; @@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State { if (type == 'success') { 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); + AppLogger.d('pop 完成'); } return; } @@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State { final progress = decoded['progress']?.toString(); if (mounted) { setState(() { - _statusText = progress == null ? '正在验证...' : '正在验证... $progress'; + _statusText = + progress == null ? '正在验证...' : '正在验证... $progress'; }); } return; @@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State { return; } + if (type == 'debug') { + AppLogger.d('JS debug: ${decoded['message']}'); + return; + } + if (type == 'expired') { if (mounted) { setState(() { @@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State { } return; } - } catch (_) { - // 忽略非 JSON 消息。 + } catch (_) {} + } + + // ─── 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) { switch (config.type) { case 'turnstile': - return _buildTurnstileHtml(config.siteKey!); + return _baseHtml( + title: 'Cloudflare Turnstile', + body: '
', + 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)); + } + } + + + + + + + - - - - - -