mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 13:30:31 +08:00
Merge remote-tracking branch 'origin/dev' into refactor/single-instance
This commit is contained in:
commit
07c5372dbb
8
.github/workflows/alpha.yml
vendored
8
.github/workflows/alpha.yml
vendored
@ -185,18 +185,18 @@ jobs:
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: |
|
||||
if [ -f "UPDATELOG.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||
if [ -f "Changelog.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' Changelog.md)
|
||||
if [ -n "$UPDATE_LOGS" ]; then
|
||||
echo "Found update logs"
|
||||
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No update sections found in UPDATELOG.md"
|
||||
echo "No update sections found in Changelog.md"
|
||||
fi
|
||||
else
|
||||
echo "UPDATELOG.md file not found"
|
||||
echo "Changelog.md file not found"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
61
.github/workflows/autobuild.yml
vendored
61
.github/workflows/autobuild.yml
vendored
@ -35,20 +35,7 @@ jobs:
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: |
|
||||
if [ -f "UPDATELOG.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||
if [ -n "$UPDATE_LOGS" ]; then
|
||||
echo "Found update logs"
|
||||
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No update sections found in UPDATELOG.md"
|
||||
fi
|
||||
else
|
||||
echo "UPDATELOG.md file not found"
|
||||
fi
|
||||
run: bash ./scripts/extract_update_logs.sh
|
||||
shell: bash
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
@ -158,7 +145,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: "1.91.0"
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
@ -214,6 +204,13 @@ jobs:
|
||||
- name: Release ${{ env.TAG_CHANNEL }} Version
|
||||
run: pnpm release-version autobuild-latest
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build for Windows-macOS-Linux
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@ -257,7 +254,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: "1.91.0"
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
@ -347,6 +347,13 @@ jobs:
|
||||
gcc-arm-linux-gnueabihf \
|
||||
g++-arm-linux-gnueabihf
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri Build for Linux
|
||||
run: |
|
||||
export PKG_CONFIG_ALLOW_CROSS=1
|
||||
@ -446,6 +453,13 @@ jobs:
|
||||
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build for Windows
|
||||
id: build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
@ -509,20 +523,7 @@ jobs:
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: |
|
||||
if [ -f "UPDATELOG.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||
if [ -n "$UPDATE_LOGS" ]; then
|
||||
echo "Found update logs"
|
||||
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No update sections found in UPDATELOG.md"
|
||||
fi
|
||||
else
|
||||
echo "UPDATELOG.md file not found"
|
||||
fi
|
||||
run: bash ./scripts/extract_update_logs.sh
|
||||
shell: bash
|
||||
|
||||
- name: Install Node
|
||||
|
||||
14
.github/workflows/dev.yml
vendored
14
.github/workflows/dev.yml
vendored
@ -74,11 +74,7 @@ jobs:
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add Rust Target
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
uses: dtolnay/rust-toolchain@1.91.0
|
||||
|
||||
- name: Rust Cache
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
@ -118,6 +114,14 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: pnpm release-version ${{ env.TAG_NAME }}
|
||||
|
||||
- name: Add Rust Target
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
|
||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
@ -73,20 +73,7 @@ jobs:
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: |
|
||||
if [ -f "UPDATELOG.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||
if [ -n "$UPDATE_LOGS" ]; then
|
||||
echo "Found update logs"
|
||||
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No update sections found in UPDATELOG.md"
|
||||
fi
|
||||
else
|
||||
echo "UPDATELOG.md file not found"
|
||||
fi
|
||||
run: bash ./scripts/extract_update_logs.sh
|
||||
shell: bash
|
||||
|
||||
- name: Set Env
|
||||
@ -552,20 +539,7 @@ jobs:
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: |
|
||||
if [ -f "UPDATELOG.md" ]; then
|
||||
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||
if [ -n "$UPDATE_LOGS" ]; then
|
||||
echo "Found update logs"
|
||||
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No update sections found in UPDATELOG.md"
|
||||
fi
|
||||
else
|
||||
echo "UPDATELOG.md file not found"
|
||||
fi
|
||||
run: bash ./scripts/extract_update_logs.sh
|
||||
shell: bash
|
||||
|
||||
- name: Install Node
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# README.md
|
||||
# UPDATELOG.md
|
||||
# Changelog.md
|
||||
# CONTRIBUTING.md
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
248
Changelog.md
Normal file
248
Changelog.md
Normal file
@ -0,0 +1,248 @@
|
||||
## v2.4.4
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- Linux 无法切换 TUN 堆栈
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.16**
|
||||
- 支持连接页面各个项目的排序
|
||||
- 实现可选的自动备份
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 替换前端信息编辑组件,提供更好性能
|
||||
- 优化后端内存和性能表现
|
||||
- 防止退出时可能的禁用 TUN 失败
|
||||
- i18n 支持
|
||||
- 优化备份设置布局
|
||||
- 优化流量图性能表现,实现动态 FPS 和窗口失焦自动暂停
|
||||
|
||||
</details>
|
||||
|
||||
## v2.4.3
|
||||
|
||||
**发行代号:澜**
|
||||
代号释义:澜象征平稳与融合,本次版本聚焦稳定性、兼容性、性能与体验优化,全面提升整体可靠性。
|
||||
|
||||
特别感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 优化服务模式重装逻辑,避免不必要的重复检查
|
||||
- 修复轻量模式退出无响应的问题
|
||||
- 修复托盘轻量模式支持退出/进入
|
||||
- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程
|
||||
- macOS Tun/系统代理 模式下图标大小不统一
|
||||
- 托盘节点切换不再显示隐藏组
|
||||
- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商
|
||||
- 修复MacOS 下 Tun开启后 系统代理无法打开的问题
|
||||
- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题
|
||||
- 修复 Webdav 恢复备份不重启
|
||||
- 修复 Linux 开机后无法正常代理需要手动设置
|
||||
- 修复增加订阅或导入订阅文件时订阅页面无更新
|
||||
- 修复系统代理守卫功能不工作
|
||||
- 修复 KDE + Wayland 下多屏显示 UI 异常
|
||||
- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常
|
||||
- 修复静默启动不加载完整 WebView 的问题
|
||||
- 修复 Linux WebKit 网络进程的崩溃
|
||||
- 修复无法导入订阅
|
||||
- 修复实际导入成功但显示导入失败的问题
|
||||
- 修复服务不可用时,自动关闭 Tun 模式导致应用卡死问题
|
||||
- 修复删除订阅时未能实际删除相关文件
|
||||
- 修复 macOS 连接界面显示异常
|
||||
- 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题
|
||||
- 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题
|
||||
- 修复自动更新使版本回退的问题
|
||||
- 修复首页自定义卡片在切换轻量模式时失效
|
||||
- 修复悬浮跳转导航失效
|
||||
- 修复小键盘热键映射错误
|
||||
- 修复前端无法及时刷新操作状态
|
||||
- 修复 macOS 从 Dock 栏退出轻量模式状态不同步
|
||||
- 修复 Linux 系统主题切换不生效
|
||||
- 修复 `允许自动更新` 字段使手动订阅刷新失效
|
||||
- 修复轻量模式托盘状态不同步
|
||||
- 修复一键导入订阅导致应用卡死崩溃的问题
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.15**
|
||||
- 支持前端修改日志(最大文件大小、最大保留数量)
|
||||
- 新增链式代理图形化设置功能
|
||||
- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏)
|
||||
- 监听关机事件,自动关闭系统代理
|
||||
- 主界面“当前节点”卡片新增“延迟测试”按钮
|
||||
- 新增批量选择配置文件功能
|
||||
- Windows / Linux / MacOS 监听关机信号,优雅恢复网络设置
|
||||
- 新增本地备份功能
|
||||
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
|
||||
- 允许独立控制订阅自动更新
|
||||
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
|
||||
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
|
||||
- 托盘 `打开目录` 中新增 `应用日志` 和 `内核日志`
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 重构并简化服务模式启动检测流程,消除重复检测
|
||||
- 重构并简化窗口创建流程
|
||||
- 重构日志系统,单个日志默认最大 10 MB
|
||||
- 优化前端资源占用
|
||||
- 改进 macos 下系统代理设置的方法
|
||||
- 优化 TUN 模式可用性的判断
|
||||
- 移除流媒体检测的系统级提示(使用软件内通知)
|
||||
- 优化后端 i18n 资源占用
|
||||
- 改进 Linux 托盘支持并添加 `--no-tray` 选项
|
||||
- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式
|
||||
- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL
|
||||
- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端
|
||||
- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核
|
||||
- 修改内核默认日志级别为 Info
|
||||
- 支持通过桌面快捷方式重新打开应用
|
||||
- 支持订阅界面输入链接后回车导入
|
||||
- 选择按延迟排序时每次延迟测试自动刷新节点顺序
|
||||
- 配置重载失败时自动重启核心
|
||||
- 启用 TUN 前等待服务就绪
|
||||
- 卸载 TUN 时会先关闭
|
||||
- 优化应用启动页
|
||||
- 优化首页当前节点对MATCH规则的支持
|
||||
- 允许在 `界面设置` 修改 `悬浮跳转导航延迟`
|
||||
- 添加热键绑定错误的提示信息
|
||||
- 在 macOS 10.15 及更高版本默认包含 Mihomo-go122,以解决 Intel 架构 Mac 无法运行内核的问题
|
||||
- Tun 模式不可用时,禁用系统托盘的 Tun 模式菜单
|
||||
- 改进订阅更新方式,仍失败需打开订阅设置 `允许危险证书`
|
||||
- 允许设置 Mihomo 端口范围 1000(含) - 65536(含)
|
||||
|
||||
</details>
|
||||
|
||||
## v2.4.2
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- 增加托盘节点选择
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 优化前端首页加载速度
|
||||
- 优化前端未使用 i18n 文件缓存
|
||||
- 优化后端内存占用
|
||||
- 优化后端启动速度
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复首页节点切换失效的问题
|
||||
- 修复和优化服务检查流程
|
||||
- 修复2.4.1引入的订阅地址重定向报错问题
|
||||
- 修复 rpm/deb 包名称问题
|
||||
- 修复托盘轻量模式状态检测异常
|
||||
- 修复通过 scheme 导入订阅崩溃
|
||||
- 修复单例检测实效
|
||||
- 修复启动阶段可能导致的无法连接内核
|
||||
- 修复导入订阅无法 Auth Basic
|
||||
|
||||
### 👙 界面样式
|
||||
|
||||
- 简化和改进代理设置样式
|
||||
|
||||
## v2.4.1
|
||||
|
||||
### 🏆 重大改进
|
||||
|
||||
- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.13**
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 优化热键响应速度,提升快捷键操作体验
|
||||
- 改进服务管理响应性,减少系统服务操作等待时间
|
||||
- 提升文件和配置处理性能
|
||||
- 优化任务管理和日志记录效率
|
||||
- 优化异步内存管理,减少内存占用并提升多任务处理效率
|
||||
- 优化启动阶段初始化性能
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复应用在某些操作中可能出现的响应延迟问题
|
||||
- 修复任务管理中的潜在并发问题
|
||||
- 修复通过托盘重启应用无法恢复
|
||||
- 修复订阅在某些情况下无法导入
|
||||
- 修复无法新建订阅时使用远程链接
|
||||
- 修复卸载服务后的 tun 开关状态问题
|
||||
- 修复页面快速切换订阅时导致崩溃
|
||||
- 修复丢失工作目录时无法恢复环境
|
||||
- 修复从轻量模式恢复导致崩溃
|
||||
|
||||
### 👙 界面样式
|
||||
|
||||
- 统一代理设置样式
|
||||
|
||||
### 🗑️ 移除内容
|
||||
|
||||
- 移除启动阶段自动清理过期订阅
|
||||
|
||||
## v2.4.0
|
||||
|
||||
**发行代号:融**
|
||||
代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。
|
||||
|
||||
### 🏆 重大改进
|
||||
|
||||
- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性
|
||||
- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示
|
||||
- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.12**
|
||||
- 新增版本信息复制按钮
|
||||
- 增强型流量监控,支持更详细的数据分析
|
||||
- 新增流量图表多种显示模式
|
||||
- 新增强制刷新配置和节点缓存功能
|
||||
- 首页流量统计支持查看刻度线详情
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 全面提升数据传输和处理效率
|
||||
- 优化内存使用,减少系统资源消耗
|
||||
- 改进流量图表渲染性能
|
||||
- 优化配置和节点刷新策略,从5秒延长到60秒
|
||||
- 改进数据缓存机制,减少重复请求
|
||||
- 优化异步程序性能
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复系统代理状态检测和显示不一致问题
|
||||
- 修复系统主题窗口颜色不一致问题
|
||||
- 修复特殊字符 URL 处理问题
|
||||
- 修复配置修改后缓存不同步问题
|
||||
- 修复 Windows 安装器自启设置问题
|
||||
- 修复 macOS 下 Dock 图标恢复窗口问题
|
||||
- 修复 linux 下 KDE/Plasma 异常标题栏按钮
|
||||
- 修复架构升级后节点测速功能异常
|
||||
- 修复架构升级后流量统计功能异常
|
||||
- 修复架构升级后日志功能异常
|
||||
- 修复外部控制器跨域配置保存问题
|
||||
- 修复首页端口显示不一致问题
|
||||
- 修复首页流量统计刻度线显示问题
|
||||
- 修复日志页面按钮功能混淆问题
|
||||
- 修复日志等级设置保存问题
|
||||
- 修复日志等级异常过滤
|
||||
- 修复清理日志天数功能异常
|
||||
- 修复偶发性启动卡死问题
|
||||
- 修复首页虚拟网卡开关在管理模式下的状态问题
|
||||
|
||||
### 🔧 技术改进
|
||||
|
||||
- 统一使用新的内核通信方式
|
||||
- 新增外部控制器配置界面
|
||||
- 改进跨平台兼容性支持
|
||||
@ -1,240 +1,3 @@
|
||||
# v2.4.4
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 替换前端信息编辑组件,提供更好性能
|
||||
|
||||
</details>
|
||||
|
||||
## v2.4.3
|
||||
|
||||
**发行代号:澜**
|
||||
代号释义:澜象征平稳与融合,本次版本聚焦稳定性、兼容性、性能与体验优化,全面提升整体可靠性。
|
||||
|
||||
特别感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 优化服务模式重装逻辑,避免不必要的重复检查
|
||||
- 修复轻量模式退出无响应的问题
|
||||
- 修复托盘轻量模式支持退出/进入
|
||||
- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程
|
||||
- macOS Tun/系统代理 模式下图标大小不统一
|
||||
- 托盘节点切换不再显示隐藏组
|
||||
- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商
|
||||
- 修复MacOS 下 Tun开启后 系统代理无法打开的问题
|
||||
- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题
|
||||
- 修复 Webdav 恢复备份不重启
|
||||
- 修复 Linux 开机后无法正常代理需要手动设置
|
||||
- 修复增加订阅或导入订阅文件时订阅页面无更新
|
||||
- 修复系统代理守卫功能不工作
|
||||
- 修复 KDE + Wayland 下多屏显示 UI 异常
|
||||
- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常
|
||||
- 修复静默启动不加载完整 WebView 的问题
|
||||
- 修复 Linux WebKit 网络进程的崩溃
|
||||
- 修复无法导入订阅
|
||||
- 修复实际导入成功但显示导入失败的问题
|
||||
- 修复服务不可用时,自动关闭 Tun 模式导致应用卡死问题
|
||||
- 修复删除订阅时未能实际删除相关文件
|
||||
- 修复 macOS 连接界面显示异常
|
||||
- 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题
|
||||
- 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题
|
||||
- 修复自动更新使版本回退的问题
|
||||
- 修复首页自定义卡片在切换轻量模式时失效
|
||||
- 修复悬浮跳转导航失效
|
||||
- 修复小键盘热键映射错误
|
||||
- 修复前端无法及时刷新操作状态
|
||||
- 修复 macOS 从 Dock 栏退出轻量模式状态不同步
|
||||
- 修复 Linux 系统主题切换不生效
|
||||
- 修复 `允许自动更新` 字段使手动订阅刷新失效
|
||||
- 修复轻量模式托盘状态不同步
|
||||
- 修复一键导入订阅导致应用卡死崩溃的问题
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.15**
|
||||
- 支持前端修改日志(最大文件大小、最大保留数量)
|
||||
- 新增链式代理图形化设置功能
|
||||
- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏)
|
||||
- 监听关机事件,自动关闭系统代理
|
||||
- 主界面“当前节点”卡片新增“延迟测试”按钮
|
||||
- 新增批量选择配置文件功能
|
||||
- Windows / Linux / MacOS 监听关机信号,优雅恢复网络设置
|
||||
- 新增本地备份功能
|
||||
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
|
||||
- 允许独立控制订阅自动更新
|
||||
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
|
||||
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
|
||||
- 托盘 `打开目录` 中新增 `应用日志` 和 `内核日志`
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 重构并简化服务模式启动检测流程,消除重复检测
|
||||
- 重构并简化窗口创建流程
|
||||
- 重构日志系统,单个日志默认最大 10 MB
|
||||
- 优化前端资源占用
|
||||
- 改进 macos 下系统代理设置的方法
|
||||
- 优化 TUN 模式可用性的判断
|
||||
- 移除流媒体检测的系统级提示(使用软件内通知)
|
||||
- 优化后端 i18n 资源占用
|
||||
- 改进 Linux 托盘支持并添加 `--no-tray` 选项
|
||||
- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式
|
||||
- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL
|
||||
- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端
|
||||
- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核
|
||||
- 修改内核默认日志级别为 Info
|
||||
- 支持通过桌面快捷方式重新打开应用
|
||||
- 支持订阅界面输入链接后回车导入
|
||||
- 选择按延迟排序时每次延迟测试自动刷新节点顺序
|
||||
- 配置重载失败时自动重启核心
|
||||
- 启用 TUN 前等待服务就绪
|
||||
- 卸载 TUN 时会先关闭
|
||||
- 优化应用启动页
|
||||
- 优化首页当前节点对MATCH规则的支持
|
||||
- 允许在 `界面设置` 修改 `悬浮跳转导航延迟`
|
||||
- 添加热键绑定错误的提示信息
|
||||
- 在 macOS 10.15 及更高版本默认包含 Mihomo-go122,以解决 Intel 架构 Mac 无法运行内核的问题
|
||||
- Tun 模式不可用时,禁用系统托盘的 Tun 模式菜单
|
||||
- 改进订阅更新方式,仍失败需打开订阅设置 `允许危险证书`
|
||||
- 允许设置 Mihomo 端口范围 1000(含) - 65536(含)
|
||||
|
||||
</details>
|
||||
|
||||
## v2.4.2
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- 增加托盘节点选择
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 优化前端首页加载速度
|
||||
- 优化前端未使用 i18n 文件缓存
|
||||
- 优化后端内存占用
|
||||
- 优化后端启动速度
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复首页节点切换失效的问题
|
||||
- 修复和优化服务检查流程
|
||||
- 修复2.4.1引入的订阅地址重定向报错问题
|
||||
- 修复 rpm/deb 包名称问题
|
||||
- 修复托盘轻量模式状态检测异常
|
||||
- 修复通过 scheme 导入订阅崩溃
|
||||
- 修复单例检测实效
|
||||
- 修复启动阶段可能导致的无法连接内核
|
||||
- 修复导入订阅无法 Auth Basic
|
||||
|
||||
### 👙 界面样式
|
||||
|
||||
- 简化和改进代理设置样式
|
||||
|
||||
## v2.4.1
|
||||
|
||||
### 🏆 重大改进
|
||||
|
||||
- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.13**
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 优化热键响应速度,提升快捷键操作体验
|
||||
- 改进服务管理响应性,减少系统服务操作等待时间
|
||||
- 提升文件和配置处理性能
|
||||
- 优化任务管理和日志记录效率
|
||||
- 优化异步内存管理,减少内存占用并提升多任务处理效率
|
||||
- 优化启动阶段初始化性能
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复应用在某些操作中可能出现的响应延迟问题
|
||||
- 修复任务管理中的潜在并发问题
|
||||
- 修复通过托盘重启应用无法恢复
|
||||
- 修复订阅在某些情况下无法导入
|
||||
- 修复无法新建订阅时使用远程链接
|
||||
- 修复卸载服务后的 tun 开关状态问题
|
||||
- 修复页面快速切换订阅时导致崩溃
|
||||
- 修复丢失工作目录时无法恢复环境
|
||||
- 修复从轻量模式恢复导致崩溃
|
||||
|
||||
### 👙 界面样式
|
||||
|
||||
- 统一代理设置样式
|
||||
|
||||
### 🗑️ 移除内容
|
||||
|
||||
- 移除启动阶段自动清理过期订阅
|
||||
|
||||
## v2.4.0
|
||||
|
||||
**发行代号:融**
|
||||
代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。
|
||||
|
||||
### 🏆 重大改进
|
||||
|
||||
- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性
|
||||
- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示
|
||||
- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.12**
|
||||
- 新增版本信息复制按钮
|
||||
- 增强型流量监控,支持更详细的数据分析
|
||||
- 新增流量图表多种显示模式
|
||||
- 新增强制刷新配置和节点缓存功能
|
||||
- 首页流量统计支持查看刻度线详情
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
- 全面提升数据传输和处理效率
|
||||
- 优化内存使用,减少系统资源消耗
|
||||
- 改进流量图表渲染性能
|
||||
- 优化配置和节点刷新策略,从5秒延长到60秒
|
||||
- 改进数据缓存机制,减少重复请求
|
||||
- 优化异步程序性能
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复系统代理状态检测和显示不一致问题
|
||||
- 修复系统主题窗口颜色不一致问题
|
||||
- 修复特殊字符 URL 处理问题
|
||||
- 修复配置修改后缓存不同步问题
|
||||
- 修复 Windows 安装器自启设置问题
|
||||
- 修复 macOS 下 Dock 图标恢复窗口问题
|
||||
- 修复 linux 下 KDE/Plasma 异常标题栏按钮
|
||||
- 修复架构升级后节点测速功能异常
|
||||
- 修复架构升级后流量统计功能异常
|
||||
- 修复架构升级后日志功能异常
|
||||
- 修复外部控制器跨域配置保存问题
|
||||
- 修复首页端口显示不一致问题
|
||||
- 修复首页流量统计刻度线显示问题
|
||||
- 修复日志页面按钮功能混淆问题
|
||||
- 修复日志等级设置保存问题
|
||||
- 修复日志等级异常过滤
|
||||
- 修复清理日志天数功能异常
|
||||
- 修复偶发性启动卡死问题
|
||||
- 修复首页虚拟网卡开关在管理模式下的状态问题
|
||||
|
||||
### 🔧 技术改进
|
||||
|
||||
- 统一使用新的内核通信方式
|
||||
- 新增外部控制器配置界面
|
||||
- 改进跨平台兼容性支持
|
||||
|
||||
## v2.3.2
|
||||
|
||||
### 🐞 修复问题
|
||||
20
package.json
20
package.json
@ -60,7 +60,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "1.11.19",
|
||||
"foxact": "^0.2.49",
|
||||
"i18next": "^25.6.1",
|
||||
"i18next": "^25.6.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
@ -71,7 +71,7 @@
|
||||
"react-dom": "19.2.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-i18next": "16.2.4",
|
||||
"react-i18next": "16.3.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^7.9.5",
|
||||
"react-virtuoso": "^4.14.1",
|
||||
@ -81,16 +81,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^6.0.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.4",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tauri-apps/cli": "2.9.3",
|
||||
"@tauri-apps/cli": "2.9.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-legacy": "^7.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^14.0.2",
|
||||
@ -112,11 +112,11 @@
|
||||
"meta-json-schema": "^1.19.14",
|
||||
"node-fetch": "^3.3.2",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.93.3",
|
||||
"sass": "^1.94.0",
|
||||
"tar": "^7.5.2",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-monaco-editor-esm": "^2.0.2",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
|
||||
747
pnpm-lock.yaml
generated
747
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
43
scripts/extract_update_logs.sh
Executable file
43
scripts/extract_update_logs.sh
Executable file
@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# extract_update_logs.sh
|
||||
# 从 Changelog.md 提取最新版本 (## v...) 的更新内容
|
||||
# 并输出到屏幕或写入环境变量文件(如 GitHub Actions)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CHANGELOG_FILE="Changelog.md"
|
||||
|
||||
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||
echo "❌ 文件不存在: $CHANGELOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 提取从第一个 '## v' 开始到下一个 '## v' 前的内容
|
||||
UPDATE_LOGS=$(awk '
|
||||
/^## v/ {
|
||||
if (found) exit;
|
||||
found=1
|
||||
}
|
||||
found
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [[ -z "$UPDATE_LOGS" ]]; then
|
||||
echo "⚠️ 未找到更新日志内容"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ 提取到的最新版本日志内容如下:"
|
||||
echo "----------------------------------------"
|
||||
echo "$UPDATE_LOGS"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# 如果在 GitHub Actions 环境中(GITHUB_ENV 已定义)
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
{
|
||||
echo "UPDATE_LOGS<<EOF"
|
||||
echo "$UPDATE_LOGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "✅ 已写入 GitHub 环境变量 UPDATE_LOGS"
|
||||
fi
|
||||
@ -2,9 +2,9 @@ import fs from "fs";
|
||||
import fsp from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const UPDATE_LOG = "UPDATELOG.md";
|
||||
const UPDATE_LOG = "Changelog.md";
|
||||
|
||||
// parse the UPDATELOG.md
|
||||
// parse the Changelog.md
|
||||
export async function resolveUpdateLog(tag) {
|
||||
const cwd = process.cwd();
|
||||
|
||||
@ -14,7 +14,7 @@ export async function resolveUpdateLog(tag) {
|
||||
const file = path.join(cwd, UPDATE_LOG);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error("could not found UPDATELOG.md");
|
||||
throw new Error("could not found Changelog.md");
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(file, "utf-8");
|
||||
@ -38,7 +38,7 @@ export async function resolveUpdateLog(tag) {
|
||||
});
|
||||
|
||||
if (!map[tag]) {
|
||||
throw new Error(`could not found "${tag}" in UPDATELOG.md`);
|
||||
throw new Error(`could not found "${tag}" in Changelog.md`);
|
||||
}
|
||||
|
||||
return map[tag].join("\n").trim();
|
||||
@ -49,7 +49,7 @@ export async function resolveUpdateLogDefault() {
|
||||
const file = path.join(cwd, UPDATE_LOG);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error("could not found UPDATELOG.md");
|
||||
throw new Error("could not found Changelog.md");
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(file, "utf-8");
|
||||
@ -77,7 +77,7 @@ export async function resolveUpdateLogDefault() {
|
||||
}
|
||||
|
||||
if (!firstTag) {
|
||||
throw new Error("could not found any version tag in UPDATELOG.md");
|
||||
throw new Error("could not found any version tag in Changelog.md");
|
||||
}
|
||||
|
||||
return content.join("\n").trim();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getOctokit, context } from "@actions/github";
|
||||
import { context, getOctokit } from "@actions/github";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
import { resolveUpdateLog } from "./updatelog.mjs";
|
||||
@ -36,7 +36,7 @@ async function resolveUpdater() {
|
||||
|
||||
const updateData = {
|
||||
name: tag.name,
|
||||
notes: await resolveUpdateLog(tag.name), // use updatelog.md
|
||||
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
"windows-x86_64": { signature: "", url: "" },
|
||||
|
||||
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
@ -1258,7 +1258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3396,7 +3396,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@ -3416,7 +3416,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5031,7 +5031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5831,7 +5831,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@ -5868,7 +5868,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@ -6417,7 +6417,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -7285,7 +7285,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sysproxy"
|
||||
version = "0.3.1"
|
||||
source = "git+https://github.com/clash-verge-rev/sysproxy-rs#50100ab03eb802056c381f3c5009e903c67e3bac"
|
||||
source = "git+https://github.com/clash-verge-rev/sysproxy-rs#ea6e5b5bcef32025e1df914d663eea8558afacb2"
|
||||
dependencies = [
|
||||
"interfaces",
|
||||
"iptools",
|
||||
@ -7704,7 +7704,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tauri-plugin-mihomo"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#dcb6b5a6753233422e7cea23042239c7994c605c"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#d0f00b33cea294cc693e177441fc897426ecbc39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"futures-util",
|
||||
@ -9348,7 +9348,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -8,6 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
||||
default-run = "clash-verge"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
rust-version = "1.91"
|
||||
|
||||
[package.metadata.bundle]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
@ -27,10 +28,10 @@ sysinfo = { version = "0.37.2", features = ["network", "system"] }
|
||||
boa_engine = "0.21.0"
|
||||
serde_json = "1.0.145"
|
||||
serde_yaml_ng = "0.10.0"
|
||||
once_cell = "1.21.3"
|
||||
once_cell = { version = "1.21.3", features = ["parking_lot"] }
|
||||
port_scanner = "0.1.5"
|
||||
delay_timer = "0.11.6"
|
||||
parking_lot = "0.12.5"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
percent-encoding = "2.3.2"
|
||||
tokio = { version = "1.48.0", features = [
|
||||
"rt-multi-thread",
|
||||
@ -186,8 +187,10 @@ unimplemented = "deny"
|
||||
# Development quality lints
|
||||
todo = "warn"
|
||||
dbg_macro = "warn"
|
||||
#print_stdout = "warn"
|
||||
#print_stderr = "warn"
|
||||
|
||||
# 我们期望所有输出方式通过 logging 模块进行统一管理
|
||||
# print_stdout = "deny"
|
||||
# print_stderr = "deny"
|
||||
|
||||
# Performance lints for proxy application
|
||||
clone_on_ref_ptr = "warn"
|
||||
@ -245,3 +248,14 @@ iter_on_empty_collections = "deny"
|
||||
equatable_if_let = "deny"
|
||||
collection_is_never_read = "deny"
|
||||
branches_sharing_code = "deny"
|
||||
pathbuf_init_then_push = "deny"
|
||||
option_as_ref_cloned = "deny"
|
||||
large_types_passed_by_value = "deny"
|
||||
# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启
|
||||
expl_impl_clone_on_copy = "deny"
|
||||
copy_iterator = "deny"
|
||||
cloned_instead_of_copied = "deny"
|
||||
# self_only_used_in_recursion = "deny" // Since 1.92.0
|
||||
unnecessary_self_imports = "deny"
|
||||
unused_trait_names = "deny"
|
||||
wildcard_imports = "deny"
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Rule
|
||||
direct: Direct
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Regel
|
||||
direct: Direkt
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Rule
|
||||
direct: Direct
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Regla
|
||||
direct: Directo
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Rule
|
||||
direct: Direct
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Aturan
|
||||
direct: Langsung
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: ルール
|
||||
direct: ダイレクト
|
||||
global: グローバル
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: 규칙 모드
|
||||
globalMode: 전역 모드
|
||||
directMode: 직접 모드
|
||||
outboundModes: Outbound Modes
|
||||
rule: 규칙
|
||||
direct: 직접
|
||||
global: 글로벌
|
||||
profiles: 프로필
|
||||
proxies: 프록시
|
||||
systemProxy: 시스템 프록시
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Правило
|
||||
direct: Прямой
|
||||
global: Глобальный
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Kural
|
||||
direct: Doğrudan
|
||||
global: Küresel
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: Rule Mode
|
||||
globalMode: Global Mode
|
||||
directMode: Direct Mode
|
||||
outboundModes: Outbound Modes
|
||||
rule: Rule
|
||||
direct: Direct
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
systemProxy: System Proxy
|
||||
|
||||
@ -28,6 +28,10 @@ tray:
|
||||
ruleMode: 规则模式
|
||||
globalMode: 全局模式
|
||||
directMode: 直连模式
|
||||
outboundModes: 出站模式
|
||||
rule: 规则
|
||||
direct: 直连
|
||||
global: 全局
|
||||
profiles: 订阅
|
||||
proxies: 代理
|
||||
systemProxy: 系统代理
|
||||
|
||||
@ -10,8 +10,8 @@ notifications:
|
||||
title: 系統代理
|
||||
body: 系統代理狀態已更新。
|
||||
tunModeToggled:
|
||||
title: TUN 模式
|
||||
body: TUN 模式狀態已更新。
|
||||
title: 虛擬網路介面卡模式
|
||||
body: 已更新虛擬網路介面卡模式狀態。
|
||||
lightweightModeEntered:
|
||||
title: 輕量模式
|
||||
body: 已進入輕量模式。
|
||||
@ -28,10 +28,14 @@ tray:
|
||||
ruleMode: 規則模式
|
||||
globalMode: 全域模式
|
||||
directMode: 直連模式
|
||||
outboundModes: 出站模式
|
||||
rule: 規則
|
||||
direct: 直連
|
||||
global: 全域
|
||||
profiles: 訂閱
|
||||
proxies: 代理
|
||||
systemProxy: 系統代理
|
||||
tunMode: TUN 模式
|
||||
tunMode: 虛擬網路介面卡模式
|
||||
closeAllConnections: 關閉所有連線
|
||||
lightweightMode: 輕量模式
|
||||
copyEnv: 複製環境變數
|
||||
@ -48,5 +52,5 @@ tray:
|
||||
exit: 離開
|
||||
tooltip:
|
||||
systemProxy: 系統代理
|
||||
tun: TUN
|
||||
tun: 虛擬網路介面卡
|
||||
profile: 訂閱
|
||||
|
||||
3
src-tauri/rust-toolchain.toml
Normal file
3
src-tauri/rust-toolchain.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.91.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@ -6,7 +6,7 @@ use_small_heuristics = "Default"
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
remove_nested_parens = true
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
merge_derives = true
|
||||
use_try_shorthand = false
|
||||
use_field_init_shorthand = false
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
use super::CmdResult;
|
||||
use crate::core::sysopt::Sysopt;
|
||||
use crate::utils::resolve::ui::{self, UiReadyStage};
|
||||
use crate::{
|
||||
cmd::StringifyErr,
|
||||
cmd::StringifyErr as _,
|
||||
feat, logging,
|
||||
utils::{
|
||||
dirs::{self, PathBufExec},
|
||||
dirs::{self, PathBufExec as _},
|
||||
logging::Type,
|
||||
},
|
||||
};
|
||||
use smartstring::alias::String;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Manager as _};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
/// 打开应用程序所在目录
|
||||
#[tauri::command]
|
||||
@ -242,34 +243,14 @@ pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<Stri
|
||||
#[tauri::command]
|
||||
pub fn notify_ui_ready() -> CmdResult<()> {
|
||||
logging!(info, Type::Cmd, "前端UI已准备就绪");
|
||||
crate::utils::resolve::ui::mark_ui_ready();
|
||||
ui::mark_ui_ready();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// UI加载阶段
|
||||
#[tauri::command]
|
||||
pub fn update_ui_stage(stage: String) -> CmdResult<()> {
|
||||
logging!(info, Type::Cmd, "UI加载阶段更新: {}", stage.as_str());
|
||||
|
||||
use crate::utils::resolve::ui::UiReadyStage;
|
||||
|
||||
let stage_enum = match stage.as_str() {
|
||||
"NotStarted" => UiReadyStage::NotStarted,
|
||||
"Loading" => UiReadyStage::Loading,
|
||||
"DomReady" => UiReadyStage::DomReady,
|
||||
"ResourcesLoaded" => UiReadyStage::ResourcesLoaded,
|
||||
"Ready" => UiReadyStage::Ready,
|
||||
_ => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Cmd,
|
||||
"Warning: 未知的UI加载阶段: {}",
|
||||
stage.as_str()
|
||||
);
|
||||
return Err(format!("未知的UI加载阶段: {}", stage.as_str()).into());
|
||||
}
|
||||
};
|
||||
|
||||
crate::utils::resolve::ui::update_ui_ready_stage(stage_enum);
|
||||
pub fn update_ui_stage(stage: UiReadyStage) -> CmdResult<()> {
|
||||
logging!(info, Type::Cmd, "UI加载阶段更新: {:?}", &stage);
|
||||
ui::update_ui_ready_stage(stage);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr, feat};
|
||||
use crate::{cmd::StringifyErr as _, feat};
|
||||
use feat::LocalBackupFile;
|
||||
use smartstring::alias::String;
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use super::CmdResult;
|
||||
use crate::utils::dirs;
|
||||
use crate::{
|
||||
cmd::StringifyErr,
|
||||
cmd::StringifyErr as _,
|
||||
config::{ClashInfo, Config},
|
||||
constants,
|
||||
core::{CoreManager, handle, validate::CoreConfigValidator},
|
||||
@ -22,7 +22,7 @@ pub async fn copy_clash_env() -> CmdResult {
|
||||
/// 获取Clash信息
|
||||
#[tauri::command]
|
||||
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
|
||||
Ok(Config::clash().await.latest_arc().get_client_info())
|
||||
Ok(Config::clash().await.data_arc().get_client_info())
|
||||
}
|
||||
|
||||
/// 修改Clash配置
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::CmdResult;
|
||||
use crate::cmd::StringifyErr;
|
||||
use crate::cmd::StringifyErr as _;
|
||||
use crate::core::{EventDrivenProxyManager, async_proxy_query::AsyncProxyQuery};
|
||||
use crate::process::AsyncHandler;
|
||||
use crate::{logging, utils::logging::Type};
|
||||
@ -93,7 +93,7 @@ pub fn get_network_interfaces() -> Vec<String> {
|
||||
/// 获取网络接口详细信息
|
||||
#[tauri::command]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig};
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
|
||||
|
||||
let names = get_network_interfaces();
|
||||
let interfaces = NetworkInterface::show().stringify_err()?;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::CmdResult;
|
||||
use super::StringifyErr;
|
||||
use super::StringifyErr as _;
|
||||
use crate::{
|
||||
config::{
|
||||
Config, IProfiles, PrfItem, PrfOption,
|
||||
@ -11,6 +11,7 @@ use crate::{
|
||||
},
|
||||
core::{CoreManager, handle, timer::Timer, tray::Tray},
|
||||
feat, logging,
|
||||
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
||||
process::AsyncHandler,
|
||||
ret_err,
|
||||
utils::{dirs, help, logging::Type},
|
||||
@ -90,6 +91,7 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
||||
}
|
||||
|
||||
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url);
|
||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -122,6 +124,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
||||
handle::Handle::notify_profile_changed(uid.clone());
|
||||
}
|
||||
Config::profiles().await.apply();
|
||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
@ -164,6 +167,7 @@ pub async fn delete_profile(index: String) -> CmdResult {
|
||||
// 发送配置变更通知
|
||||
logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index);
|
||||
handle::Handle::notify_profile_changed(index);
|
||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "{}", e);
|
||||
@ -460,6 +464,7 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
});
|
||||
}
|
||||
|
||||
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr, config::*, core::CoreManager, log_err};
|
||||
use anyhow::{Context, anyhow};
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
config::{Config, ConfigType},
|
||||
core::CoreManager,
|
||||
log_err,
|
||||
};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
use super::CmdResult;
|
||||
use crate::{
|
||||
cmd::StringifyErr,
|
||||
config::*,
|
||||
core::{validate::CoreConfigValidator, *},
|
||||
cmd::StringifyErr as _,
|
||||
config::{Config, PrfItem},
|
||||
core::{CoreManager, handle, validate::CoreConfigValidator},
|
||||
logging,
|
||||
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
||||
utils::{dirs, logging::Type},
|
||||
};
|
||||
use smartstring::alias::String;
|
||||
@ -17,6 +18,12 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let backup_trigger = match index.as_str() {
|
||||
"Merge" => Some(AutoBackupTrigger::GlobalMerge),
|
||||
"Script" => Some(AutoBackupTrigger::GlobalScript),
|
||||
_ => Some(AutoBackupTrigger::ProfileChange),
|
||||
};
|
||||
|
||||
// 在异步操作前获取必要元数据并释放锁
|
||||
let (rel_path, is_merge_file) = {
|
||||
let profiles = Config::profiles().await;
|
||||
@ -51,11 +58,17 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|
||||
is_merge_file
|
||||
);
|
||||
|
||||
if is_merge_file {
|
||||
return handle_merge_file(&file_path_str, &file_path, &original_content).await;
|
||||
let changes_applied = if is_merge_file {
|
||||
handle_merge_file(&file_path_str, &file_path, &original_content).await?
|
||||
} else {
|
||||
handle_full_validation(&file_path_str, &file_path, &original_content).await?
|
||||
};
|
||||
|
||||
if changes_applied && let Some(trigger) = backup_trigger {
|
||||
AutoBackupManager::trigger_backup(trigger);
|
||||
}
|
||||
|
||||
handle_full_validation(&file_path_str, &file_path, &original_content).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_original(
|
||||
@ -76,7 +89,7 @@ async fn handle_merge_file(
|
||||
file_path_str: &str,
|
||||
file_path: &std::path::Path,
|
||||
original_content: &str,
|
||||
) -> CmdResult {
|
||||
) -> CmdResult<bool> {
|
||||
logging!(
|
||||
info,
|
||||
Type::Config,
|
||||
@ -96,7 +109,7 @@ async fn handle_merge_file(
|
||||
} else {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
Ok((false, error_msg)) => {
|
||||
logging!(
|
||||
@ -108,7 +121,7 @@ async fn handle_merge_file(
|
||||
restore_original(file_path, original_content).await?;
|
||||
let result = (false, error_msg.clone());
|
||||
crate::cmd::validate::handle_yaml_validation_notice(&result, "合并配置文件");
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e);
|
||||
@ -122,11 +135,11 @@ async fn handle_full_validation(
|
||||
file_path_str: &str,
|
||||
file_path: &std::path::Path,
|
||||
original_content: &str,
|
||||
) -> CmdResult {
|
||||
) -> CmdResult<bool> {
|
||||
match CoreConfigValidator::validate_config_file(file_path_str, None).await {
|
||||
Ok((true, _)) => {
|
||||
logging!(info, Type::Config, "[cmd配置save] 验证成功");
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
Ok((false, error_msg)) => {
|
||||
logging!(warn, Type::Config, "[cmd配置save] 验证失败: {}", error_msg);
|
||||
@ -160,7 +173,7 @@ async fn handle_full_validation(
|
||||
handle::Handle::notice_message("config_validate::error", error_msg.to_owned());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use super::{CmdResult, StringifyErr};
|
||||
use super::{CmdResult, StringifyErr as _};
|
||||
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
|
||||
use smartstring::SmartString;
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ use crate::{
|
||||
#[cfg(target_os = "windows")]
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt as _;
|
||||
use tokio::time::Instant;
|
||||
|
||||
// 存储应用启动时间的全局变量
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::cmd::CmdResult;
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use crate::cmd::CmdResult;
|
||||
use crate::cmd::StringifyErr;
|
||||
use crate::cmd::StringifyErr as _;
|
||||
use crate::core::win_uwp;
|
||||
|
||||
pub fn invoke_uwp_tool() -> CmdResult {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::CmdResult;
|
||||
use crate::{
|
||||
core::{validate::CoreConfigValidator, *},
|
||||
core::{handle, validate::CoreConfigValidator},
|
||||
logging,
|
||||
utils::logging::Type,
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr, config::IVerge, feat, utils::draft::SharedBox};
|
||||
use crate::{cmd::StringifyErr as _, config::IVerge, feat, utils::draft::SharedBox};
|
||||
|
||||
/// 获取Verge配置
|
||||
#[tauri::command]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::CmdResult;
|
||||
use crate::{
|
||||
cmd::StringifyErr,
|
||||
cmd::StringifyErr as _,
|
||||
config::{Config, IVerge},
|
||||
core, feat,
|
||||
};
|
||||
@ -19,7 +19,7 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
|
||||
Config::verge().await.edit_draft(|e| e.patch_config(&patch));
|
||||
Config::verge().await.apply();
|
||||
|
||||
let verge_data = Config::verge().await.latest_arc();
|
||||
let verge_data = Config::verge().await.data_arc();
|
||||
verge_data.save_file().await.stringify_err()?;
|
||||
core::backup::WebDavClient::global().reset();
|
||||
Ok(())
|
||||
|
||||
@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
str::FromStr,
|
||||
str::FromStr as _,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
use crate::utils::dirs::get_encryption_key;
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, Key,
|
||||
aead::{Aead, KeyInit},
|
||||
aead::{Aead as _, KeyInit as _},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::cell::Cell;
|
||||
use std::future::Future;
|
||||
|
||||
@ -6,7 +6,7 @@ use crate::{
|
||||
tmpl,
|
||||
},
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use super::{PrfOption, prfitem::PrfItem};
|
||||
use crate::utils::{
|
||||
dirs::{self, PathBufExec},
|
||||
dirs::{self, PathBufExec as _},
|
||||
help,
|
||||
};
|
||||
use crate::{logging, utils::logging::Type};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
|
||||
@ -158,6 +158,15 @@ pub struct IVerge {
|
||||
/// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天
|
||||
pub auto_log_clean: Option<i32>,
|
||||
|
||||
/// Enable scheduled automatic backups
|
||||
pub enable_auto_backup_schedule: Option<bool>,
|
||||
|
||||
/// Automatic backup interval in hours
|
||||
pub auto_backup_interval_hours: Option<u64>,
|
||||
|
||||
/// Create backups automatically when critical configs change
|
||||
pub auto_backup_on_change: Option<bool>,
|
||||
|
||||
/// verge 的各种 port 用于覆盖 clash 的各种 port
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub verge_redir_port: Option<u16>,
|
||||
@ -422,12 +431,15 @@ impl IVerge {
|
||||
auto_check_update: Some(true),
|
||||
enable_builtin_enhanced: Some(true),
|
||||
auto_log_clean: Some(2), // 1: 1天, 2: 7天, 3: 30天, 4: 90天
|
||||
enable_auto_backup_schedule: Some(false),
|
||||
auto_backup_interval_hours: Some(24),
|
||||
auto_backup_on_change: Some(true),
|
||||
webdav_url: None,
|
||||
webdav_username: None,
|
||||
webdav_password: None,
|
||||
enable_tray_speed: Some(false),
|
||||
// enable_tray_icon: Some(true),
|
||||
tray_inline_proxy_groups: Some(false),
|
||||
tray_inline_proxy_groups: Some(true),
|
||||
enable_global_hotkey: Some(true),
|
||||
enable_auto_light_weight_mode: Some(false),
|
||||
auto_light_weight_minutes: Some(10),
|
||||
@ -517,6 +529,9 @@ impl IVerge {
|
||||
patch!(proxy_layout_column);
|
||||
patch!(test_list);
|
||||
patch!(auto_log_clean);
|
||||
patch!(enable_auto_backup_schedule);
|
||||
patch!(auto_backup_interval_hours);
|
||||
patch!(auto_backup_on_change);
|
||||
|
||||
patch!(webdav_url);
|
||||
patch!(webdav_username);
|
||||
|
||||
@ -68,10 +68,6 @@ pub mod error_patterns {
|
||||
}
|
||||
|
||||
pub mod tun {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const DEFAULT_STACK: &str = "mixed";
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub const DEFAULT_STACK: &str = "gvisor";
|
||||
|
||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||
|
||||
@ -13,7 +13,7 @@ use smartstring::alias::String;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env::{consts::OS, temp_dir},
|
||||
io::Write,
|
||||
io::Write as _,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
@ -92,20 +92,20 @@ impl WebDavClient {
|
||||
|| verge.webdav_username.is_none()
|
||||
|| verge.webdav_password.is_none()
|
||||
{
|
||||
let msg: String = "Unable to create web dav client, please make sure the webdav config is correct".into();
|
||||
let msg: String =
|
||||
"Unable to create web dav client, please make sure the webdav config is correct".into();
|
||||
return Err(anyhow::Error::msg(msg));
|
||||
}
|
||||
|
||||
let config = WebDavConfig {
|
||||
url: verge
|
||||
.webdav_url
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches('/')
|
||||
.into(),
|
||||
username: verge.webdav_username.as_ref().cloned().unwrap_or_default(),
|
||||
password: verge.webdav_password.as_ref().cloned().unwrap_or_default(),
|
||||
username: verge.webdav_username.clone().unwrap_or_default(),
|
||||
password: verge.webdav_password.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
// 存储配置到 ArcSwapOption
|
||||
|
||||
@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream};
|
||||
use tokio_stream::{StreamExt as _, wrappers::UnboundedReceiverStream};
|
||||
|
||||
use crate::config::{Config, IVerge};
|
||||
use crate::core::{async_proxy_query::AsyncProxyQuery, handle};
|
||||
@ -520,7 +520,7 @@ impl EventDrivenProxyManager {
|
||||
|
||||
use crate::utils::dirs;
|
||||
#[allow(unused_imports)] // creation_flags必须
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
use tokio::process::Command;
|
||||
|
||||
let binary_path = match dirs::service_path() {
|
||||
|
||||
@ -1,27 +1,33 @@
|
||||
use crate::{APP_HANDLE, constants::timing, singleton};
|
||||
use parking_lot::RwLock;
|
||||
use smartstring::alias::String;
|
||||
use std::{sync::Arc, thread};
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
use tauri::{AppHandle, Manager as _, WebviewWindow};
|
||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||
use tokio::sync::RwLockReadGuard;
|
||||
|
||||
use super::notification::{ErrorMessage, FrontendEvent, NotificationSystem};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug)]
|
||||
pub struct Handle {
|
||||
is_exiting: Arc<RwLock<bool>>,
|
||||
is_exiting: AtomicBool,
|
||||
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
|
||||
startup_completed: Arc<RwLock<bool>>,
|
||||
startup_completed: AtomicBool,
|
||||
pub(crate) notification_system: Arc<RwLock<Option<NotificationSystem>>>,
|
||||
}
|
||||
|
||||
impl Default for Handle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_exiting: Arc::new(RwLock::new(false)),
|
||||
is_exiting: AtomicBool::new(false),
|
||||
startup_errors: Arc::new(RwLock::new(Vec::new())),
|
||||
startup_completed: Arc::new(RwLock::new(false)),
|
||||
startup_completed: AtomicBool::new(false),
|
||||
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
|
||||
}
|
||||
}
|
||||
@ -108,7 +114,7 @@ impl Handle {
|
||||
let status_str = status.into();
|
||||
let msg_str = msg.into();
|
||||
|
||||
if !*handle.startup_completed.read() {
|
||||
if !handle.startup_completed.load(Ordering::Acquire) {
|
||||
handle.startup_errors.write().push(ErrorMessage {
|
||||
status: status_str,
|
||||
message: msg_str,
|
||||
@ -139,7 +145,7 @@ impl Handle {
|
||||
}
|
||||
|
||||
pub fn mark_startup_completed(&self) {
|
||||
*self.startup_completed.write() = true;
|
||||
self.startup_completed.store(true, Ordering::Release);
|
||||
self.send_startup_errors();
|
||||
}
|
||||
|
||||
@ -182,7 +188,7 @@ impl Handle {
|
||||
}
|
||||
|
||||
pub fn set_is_exiting(&self) {
|
||||
*self.is_exiting.write() = true;
|
||||
self.is_exiting.store(true, Ordering::Release);
|
||||
|
||||
let mut system_opt = self.notification_system.write();
|
||||
if let Some(system) = system_opt.as_mut() {
|
||||
@ -191,7 +197,7 @@ impl Handle {
|
||||
}
|
||||
|
||||
pub fn is_exiting(&self) -> bool {
|
||||
*self.is_exiting.read()
|
||||
self.is_exiting.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ use anyhow::{Result, bail};
|
||||
use arc_swap::ArcSwap;
|
||||
use smartstring::alias::String;
|
||||
use std::{collections::HashMap, fmt, str::FromStr, sync::Arc};
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt as _, ShortcutState};
|
||||
|
||||
/// Enum representing all available hotkey functions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@ -227,7 +227,7 @@ impl Hotkey {
|
||||
|
||||
let is_enable_global_hotkey = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_global_hotkey
|
||||
.unwrap_or(true);
|
||||
|
||||
@ -264,7 +264,7 @@ singleton_with_logging!(Hotkey, INSTANCE, "Hotkey");
|
||||
impl Hotkey {
|
||||
pub async fn init(&self, skip: bool) -> Result<()> {
|
||||
let verge = Config::verge().await;
|
||||
let enable_global_hotkey = !skip && verge.latest_arc().enable_global_hotkey.unwrap_or(true);
|
||||
let enable_global_hotkey = !skip && verge.data_arc().enable_global_hotkey.unwrap_or(true);
|
||||
|
||||
logging!(
|
||||
debug,
|
||||
@ -274,7 +274,7 @@ impl Hotkey {
|
||||
);
|
||||
|
||||
// Extract hotkeys data before async operations
|
||||
let hotkeys = verge.latest_arc().hotkeys.as_ref().cloned();
|
||||
let hotkeys = verge.data_arc().hotkeys.clone();
|
||||
|
||||
if let Some(hotkeys) = hotkeys {
|
||||
logging!(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::CoreManager;
|
||||
use crate::{
|
||||
config::*,
|
||||
config::{Config, ConfigType, IRuntime},
|
||||
constants::timing,
|
||||
core::{handle, validate::CoreConfigValidator},
|
||||
logging,
|
||||
|
||||
@ -16,7 +16,7 @@ use compact_str::CompactString;
|
||||
use flexi_logger::DeferredNow;
|
||||
use log::Level;
|
||||
use scopeguard::defer;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn get_clash_logs(&self) -> Result<Vec<CompactString>> {
|
||||
|
||||
@ -8,13 +8,13 @@ use parking_lot::RwLock;
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{Emitter, WebviewWindow};
|
||||
use tauri::{Emitter as _, WebviewWindow};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FrontendEvent {
|
||||
@ -47,7 +47,7 @@ pub struct NotificationSystem {
|
||||
worker_handle: Option<thread::JoinHandle<()>>,
|
||||
pub(super) is_running: bool,
|
||||
stats: EventStats,
|
||||
emergency_mode: RwLock<bool>,
|
||||
emergency_mode: AtomicBool,
|
||||
}
|
||||
|
||||
impl Default for NotificationSystem {
|
||||
@ -63,7 +63,7 @@ impl NotificationSystem {
|
||||
worker_handle: None,
|
||||
is_running: false,
|
||||
stats: EventStats::default(),
|
||||
emergency_mode: RwLock::new(false),
|
||||
emergency_mode: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +125,7 @@ impl NotificationSystem {
|
||||
}
|
||||
|
||||
fn should_skip_event(&self, event: &FrontendEvent) -> bool {
|
||||
let is_emergency = *self.emergency_mode.read();
|
||||
let is_emergency = self.emergency_mode.load(Ordering::Acquire);
|
||||
matches!(
|
||||
(is_emergency, event),
|
||||
(true, FrontendEvent::NoticeMessage { status, .. }) if status == "info"
|
||||
@ -184,14 +184,14 @@ impl NotificationSystem {
|
||||
*self.stats.last_error_time.write() = Some(Instant::now());
|
||||
|
||||
let errors = self.stats.total_errors.load(Ordering::Relaxed);
|
||||
if errors > retry::EVENT_EMIT_THRESHOLD && !*self.emergency_mode.read() {
|
||||
if errors > retry::EVENT_EMIT_THRESHOLD && !self.emergency_mode.load(Ordering::Acquire) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Frontend,
|
||||
"Entering emergency mode after {} errors",
|
||||
errors
|
||||
);
|
||||
*self.emergency_mode.write() = true;
|
||||
self.emergency_mode.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::{
|
||||
logging, logging_error,
|
||||
utils::{dirs, init::service_writer_config, logging::Type},
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_service_ipc::CoreConfig;
|
||||
use compact_str::CompactString;
|
||||
use once_cell::sync::Lazy;
|
||||
@ -37,7 +37,7 @@ async fn uninstall_service() -> Result<()> {
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
let binary_path = dirs::service_path()?;
|
||||
let uninstall_path = binary_path.with_file_name("clash-verge-service-uninstall.exe");
|
||||
@ -72,7 +72,7 @@ async fn install_service() -> Result<()> {
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
let binary_path = dirs::service_path()?;
|
||||
let install_path = binary_path.with_file_name("clash-verge-service-install.exe");
|
||||
|
||||
@ -12,7 +12,7 @@ use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
use tauri_plugin_autostart::ManagerExt as _;
|
||||
|
||||
pub struct Sysopt {
|
||||
initialed: AtomicBool,
|
||||
@ -59,7 +59,7 @@ async fn execute_sysproxy_command(args: Vec<std::string::String>) -> Result<()>
|
||||
use crate::utils::dirs;
|
||||
use anyhow::bail;
|
||||
#[allow(unused_imports)] // Required for .creation_flags() method
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
use tokio::process::Command;
|
||||
|
||||
let binary_path = dirs::service_path()?;
|
||||
|
||||
@ -2,7 +2,7 @@ use crate::{
|
||||
config::Config, core::sysopt::Sysopt, feat, logging, logging_error, singleton,
|
||||
utils::logging::Type,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context as _, Result};
|
||||
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
|
||||
use parking_lot::RwLock;
|
||||
use smartstring::alias::String;
|
||||
|
||||
@ -36,6 +36,7 @@ define_menu! {
|
||||
rule_mode => RULE_MODE, "tray_rule_mode", "tray.ruleMode",
|
||||
global_mode => GLOBAL_MODE, "tray_global_mode", "tray.globalMode",
|
||||
direct_mode => DIRECT_MODE, "tray_direct_mode", "tray.directMode",
|
||||
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
|
||||
profiles => PROFILES, "tray_profiles", "tray.profiles",
|
||||
proxies => PROXIES, "tray_proxies", "tray.proxies",
|
||||
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
|
||||
|
||||
@ -4,7 +4,7 @@ use tauri_plugin_mihomo::models::Proxies;
|
||||
use tokio::fs;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod speed_rate;
|
||||
use crate::config::PrfSelected;
|
||||
use crate::config::{IVerge, PrfSelected};
|
||||
use crate::core::service;
|
||||
use crate::module::lightweight;
|
||||
use crate::process::AsyncHandler;
|
||||
@ -83,8 +83,7 @@ pub struct Tray {
|
||||
}
|
||||
|
||||
impl TrayState {
|
||||
pub async fn get_common_tray_icon() -> (bool, Vec<u8>) {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
async fn get_common_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
|
||||
let is_common_tray_icon = verge.common_tray_icon.unwrap_or(false);
|
||||
if is_common_tray_icon
|
||||
&& let Ok(Some(common_icon_path)) = find_target_icons("common")
|
||||
@ -120,8 +119,7 @@ impl TrayState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_sysproxy_tray_icon() -> (bool, Vec<u8>) {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
async fn get_sysproxy_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
|
||||
let is_sysproxy_tray_icon = verge.sysproxy_tray_icon.unwrap_or(false);
|
||||
if is_sysproxy_tray_icon
|
||||
&& let Ok(Some(sysproxy_icon_path)) = find_target_icons("sysproxy")
|
||||
@ -157,8 +155,7 @@ impl TrayState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tun_tray_icon() -> (bool, Vec<u8>) {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
async fn get_tun_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
|
||||
let is_tun_tray_icon = verge.tun_tray_icon.unwrap_or(false);
|
||||
if is_tun_tray_icon
|
||||
&& let Ok(Some(tun_icon_path)) = find_target_icons("tun")
|
||||
@ -351,7 +348,7 @@ impl Tray {
|
||||
|
||||
/// 更新托盘图标
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn update_icon(&self) -> Result<()> {
|
||||
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
||||
return Ok(());
|
||||
@ -371,15 +368,14 @@ impl Tray {
|
||||
}
|
||||
};
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||
|
||||
let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
|
||||
(true, true) => TrayState::get_tun_tray_icon().await,
|
||||
(true, false) => TrayState::get_sysproxy_tray_icon().await,
|
||||
(false, true) => TrayState::get_tun_tray_icon().await,
|
||||
(false, false) => TrayState::get_common_tray_icon().await,
|
||||
(true, true) => TrayState::get_tun_tray_icon(verge).await,
|
||||
(true, false) => TrayState::get_sysproxy_tray_icon(verge).await,
|
||||
(false, true) => TrayState::get_tun_tray_icon(verge).await,
|
||||
(false, false) => TrayState::get_common_tray_icon(verge).await,
|
||||
};
|
||||
|
||||
let colorful = verge
|
||||
@ -394,7 +390,7 @@ impl Tray {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn update_icon(&self) -> Result<()> {
|
||||
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
||||
return Ok(());
|
||||
@ -414,15 +410,14 @@ impl Tray {
|
||||
}
|
||||
};
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||
|
||||
let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
|
||||
(true, true) => TrayState::get_tun_tray_icon().await,
|
||||
(true, false) => TrayState::get_sysproxy_tray_icon().await,
|
||||
(false, true) => TrayState::get_tun_tray_icon().await,
|
||||
(false, false) => TrayState::get_common_tray_icon().await,
|
||||
(true, true) => TrayState::get_tun_tray_icon(verge).await,
|
||||
(true, false) => TrayState::get_sysproxy_tray_icon(verge).await,
|
||||
(false, true) => TrayState::get_tun_tray_icon(verge).await,
|
||||
(false, false) => TrayState::get_common_tray_icon(verge).await,
|
||||
};
|
||||
|
||||
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
|
||||
@ -505,13 +500,14 @@ impl Tray {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘局部更新");
|
||||
return Ok(());
|
||||
}
|
||||
let verge = Config::verge().await.data_arc();
|
||||
self.update_menu().await?;
|
||||
self.update_icon().await?;
|
||||
self.update_icon(&verge).await?;
|
||||
self.update_tooltip().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
|
||||
async fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘创建");
|
||||
return Ok(());
|
||||
@ -519,8 +515,10 @@ impl Tray {
|
||||
|
||||
logging!(info, Type::Tray, "正在从AppHandle创建系统托盘");
|
||||
|
||||
let verge = Config::verge().await.data_arc();
|
||||
|
||||
// 获取图标
|
||||
let icon_bytes = TrayState::get_common_tray_icon().await.1;
|
||||
let icon_bytes = TrayState::get_common_tray_icon(&verge).await.1;
|
||||
let icon = tauri::image::Image::from_bytes(&icon_bytes)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@ -530,6 +528,7 @@ impl Tray {
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
let show_menu_on_left_click = {
|
||||
// TODO 优化这里 复用 verge
|
||||
let tray_event = { Config::verge().await.latest_arc().tray_event.clone() };
|
||||
let tray_event: String = tray_event.unwrap_or_else(|| "main_window".into());
|
||||
tray_event.as_str() == "tray_menu"
|
||||
@ -871,7 +870,7 @@ async fn create_tray_menu(
|
||||
});
|
||||
|
||||
let verge_settings = Config::verge().await.latest_arc();
|
||||
let show_proxy_groups_inline = verge_settings.tray_inline_proxy_groups.unwrap_or(false);
|
||||
let show_proxy_groups_inline = verge_settings.tray_inline_proxy_groups.unwrap_or(true);
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@ -896,6 +895,13 @@ async fn create_tray_menu(
|
||||
hotkeys.get("open_or_close_dashboard").map(|s| s.as_str()),
|
||||
)?;
|
||||
|
||||
let current_mode_text = match current_proxy_mode {
|
||||
"global" => rust_i18n::t!("tray.global"),
|
||||
"direct" => rust_i18n::t!("tray.direct"),
|
||||
_ => rust_i18n::t!("tray.rule"),
|
||||
};
|
||||
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
|
||||
|
||||
let rule_mode = &CheckMenuItem::with_id(
|
||||
app_handle,
|
||||
MenuIds::RULE_MODE,
|
||||
@ -923,6 +929,18 @@ async fn create_tray_menu(
|
||||
hotkeys.get("clash_mode_direct").map(|s| s.as_str()),
|
||||
)?;
|
||||
|
||||
let outbound_modes = &Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
MenuIds::OUTBOUND_MODES,
|
||||
outbound_modes_label.as_str(),
|
||||
true,
|
||||
&[
|
||||
rule_mode as &dyn IsMenuItem<Wry>,
|
||||
global_mode as &dyn IsMenuItem<Wry>,
|
||||
direct_mode as &dyn IsMenuItem<Wry>,
|
||||
],
|
||||
)?;
|
||||
|
||||
let profiles = &Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
MenuIds::PROFILES,
|
||||
@ -1073,6 +1091,7 @@ async fn create_tray_menu(
|
||||
&texts.more,
|
||||
true,
|
||||
&[
|
||||
copy_env as &dyn IsMenuItem<Wry>,
|
||||
close_all_connections,
|
||||
restart_clash,
|
||||
restart_app,
|
||||
@ -1091,15 +1110,8 @@ async fn create_tray_menu(
|
||||
let separator = &PredefinedMenuItem::separator(app_handle)?;
|
||||
|
||||
// 动态构建菜单项
|
||||
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![
|
||||
open_window,
|
||||
separator,
|
||||
rule_mode,
|
||||
global_mode,
|
||||
direct_mode,
|
||||
separator,
|
||||
profiles,
|
||||
];
|
||||
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> =
|
||||
vec![open_window, outbound_modes, separator, profiles];
|
||||
|
||||
// 如果有代理节点,添加代理节点菜单
|
||||
if show_proxy_groups_inline {
|
||||
@ -1116,7 +1128,6 @@ async fn create_tray_menu(
|
||||
tun_mode as &dyn IsMenuItem<Wry>,
|
||||
separator,
|
||||
lightweight_mode as &dyn IsMenuItem<Wry>,
|
||||
copy_env as &dyn IsMenuItem<Wry>,
|
||||
open_dir as &dyn IsMenuItem<Wry>,
|
||||
more as &dyn IsMenuItem<Wry>,
|
||||
separator,
|
||||
|
||||
@ -2,7 +2,7 @@ use anyhow::Result;
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::config::{Config, ConfigType};
|
||||
|
||||
@ -5,7 +5,14 @@ mod script;
|
||||
pub mod seq;
|
||||
mod tun;
|
||||
|
||||
use self::{chain::*, field::*, merge::*, script::*, seq::*, tun::*};
|
||||
use self::{
|
||||
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
|
||||
field::{use_keys, use_lowercase, use_sort},
|
||||
merge::use_merge,
|
||||
script::use_script,
|
||||
seq::{SeqMap, use_seq},
|
||||
tun::use_tun,
|
||||
};
|
||||
use crate::constants;
|
||||
use crate::utils::dirs;
|
||||
use crate::{config::Config, utils::tmpl};
|
||||
|
||||
@ -2,8 +2,6 @@ use serde_yaml_ng::{Mapping, Value};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::process::AsyncHandler;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::{logging, utils::logging::Type};
|
||||
|
||||
macro_rules! revise {
|
||||
($map: expr, $key: expr, $val: expr) => {
|
||||
@ -31,27 +29,6 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
});
|
||||
|
||||
if enable {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let stack_key = Value::from("stack");
|
||||
let should_override = match tun_val.get(&stack_key) {
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.map(|stack| stack.eq_ignore_ascii_case("gvisor"))
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_override {
|
||||
revise!(tun_val, "stack", "mixed");
|
||||
logging!(
|
||||
warn,
|
||||
Type::Network,
|
||||
"Warning: gVisor TUN stack detected on Linux; falling back to 'mixed' for compatibility"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 读取DNS配置
|
||||
let dns_key = Value::from("dns");
|
||||
let dns_val = config.get(&dns_key);
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::{
|
||||
logging, logging_error,
|
||||
process::AsyncHandler,
|
||||
utils::{
|
||||
dirs::{PathBufExec, app_home_dir, local_backup_dir},
|
||||
dirs::{PathBufExec as _, app_home_dir, local_backup_dir},
|
||||
logging::Type,
|
||||
},
|
||||
};
|
||||
@ -123,6 +123,15 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
||||
|
||||
/// Create a backup and save to local storage
|
||||
pub async fn create_local_backup() -> Result<()> {
|
||||
create_local_backup_with_namer(|name| name.to_string().into())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn create_local_backup_with_namer<F>(namer: F) -> Result<String>
|
||||
where
|
||||
F: FnOnce(&str) -> String,
|
||||
{
|
||||
let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| {
|
||||
logging!(
|
||||
error,
|
||||
@ -133,7 +142,8 @@ pub async fn create_local_backup() -> Result<()> {
|
||||
})?;
|
||||
|
||||
let backup_dir = local_backup_dir()?;
|
||||
let target_path = backup_dir.join(file_name.as_str());
|
||||
let final_name = namer(file_name.as_str());
|
||||
let target_path = backup_dir.join(final_name.as_str());
|
||||
|
||||
if let Err(err) = move_file(temp_file_path.clone(), target_path.clone()).await {
|
||||
logging!(
|
||||
@ -152,7 +162,7 @@ pub async fn create_local_backup() -> Result<()> {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(final_name)
|
||||
}
|
||||
|
||||
async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> {
|
||||
|
||||
@ -82,7 +82,12 @@ pub async fn change_clash_mode(mode: String) {
|
||||
if clash_data.save_config().await.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
logging_error!(Type::Tray, tray::Tray::global().update_menu().await);
|
||||
logging_error!(Type::Tray, tray::Tray::global().update_icon().await);
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
tray::Tray::global()
|
||||
.update_icon(&Config::verge().await.data_arc())
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
let is_auto_close_connection = Config::verge()
|
||||
|
||||
@ -2,7 +2,7 @@ use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{CoreManager, handle, hotkey, sysopt, tray},
|
||||
logging_error,
|
||||
module::lightweight,
|
||||
module::{auto_backup::AutoBackupManager, lightweight},
|
||||
utils::{draft::SharedBox, logging::Type},
|
||||
};
|
||||
use anyhow::Result;
|
||||
@ -22,7 +22,12 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
|
||||
} else {
|
||||
if patch.get("mode").is_some() {
|
||||
logging_error!(Type::Tray, tray::Tray::global().update_menu().await);
|
||||
logging_error!(Type::Tray, tray::Tray::global().update_icon().await);
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
tray::Tray::global()
|
||||
.update_icon(&Config::verge().await.data_arc())
|
||||
.await
|
||||
);
|
||||
}
|
||||
Config::runtime()
|
||||
.await
|
||||
@ -180,6 +185,7 @@ fn determine_update_flags(patch: &IVerge) -> i32 {
|
||||
update_flags
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<()> {
|
||||
// Process updates based on flags
|
||||
if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 {
|
||||
@ -211,7 +217,9 @@ async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<(
|
||||
tray::Tray::global().update_menu().await?;
|
||||
}
|
||||
if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 {
|
||||
tray::Tray::global().update_icon().await?;
|
||||
tray::Tray::global()
|
||||
.update_icon(&Config::verge().await.latest_arc())
|
||||
.await?;
|
||||
}
|
||||
if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 {
|
||||
tray::Tray::global().update_tooltip().await?;
|
||||
@ -243,6 +251,10 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||
return Err(err);
|
||||
}
|
||||
Config::verge().await.apply();
|
||||
logging_error!(
|
||||
Type::Backup,
|
||||
AutoBackupManager::global().refresh_settings().await
|
||||
);
|
||||
if !not_save_file {
|
||||
// 分离数据获取和异步调用
|
||||
let verge_data = Config::verge().await.data_arc();
|
||||
|
||||
@ -7,7 +7,7 @@ use crate::{
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use smartstring::alias::String;
|
||||
use tauri::Emitter;
|
||||
use tauri::Emitter as _;
|
||||
|
||||
/// Toggle proxy profile
|
||||
pub async fn toggle_proxy_profile(profile_index: String) {
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::{
|
||||
utils::logging::Type,
|
||||
};
|
||||
use std::env;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt as _;
|
||||
|
||||
/// Toggle system proxy on/off
|
||||
pub async fn toggle_system_proxy() {
|
||||
|
||||
@ -47,7 +47,7 @@ pub async fn clean_async() -> bool {
|
||||
let tun_task = async {
|
||||
let tun_enabled = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_tun_mode
|
||||
.unwrap_or(false);
|
||||
|
||||
@ -57,13 +57,8 @@ pub async fn clean_async() -> bool {
|
||||
|
||||
let disable_tun = serde_json::json!({ "tun": { "enable": false } });
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let tun_timeout = Duration::from_millis(100);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let tun_timeout = Duration::from_millis(100);
|
||||
|
||||
match timeout(
|
||||
tun_timeout,
|
||||
Duration::from_millis(1000),
|
||||
handle::Handle::mihomo()
|
||||
.await
|
||||
.patch_base_config(&disable_tun),
|
||||
@ -100,7 +95,7 @@ pub async fn clean_async() -> bool {
|
||||
// 检查系统代理是否开启
|
||||
let sys_proxy_enabled = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_system_proxy
|
||||
.unwrap_or(false);
|
||||
|
||||
@ -176,7 +171,7 @@ pub async fn clean_async() -> bool {
|
||||
{
|
||||
let sys_proxy_enabled = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_system_proxy
|
||||
.unwrap_or(false);
|
||||
|
||||
@ -316,7 +311,7 @@ pub async fn hide() {
|
||||
|
||||
let enable_auto_light_weight_mode = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_auto_light_weight_mode
|
||||
.unwrap_or(false);
|
||||
|
||||
|
||||
@ -11,25 +11,20 @@ mod module;
|
||||
mod process;
|
||||
pub mod utils;
|
||||
use crate::constants::files;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::module::lightweight;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::utils::linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
core::{EventDrivenProxyManager, handle, hotkey},
|
||||
core::{EventDrivenProxyManager, handle},
|
||||
process::AsyncHandler,
|
||||
utils::resolve,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use config::Config;
|
||||
use once_cell::sync::OnceCell;
|
||||
use rust_i18n::i18n;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Manager as _};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_deep_link::DeepLinkExt as _;
|
||||
use utils::logging::Type;
|
||||
|
||||
i18n!("locales", fallback = "zh");
|
||||
@ -267,8 +262,20 @@ pub fn run() {
|
||||
.invoke_handler(app_init::generate_handlers());
|
||||
|
||||
mod event_handlers {
|
||||
use super::*;
|
||||
use crate::core::handle;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::module::lightweight;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
config::Config,
|
||||
core::{self, handle, hotkey},
|
||||
logging,
|
||||
process::AsyncHandler,
|
||||
utils::logging::Type,
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::Manager as _;
|
||||
|
||||
pub fn handle_ready_resumed(_app_handle: &AppHandle) {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
@ -320,7 +327,7 @@ pub fn run() {
|
||||
AsyncHandler::spawn(move || async move {
|
||||
let is_enable_global_hotkey = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_global_hotkey
|
||||
.unwrap_or(true);
|
||||
|
||||
@ -360,7 +367,7 @@ pub fn run() {
|
||||
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW);
|
||||
let is_enable_global_hotkey = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_global_hotkey
|
||||
.unwrap_or(true);
|
||||
if !is_enable_global_hotkey {
|
||||
@ -416,7 +423,7 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::ExitRequested { api, code, .. } => {
|
||||
tauri::async_runtime::block_on(async {
|
||||
AsyncHandler::block_on(async {
|
||||
let _ = handle::Handle::mihomo()
|
||||
.await
|
||||
.clear_all_ws_connections()
|
||||
|
||||
332
src-tauri/src/module/auto_backup.rs
Normal file
332
src-tauri/src/module/auto_backup.rs
Normal file
@ -0,0 +1,332 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
feat::create_local_backup_with_namer,
|
||||
logging,
|
||||
process::AsyncHandler,
|
||||
utils::{dirs::local_backup_dir, logging::Type},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::RwLock;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||
},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{
|
||||
fs,
|
||||
sync::{Mutex, watch},
|
||||
};
|
||||
|
||||
const DEFAULT_INTERVAL_HOURS: u64 = 24;
|
||||
const MIN_INTERVAL_HOURS: u64 = 1;
|
||||
const MAX_INTERVAL_HOURS: u64 = 168;
|
||||
const MIN_BACKUP_INTERVAL_SECS: i64 = 60;
|
||||
const AUTO_BACKUP_KEEP: usize = 20;
|
||||
const AUTO_MARKER: &str = "-auto-";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AutoBackupTrigger {
|
||||
Scheduled,
|
||||
GlobalMerge,
|
||||
GlobalScript,
|
||||
ProfileChange,
|
||||
}
|
||||
|
||||
impl AutoBackupTrigger {
|
||||
const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scheduled => "scheduled",
|
||||
Self::GlobalMerge => "merge",
|
||||
Self::GlobalScript => "script",
|
||||
Self::ProfileChange => "profile",
|
||||
}
|
||||
}
|
||||
|
||||
const fn is_schedule(self) -> bool {
|
||||
matches!(self, Self::Scheduled)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AutoBackupSettings {
|
||||
schedule_enabled: bool,
|
||||
interval_hours: u64,
|
||||
change_enabled: bool,
|
||||
}
|
||||
|
||||
impl AutoBackupSettings {
|
||||
fn from_verge(verge: &IVerge) -> Self {
|
||||
let interval = verge
|
||||
.auto_backup_interval_hours
|
||||
.unwrap_or(DEFAULT_INTERVAL_HOURS)
|
||||
.clamp(MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS);
|
||||
|
||||
Self {
|
||||
schedule_enabled: verge.enable_auto_backup_schedule.unwrap_or(false),
|
||||
interval_hours: interval,
|
||||
change_enabled: verge.auto_backup_on_change.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoBackupSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schedule_enabled: false,
|
||||
interval_hours: DEFAULT_INTERVAL_HOURS,
|
||||
change_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AutoBackupManager {
|
||||
settings: Arc<RwLock<AutoBackupSettings>>,
|
||||
settings_tx: watch::Sender<AutoBackupSettings>,
|
||||
runner_started: AtomicBool,
|
||||
exec_lock: Mutex<()>,
|
||||
last_backup: AtomicI64,
|
||||
}
|
||||
|
||||
impl AutoBackupManager {
|
||||
pub fn global() -> &'static Self {
|
||||
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new();
|
||||
INSTANCE.get_or_init(|| {
|
||||
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
||||
Self {
|
||||
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
|
||||
settings_tx: tx,
|
||||
runner_started: AtomicBool::new(false),
|
||||
exec_lock: Mutex::new(()),
|
||||
last_backup: AtomicI64::new(0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
let settings = Self::load_settings().await;
|
||||
{
|
||||
*self.settings.write() = settings;
|
||||
}
|
||||
let _ = self.settings_tx.send(settings);
|
||||
self.maybe_start_runner(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh_settings(&self) -> Result<()> {
|
||||
let settings = Self::load_settings().await;
|
||||
{
|
||||
*self.settings.write() = settings;
|
||||
}
|
||||
let _ = self.settings_tx.send(settings);
|
||||
self.maybe_start_runner(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn trigger_backup(trigger: AutoBackupTrigger) {
|
||||
AsyncHandler::spawn(move || async move {
|
||||
if let Err(err) = Self::global().execute_trigger(trigger).await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Auto backup execution failed ({:?}): {err:#?}",
|
||||
trigger
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn maybe_start_runner(&self, settings: AutoBackupSettings) {
|
||||
if settings.schedule_enabled {
|
||||
self.ensure_runner();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_runner(&self) {
|
||||
if self.runner_started.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rx = self.settings_tx.subscribe();
|
||||
AsyncHandler::spawn(move || async move {
|
||||
Self::run_scheduler(&mut rx).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_scheduler(rx: &mut watch::Receiver<AutoBackupSettings>) {
|
||||
let mut current = *rx.borrow();
|
||||
loop {
|
||||
if !current.schedule_enabled {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
current = *rx.borrow();
|
||||
continue;
|
||||
}
|
||||
|
||||
let duration = Duration::from_secs(current.interval_hours.saturating_mul(3600));
|
||||
let sleeper = tokio::time::sleep(duration);
|
||||
tokio::pin!(sleeper);
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut sleeper => {
|
||||
if let Err(err) = Self::global()
|
||||
.execute_trigger(AutoBackupTrigger::Scheduled)
|
||||
.await
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Scheduled auto backup failed: {err:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
changed = rx.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
current = *rx.borrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
|
||||
let snapshot = *self.settings.read();
|
||||
|
||||
if trigger.is_schedule() && !snapshot.schedule_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if !trigger.is_schedule() && !snapshot.change_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.should_run_now() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _guard = self.exec_lock.lock().await;
|
||||
if !self.should_run_now() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file_name =
|
||||
create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into())
|
||||
.await?;
|
||||
self.last_backup
|
||||
.store(Local::now().timestamp(), Ordering::Release);
|
||||
|
||||
if let Err(err) = cleanup_auto_backups().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Failed to cleanup old auto backups: {err:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::Backup,
|
||||
"Auto backup created ({:?}): {}",
|
||||
trigger,
|
||||
file_name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_run_now(&self) -> bool {
|
||||
let last = self.last_backup.load(Ordering::Acquire);
|
||||
if last == 0 {
|
||||
return true;
|
||||
}
|
||||
let now = Local::now().timestamp();
|
||||
now.saturating_sub(last) >= MIN_BACKUP_INTERVAL_SECS
|
||||
}
|
||||
|
||||
async fn load_settings() -> AutoBackupSettings {
|
||||
let verge = Config::verge().await;
|
||||
AutoBackupSettings::from_verge(&verge.latest_arc())
|
||||
}
|
||||
}
|
||||
|
||||
fn append_auto_suffix(file_name: &str, slug: &str) -> String {
|
||||
match file_name.rsplit_once('.') {
|
||||
Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"),
|
||||
None => format!("{file_name}{AUTO_MARKER}{slug}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_auto_backups() -> Result<()> {
|
||||
if AUTO_BACKUP_KEEP == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let backup_dir = local_backup_dir()?;
|
||||
if !backup_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = match fs::read_dir(&backup_dir).await {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Failed to read backup directory: {err:#?}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mut files: Vec<(PathBuf, u64)> = Vec::new();
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if !file_name.contains(AUTO_MARKER) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.await
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|dur| dur.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
files.push((path, modified));
|
||||
}
|
||||
|
||||
if files.len() <= AUTO_BACKUP_KEEP {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
files.sort_by_key(|(_, ts)| *ts);
|
||||
let remove_count = files.len() - AUTO_BACKUP_KEEP;
|
||||
for (path, _) in files.into_iter().take(remove_count) {
|
||||
if let Err(err) = fs::remove_file(&path).await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Failed to remove auto backup {}: {err:#?}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -10,10 +10,10 @@ use crate::{
|
||||
use crate::logging_error;
|
||||
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context as _, Result};
|
||||
use delay_timer::prelude::TaskBuilder;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
|
||||
use tauri::Listener;
|
||||
use tauri::Listener as _;
|
||||
|
||||
const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod auto_backup;
|
||||
pub mod lightweight;
|
||||
pub mod signal;
|
||||
pub mod sysinfo;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use tauri::Manager;
|
||||
use tauri::Manager as _;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
UI::WindowsAndMessaging::{
|
||||
|
||||
@ -12,6 +12,7 @@ impl AsyncHandler {
|
||||
// async_runtime::handle()
|
||||
// }
|
||||
|
||||
#[inline]
|
||||
#[track_caller]
|
||||
pub fn spawn<F, Fut>(f: F) -> JoinHandle<()>
|
||||
where
|
||||
@ -23,6 +24,7 @@ impl AsyncHandler {
|
||||
async_runtime::spawn(f())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[track_caller]
|
||||
pub fn spawn_blocking<T, F>(f: F) -> JoinHandle<T>
|
||||
where
|
||||
@ -34,7 +36,7 @@ impl AsyncHandler {
|
||||
async_runtime::spawn_blocking(f)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[inline]
|
||||
#[track_caller]
|
||||
pub fn block_on<Fut>(fut: Fut) -> Fut::Output
|
||||
where
|
||||
|
||||
@ -7,6 +7,7 @@ use crate::{logging, utils::logging::Type};
|
||||
pub struct CommandChildGuard(Option<CommandChild>);
|
||||
|
||||
impl Drop for CommandChildGuard {
|
||||
#[inline]
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = self.kill() {
|
||||
logging!(
|
||||
@ -20,10 +21,12 @@ impl Drop for CommandChildGuard {
|
||||
}
|
||||
|
||||
impl CommandChildGuard {
|
||||
#[inline]
|
||||
pub const fn new(child: CommandChild) -> Self {
|
||||
Self(Some(child))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn kill(&mut self) -> Result<()> {
|
||||
if let Some(child) = self.0.take() {
|
||||
let _ = child.kill();
|
||||
@ -31,6 +34,7 @@ impl CommandChildGuard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn pid(&self) -> Option<u32> {
|
||||
self.0.as_ref().map(|c| c.pid())
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::{logging, utils::logging::Type};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::{os::windows::process::CommandExt, path::Path, path::PathBuf};
|
||||
use std::{os::windows::process::CommandExt as _, path::Path, path::PathBuf};
|
||||
|
||||
/// Windows 下的开机启动文件夹路径
|
||||
#[cfg(target_os = "windows")]
|
||||
@ -37,7 +37,7 @@ pub fn get_exe_path() -> Result<PathBuf> {
|
||||
/// 创建快捷方式
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn create_shortcut() -> Result<()> {
|
||||
use crate::utils::dirs::PathBufExec;
|
||||
use crate::utils::dirs::PathBufExec as _;
|
||||
|
||||
let exe_path = get_exe_path()?;
|
||||
let startup_dir = get_startup_dir()?;
|
||||
@ -90,7 +90,7 @@ pub async fn create_shortcut() -> Result<()> {
|
||||
/// 删除快捷方式
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn remove_shortcut() -> Result<()> {
|
||||
use crate::utils::dirs::PathBufExec;
|
||||
use crate::utils::dirs::PathBufExec as _;
|
||||
|
||||
let startup_dir = get_startup_dir()?;
|
||||
let old_shortcut_path = startup_dir.join("Clash-Verge.lnk");
|
||||
|
||||
@ -9,7 +9,7 @@ use once_cell::sync::OnceCell;
|
||||
#[cfg(unix)]
|
||||
use std::iter;
|
||||
use std::{fs, path::PathBuf};
|
||||
use tauri::Manager;
|
||||
use tauri::Manager as _;
|
||||
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
pub static APP_ID: &str = "io.github.clash-verge-rev.clash-verge-rev";
|
||||
@ -103,31 +103,25 @@ pub fn app_icons_dir() -> Result<PathBuf> {
|
||||
|
||||
pub fn find_target_icons(target: &str) -> Result<Option<String>> {
|
||||
let icons_dir = app_icons_dir()?;
|
||||
let mut matching_files = Vec::new();
|
||||
let icon_path = fs::read_dir(&icons_dir)?
|
||||
.filter_map(|entry| entry.ok().map(|e| e.path()))
|
||||
.find(|path| {
|
||||
let prefix_matches = path
|
||||
.file_prefix()
|
||||
.and_then(|p| p.to_str())
|
||||
.is_some_and(|prefix| prefix.starts_with(target));
|
||||
let ext_matches = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| {
|
||||
ext.eq_ignore_ascii_case("ico") || ext.eq_ignore_ascii_case("png")
|
||||
});
|
||||
prefix_matches && ext_matches
|
||||
});
|
||||
|
||||
for entry in fs::read_dir(icons_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
|
||||
&& file_name.starts_with(target)
|
||||
&& (file_name.ends_with(".ico") || file_name.ends_with(".png"))
|
||||
{
|
||||
matching_files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if matching_files.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
match matching_files.first() {
|
||||
Some(first_path) => {
|
||||
let first = path_to_str(first_path)?;
|
||||
Ok(Some(first.into()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
icon_path
|
||||
.map(|path| path_to_str(&path).map(|s| s.into()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// logs dir
|
||||
|
||||
@ -12,12 +12,14 @@ pub struct Draft<T: Clone> {
|
||||
}
|
||||
|
||||
impl<T: Clone> Draft<T> {
|
||||
#[inline]
|
||||
pub fn new(data: T) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new((Arc::new(Box::new(data)), None))),
|
||||
}
|
||||
}
|
||||
/// 以 Arc<Box<T>> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc)
|
||||
#[inline]
|
||||
pub fn data_arc(&self) -> SharedBox<T> {
|
||||
let guard = self.inner.read();
|
||||
Arc::clone(&guard.0)
|
||||
@ -25,18 +27,16 @@ impl<T: Clone> Draft<T> {
|
||||
|
||||
/// 获取当前(草稿若存在则返回草稿,否则返回已提交)的快照
|
||||
/// 这也是零拷贝:只 clone Arc,不 clone T
|
||||
#[inline]
|
||||
pub fn latest_arc(&self) -> SharedBox<T> {
|
||||
let guard = self.inner.read();
|
||||
guard
|
||||
.1
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::clone(&guard.0))
|
||||
guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0))
|
||||
}
|
||||
|
||||
/// 通过闭包以可变方式编辑草稿(在闭包中我们给出 &mut T)
|
||||
/// - 延迟拷贝:如果只有这一个 Arc 引用,则直接修改,不会克隆 T;
|
||||
/// - 若草稿被其他读者共享,Arc::make_mut 会做一次 T.clone(最小必要拷贝)。
|
||||
#[inline]
|
||||
pub fn edit_draft<F, R>(&self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut T) -> R,
|
||||
@ -60,6 +60,7 @@ impl<T: Clone> Draft<T> {
|
||||
}
|
||||
|
||||
/// 将草稿提交到已提交位置(替换),并清除草稿
|
||||
#[inline]
|
||||
pub fn apply(&self) {
|
||||
let mut guard = self.inner.write();
|
||||
if let Some(d) = guard.1.take() {
|
||||
@ -68,6 +69,7 @@ impl<T: Clone> Draft<T> {
|
||||
}
|
||||
|
||||
/// 丢弃草稿(如果存在)
|
||||
#[inline]
|
||||
pub fn discard(&self) {
|
||||
let mut guard = self.inner.write();
|
||||
guard.1 = None;
|
||||
@ -75,6 +77,7 @@ impl<T: Clone> Draft<T> {
|
||||
|
||||
/// 异步地以拥有 Box<T> 的方式修改已提交数据:将克隆一次已提交数据到本地,
|
||||
/// 异步闭包返回新的 Box<T>(替换已提交数据)和业务返回值 R。
|
||||
#[inline]
|
||||
pub async fn with_data_modify<F, Fut, R>(&self, f: F) -> Result<R, anyhow::Error>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::{config::with_encryption, enhance::seq::SeqMap, logging, utils::logging::Type};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use nanoid::nanoid;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_yaml_ng::Mapping;
|
||||
|
||||
@ -2,26 +2,26 @@
|
||||
#[cfg(not(feature = "tauri-dev"))]
|
||||
use crate::utils::logging::NoModuleFilter;
|
||||
use crate::{
|
||||
config::*,
|
||||
config::{Config, IClashTemp, IProfiles, IVerge},
|
||||
constants,
|
||||
core::handle,
|
||||
logging,
|
||||
process::AsyncHandler,
|
||||
utils::{
|
||||
dirs::{self, PathBufExec, service_log_dir, sidecar_log_dir},
|
||||
dirs::{self, PathBufExec as _, service_log_dir, sidecar_log_dir},
|
||||
help,
|
||||
logging::Type,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::{Local, TimeZone};
|
||||
use chrono::{Local, TimeZone as _};
|
||||
use clash_verge_service_ipc::WriterConfig;
|
||||
use flexi_logger::writers::FileLogWriter;
|
||||
use flexi_logger::{Cleanup, Criterion, FileSpec};
|
||||
#[cfg(not(feature = "tauri-dev"))]
|
||||
use flexi_logger::{Duplicate, LogSpecBuilder, Logger};
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use std::{path::PathBuf, str::FromStr as _};
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
use tokio::fs;
|
||||
use tokio::fs::DirEntry;
|
||||
|
||||
@ -31,7 +31,7 @@ pub async fn init_logger() -> Result<()> {
|
||||
// TODO 提供 runtime 级别实时修改
|
||||
let (log_level, log_max_size, log_max_count) = {
|
||||
let verge_guard = Config::verge().await;
|
||||
let verge = verge_guard.latest_arc();
|
||||
let verge = verge_guard.data_arc();
|
||||
(
|
||||
verge.get_log_level(),
|
||||
verge.app_log_max_size.unwrap_or(128),
|
||||
@ -89,7 +89,7 @@ pub async fn init_logger() -> Result<()> {
|
||||
pub async fn sidecar_writer() -> Result<FileLogWriter> {
|
||||
let (log_max_size, log_max_count) = {
|
||||
let verge_guard = Config::verge().await;
|
||||
let verge = verge_guard.latest_arc();
|
||||
let verge = verge_guard.data_arc();
|
||||
(
|
||||
verge.app_log_max_size.unwrap_or(128),
|
||||
verge.app_log_max_count.unwrap_or(8),
|
||||
@ -117,7 +117,7 @@ pub async fn sidecar_writer() -> Result<FileLogWriter> {
|
||||
pub async fn service_writer_config() -> Result<WriterConfig> {
|
||||
let (log_max_size, log_max_count) = {
|
||||
let verge_guard = Config::verge().await;
|
||||
let verge = verge_guard.latest_arc();
|
||||
let verge = verge_guard.data_arc();
|
||||
(
|
||||
verge.app_log_max_size.unwrap_or(128),
|
||||
verge.app_log_max_count.unwrap_or(8),
|
||||
@ -142,7 +142,7 @@ pub async fn delete_log() -> Result<()> {
|
||||
|
||||
let auto_log_clean = {
|
||||
let verge = Config::verge().await;
|
||||
let verge = verge.latest_arc();
|
||||
let verge = verge.data_arc();
|
||||
verge.auto_log_clean.unwrap_or(0)
|
||||
};
|
||||
|
||||
@ -466,7 +466,7 @@ pub async fn startup_script() -> Result<()> {
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
let script_path = {
|
||||
let verge = Config::verge().await;
|
||||
let verge = verge.latest_arc();
|
||||
let verge = verge.data_arc();
|
||||
verge.startup_script.clone().unwrap_or_else(|| "".into())
|
||||
};
|
||||
|
||||
|
||||
@ -262,7 +262,9 @@ impl DmabufDecision {
|
||||
if intel_gpu.inconclusive {
|
||||
decision.message = Some("Wayland 上检测到 Intel GPU,但缺少 boot_vga 信息:预防性禁用 WebKit DMABUF,若确认非主 GPU 可通过 CLASH_VERGE_DMABUF=1 覆盖。".into());
|
||||
} else {
|
||||
decision.message = Some("Wayland 上检测到 Intel 主 GPU (0x8086):禁用 WebKit DMABUF 以避免帧缓冲失败。".into());
|
||||
decision.message = Some(
|
||||
"Wayland 上检测到 Intel 主 GPU (0x8086):禁用 WebKit DMABUF 以避免帧缓冲失败。".into(),
|
||||
);
|
||||
}
|
||||
} else if session.is_wayland {
|
||||
decision.message = Some(
|
||||
|
||||
@ -3,7 +3,7 @@ use flexi_logger::DeferredNow;
|
||||
#[cfg(not(feature = "tauri-dev"))]
|
||||
use flexi_logger::filter::LogLineFilter;
|
||||
use flexi_logger::writers::FileLogWriter;
|
||||
use flexi_logger::writers::LogWriter;
|
||||
use flexi_logger::writers::LogWriter as _;
|
||||
use log::Level;
|
||||
use log::Record;
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
@ -159,10 +159,10 @@ impl NetworkManager {
|
||||
ProxyType::None => None,
|
||||
ProxyType::Localhost => {
|
||||
let port = {
|
||||
let verge_port = Config::verge().await.latest_arc().verge_mixed_port;
|
||||
let verge_port = Config::verge().await.data_arc().verge_mixed_port;
|
||||
match verge_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.latest_arc().get_mixed_port(),
|
||||
None => Config::clash().await.data_arc().get_mixed_port(),
|
||||
}
|
||||
};
|
||||
let proxy_scheme = format!("http://127.0.0.1:{port}");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::{core::handle, utils::i18n};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_notification::NotificationExt as _;
|
||||
|
||||
pub enum NotificationEvent<'a> {
|
||||
DashboardToggled,
|
||||
|
||||
@ -3,7 +3,7 @@ use crate::{logging, utils::logging::Type};
|
||||
pub async fn set_public_dns(dns_server: String) {
|
||||
use crate::utils::logging::Type;
|
||||
use crate::{core::handle, logging, utils::dirs};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
|
||||
logging!(info, Type::Config, "try to set system dns");
|
||||
@ -50,7 +50,7 @@ pub async fn set_public_dns(dns_server: String) {
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn restore_public_dns() {
|
||||
use crate::{core::handle, utils::dirs};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
logging!(info, Type::Config, "try to unset system dns");
|
||||
let resource_dir = match dirs::app_resources_dir() {
|
||||
|
||||
@ -10,7 +10,7 @@ use crate::{
|
||||
tray::Tray,
|
||||
},
|
||||
logging, logging_error,
|
||||
module::{lightweight::auto_lightweight_boot, signal},
|
||||
module::{auto_backup::AutoBackupManager, lightweight::auto_lightweight_boot, signal},
|
||||
process::AsyncHandler,
|
||||
utils::{init, logging::Type, server, window_manager::WindowManager},
|
||||
};
|
||||
@ -67,6 +67,7 @@ pub fn resolve_setup_async() {
|
||||
init_timer(),
|
||||
init_hotkey(),
|
||||
init_auto_lightweight_boot(),
|
||||
init_auto_backup(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -122,6 +123,10 @@ pub(super) async fn init_auto_lightweight_boot() {
|
||||
logging_error!(Type::Setup, auto_lightweight_boot().await);
|
||||
}
|
||||
|
||||
pub(super) async fn init_auto_backup() {
|
||||
logging_error!(Type::Setup, AutoBackupManager::global().init().await);
|
||||
}
|
||||
|
||||
pub(super) fn init_signal() {
|
||||
logging!(info, Type::Setup, "Initializing signal handlers...");
|
||||
signal::register();
|
||||
@ -174,7 +179,7 @@ pub(super) async fn refresh_tray_menu() {
|
||||
pub(super) async fn init_window() {
|
||||
let is_silent_start = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.enable_silent_start
|
||||
.unwrap_or(false);
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@ -1,50 +1,37 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::{logging, utils::logging::Type};
|
||||
|
||||
// 使用 AtomicBool 替代 RwLock<bool>,性能更好且无锁
|
||||
static UI_READY: OnceCell<AtomicBool> = OnceCell::new();
|
||||
// 获取 UI 是否准备就绪的全局状态
|
||||
static UI_READY: AtomicBool = AtomicBool::new(false);
|
||||
// 获取UI就绪状态细节
|
||||
static UI_READY_STATE: OnceCell<UiReadyState> = OnceCell::new();
|
||||
static UI_READY_STATE: AtomicU8 = AtomicU8::new(0);
|
||||
// 添加通知机制,用于事件驱动的 UI 就绪检测
|
||||
static UI_READY_NOTIFY: OnceCell<Arc<Notify>> = OnceCell::new();
|
||||
|
||||
// UI就绪阶段状态枚举
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum UiReadyStage {
|
||||
NotStarted,
|
||||
NotStarted = 0,
|
||||
Loading,
|
||||
DomReady,
|
||||
ResourcesLoaded,
|
||||
Ready,
|
||||
}
|
||||
|
||||
// UI就绪详细状态
|
||||
#[derive(Debug)]
|
||||
struct UiReadyState {
|
||||
stage: RwLock<UiReadyStage>,
|
||||
pub fn get_ui_ready() -> &'static AtomicBool {
|
||||
&UI_READY
|
||||
}
|
||||
|
||||
impl Default for UiReadyState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stage: RwLock::new(UiReadyStage::NotStarted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_ui_ready() -> &'static AtomicBool {
|
||||
UI_READY.get_or_init(|| AtomicBool::new(false))
|
||||
}
|
||||
|
||||
fn get_ui_ready_state() -> &'static UiReadyState {
|
||||
UI_READY_STATE.get_or_init(UiReadyState::default)
|
||||
fn get_ui_ready_state() -> &'static AtomicU8 {
|
||||
&UI_READY_STATE
|
||||
}
|
||||
|
||||
fn get_ui_ready_notify() -> &'static Arc<Notify> {
|
||||
@ -53,8 +40,7 @@ fn get_ui_ready_notify() -> &'static Arc<Notify> {
|
||||
|
||||
// 更新UI准备阶段
|
||||
pub fn update_ui_ready_stage(stage: UiReadyStage) {
|
||||
let state = get_ui_ready_state();
|
||||
*state.stage.write() = stage;
|
||||
get_ui_ready_state().store(stage as u8, Ordering::Release);
|
||||
// 如果是最终阶段,标记UI完全就绪
|
||||
if stage == UiReadyStage::Ready {
|
||||
mark_ui_ready();
|
||||
|
||||
@ -7,7 +7,7 @@ use crate::{
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
use warp::Filter;
|
||||
use warp::Filter as _;
|
||||
|
||||
// 关闭 embedded server 的信号发送端
|
||||
static SHUTDOWN_SENDER: OnceCell<Mutex<Option<oneshot::Sender<()>>>> = OnceCell::new();
|
||||
@ -26,15 +26,15 @@ pub fn embed_server() {
|
||||
let clash_config = Config::clash().await;
|
||||
|
||||
let pac_content = verge_config
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.pac_file_content
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_PAC.into());
|
||||
|
||||
let pac_port = verge_config
|
||||
.latest_arc()
|
||||
.data_arc()
|
||||
.verge_mixed_port
|
||||
.unwrap_or_else(|| clash_config.latest_arc().get_mixed_port());
|
||||
.unwrap_or_else(|| clash_config.data_arc().get_mixed_port());
|
||||
|
||||
let pac = warp::path!("commands" / "pac").map(move || {
|
||||
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::{
|
||||
};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use tauri::{Manager, WebviewWindow, Wry};
|
||||
use tauri::{Manager as _, WebviewWindow, Wry};
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
191
src/components/connection/connection-column-manager.tsx
Normal file
191
src/components/connection/connection-column-manager.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { DragIndicatorRounded } from "@mui/icons-material";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ColumnOption {
|
||||
field: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
columns: ColumnOption[];
|
||||
onClose: () => void;
|
||||
onToggle: (field: string, visible: boolean) => void;
|
||||
onOrderChange: (order: string[]) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const ConnectionColumnManager = ({
|
||||
open,
|
||||
columns,
|
||||
onClose,
|
||||
onToggle,
|
||||
onOrderChange,
|
||||
onReset,
|
||||
}: Props) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = useMemo(() => columns.map((column) => column.field), [columns]);
|
||||
const visibleCount = useMemo(
|
||||
() => columns.filter((column) => column.visible).length,
|
||||
[columns],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const order = columns.map((column) => column.field);
|
||||
const oldIndex = order.indexOf(active.id as string);
|
||||
const newIndex = order.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
onOrderChange(arrayMove(order, oldIndex, newIndex));
|
||||
},
|
||||
[columns, onOrderChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>
|
||||
{t("connections.components.columnManager.title")}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items}>
|
||||
<List
|
||||
dense
|
||||
disablePadding
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<SortableColumnItem
|
||||
key={column.field}
|
||||
column={column}
|
||||
onToggle={onToggle}
|
||||
dragHandleLabel={t(
|
||||
"connections.components.columnManager.dragHandle",
|
||||
)}
|
||||
disableToggle={column.visible && visibleCount <= 1}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button variant="text" onClick={onReset}>
|
||||
{t("shared.actions.resetToDefault")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onClose}>
|
||||
{t("shared.actions.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableColumnItemProps {
|
||||
column: ColumnOption;
|
||||
onToggle: (field: string, visible: boolean) => void;
|
||||
dragHandleLabel: string;
|
||||
disableToggle?: boolean;
|
||||
}
|
||||
|
||||
const SortableColumnItem = ({
|
||||
column,
|
||||
onToggle,
|
||||
dragHandleLabel,
|
||||
disableToggle = false,
|
||||
}: SortableColumnItemProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: column.field });
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}),
|
||||
[transform, transition],
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={setNodeRef}
|
||||
disableGutters
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: isDragging ? "action.hover" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={column.visible}
|
||||
disabled={disableToggle}
|
||||
onChange={(event) => onToggle(column.field, event.target.checked)}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={column.label}
|
||||
slotProps={{ primary: { variant: "body2" } }}
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
sx={{ cursor: isDragging ? "grabbing" : "grab" }}
|
||||
aria-label={dragHandleLabel}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DragIndicatorRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@ -1,24 +1,63 @@
|
||||
import { Box } from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridColumnOrderChangeParams,
|
||||
GridColumnResizeParams,
|
||||
GridColumnVisibilityModel,
|
||||
useGridApiRef,
|
||||
GridColumnMenuItemProps,
|
||||
GridColumnMenuHideItem,
|
||||
useGridRootProps,
|
||||
} from "@mui/x-data-grid";
|
||||
import dayjs from "dayjs";
|
||||
import { useLocalStorage } from "foxact/use-local-storage";
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
createContext,
|
||||
use,
|
||||
} from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { truncateStr } from "@/utils/truncate-str";
|
||||
|
||||
import { ConnectionColumnManager } from "./connection-column-manager";
|
||||
|
||||
const ColumnManagerContext = createContext<() => void>(() => {});
|
||||
|
||||
/**
|
||||
* Reconcile stored column order with base columns to handle added/removed fields
|
||||
*/
|
||||
const reconcileColumnOrder = (
|
||||
storedOrder: string[],
|
||||
baseFields: string[],
|
||||
): string[] => {
|
||||
const filtered = storedOrder.filter((field) => baseFields.includes(field));
|
||||
const missing = baseFields.filter((field) => !filtered.includes(field));
|
||||
return [...filtered, ...missing];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
connections: IConnectionsItem[];
|
||||
onShowDetail: (data: IConnectionsItem) => void;
|
||||
columnManagerOpen: boolean;
|
||||
onOpenColumnManager: () => void;
|
||||
onCloseColumnManager: () => void;
|
||||
}
|
||||
|
||||
export const ConnectionTable = (props: Props) => {
|
||||
const { connections, onShowDetail } = props;
|
||||
const {
|
||||
connections,
|
||||
onShowDetail,
|
||||
columnManagerOpen,
|
||||
onOpenColumnManager,
|
||||
onCloseColumnManager,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const apiRef = useGridApiRef();
|
||||
useLayoutEffect(() => {
|
||||
@ -145,10 +184,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
};
|
||||
}, [apiRef]);
|
||||
|
||||
const [columnVisible, setColumnVisible] = useState<
|
||||
Partial<Record<keyof IConnectionsItem, boolean>>
|
||||
>({});
|
||||
|
||||
const [columnWidths, setColumnWidths] = useLocalStorage<
|
||||
Record<string, number>
|
||||
>(
|
||||
@ -158,7 +193,43 @@ export const ConnectionTable = (props: Props) => {
|
||||
{},
|
||||
);
|
||||
|
||||
const columns = useMemo<GridColDef[]>(() => {
|
||||
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage<
|
||||
Partial<Record<string, boolean>>
|
||||
>(
|
||||
"connection-table-visibility",
|
||||
{},
|
||||
{
|
||||
serializer: JSON.stringify,
|
||||
deserializer: (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse connection-table-visibility", err);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
||||
"connection-table-order",
|
||||
[],
|
||||
{
|
||||
serializer: JSON.stringify,
|
||||
deserializer: (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse connection-table-order", err);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const baseColumns = useMemo<GridColDef[]>(() => {
|
||||
return [
|
||||
{
|
||||
field: "host",
|
||||
@ -248,6 +319,49 @@ export const ConnectionTable = (props: Props) => {
|
||||
];
|
||||
}, [columnWidths, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder((prevValue) => {
|
||||
const baseFields = baseColumns.map((col) => col.field);
|
||||
const prev = Array.isArray(prevValue) ? prevValue : [];
|
||||
const reconciled = reconcileColumnOrder(prev, baseFields);
|
||||
if (
|
||||
reconciled.length === prev.length &&
|
||||
reconciled.every((field, i) => field === prev[i])
|
||||
) {
|
||||
return prevValue;
|
||||
}
|
||||
return reconciled;
|
||||
});
|
||||
}, [baseColumns, setColumnOrder]);
|
||||
|
||||
const columns = useMemo<GridColDef[]>(() => {
|
||||
const order = Array.isArray(columnOrder) ? columnOrder : [];
|
||||
const orderMap = new Map(order.map((field, index) => [field, index]));
|
||||
|
||||
return [...baseColumns].sort((a, b) => {
|
||||
const aIndex = orderMap.has(a.field)
|
||||
? (orderMap.get(a.field) as number)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bIndex = orderMap.has(b.field)
|
||||
? (orderMap.get(b.field) as number)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aIndex === bIndex) {
|
||||
return order.indexOf(a.field) - order.indexOf(b.field);
|
||||
}
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}, [baseColumns, columnOrder]);
|
||||
|
||||
const visibleColumnsCount = useMemo(() => {
|
||||
return columns.reduce((count, column) => {
|
||||
return (columnVisibilityModel?.[column.field] ?? true) !== false
|
||||
? count + 1
|
||||
: count;
|
||||
}, 0);
|
||||
}, [columns, columnVisibilityModel]);
|
||||
|
||||
const handleColumnResize = (params: GridColumnResizeParams) => {
|
||||
const { colDef, width } = params;
|
||||
setColumnWidths((prev) => ({
|
||||
@ -256,6 +370,111 @@ export const ConnectionTable = (props: Props) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleColumnVisibilityChange = useCallback(
|
||||
(model: GridColumnVisibilityModel) => {
|
||||
const hiddenFields = new Set<string>();
|
||||
Object.entries(model).forEach(([field, value]) => {
|
||||
if (value === false) {
|
||||
hiddenFields.add(field);
|
||||
}
|
||||
});
|
||||
|
||||
const nextVisibleCount = columns.reduce((count, column) => {
|
||||
return hiddenFields.has(column.field) ? count : count + 1;
|
||||
}, 0);
|
||||
|
||||
if (nextVisibleCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setColumnVisibilityModel(() => {
|
||||
const sanitized: Partial<Record<string, boolean>> = {};
|
||||
hiddenFields.forEach((field) => {
|
||||
sanitized[field] = false;
|
||||
});
|
||||
return sanitized;
|
||||
});
|
||||
},
|
||||
[columns, setColumnVisibilityModel],
|
||||
);
|
||||
|
||||
const handleToggleColumn = useCallback(
|
||||
(field: string, visible: boolean) => {
|
||||
if (!visible && visibleColumnsCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setColumnVisibilityModel((prev) => {
|
||||
const next = { ...(prev ?? {}) };
|
||||
if (visible) {
|
||||
delete next[field];
|
||||
} else {
|
||||
next[field] = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setColumnVisibilityModel, visibleColumnsCount],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(params: GridColumnOrderChangeParams) => {
|
||||
setColumnOrder((prevValue) => {
|
||||
const baseFields = baseColumns.map((col) => col.field);
|
||||
const currentOrder = Array.isArray(prevValue)
|
||||
? [...prevValue]
|
||||
: [...baseFields];
|
||||
const field = params.column.field;
|
||||
const currentIndex = currentOrder.indexOf(field);
|
||||
if (currentIndex === -1) return currentOrder;
|
||||
|
||||
currentOrder.splice(currentIndex, 1);
|
||||
const targetIndex = Math.min(
|
||||
Math.max(params.targetIndex, 0),
|
||||
currentOrder.length,
|
||||
);
|
||||
currentOrder.splice(targetIndex, 0, field);
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
},
|
||||
[baseColumns, setColumnOrder],
|
||||
);
|
||||
|
||||
const handleManagerOrderChange = useCallback(
|
||||
(order: string[]) => {
|
||||
setColumnOrder(() => {
|
||||
const baseFields = baseColumns.map((col) => col.field);
|
||||
return reconcileColumnOrder(order, baseFields);
|
||||
});
|
||||
},
|
||||
[baseColumns, setColumnOrder],
|
||||
);
|
||||
|
||||
const handleResetColumns = useCallback(() => {
|
||||
setColumnVisibilityModel({});
|
||||
setColumnOrder(baseColumns.map((col) => col.field));
|
||||
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
|
||||
|
||||
const gridVisibilityModel = useMemo(() => {
|
||||
const result: GridColumnVisibilityModel = {};
|
||||
if (!columnVisibilityModel) return result;
|
||||
Object.entries(columnVisibilityModel).forEach(([field, value]) => {
|
||||
if (typeof value === "boolean") {
|
||||
result[field] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [columnVisibilityModel]);
|
||||
|
||||
const columnOptions = useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
field: column.field,
|
||||
label: column.headerName ?? column.field,
|
||||
visible: (columnVisibilityModel?.[column.field] ?? true) !== false,
|
||||
}));
|
||||
}, [columns, columnVisibilityModel]);
|
||||
|
||||
const connRows = useMemo(() => {
|
||||
return connections.map((each) => {
|
||||
const { metadata, rulePayload } = each;
|
||||
@ -286,24 +505,98 @@ export const ConnectionTable = (props: Props) => {
|
||||
}, [connections]);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
apiRef={apiRef}
|
||||
hideFooter
|
||||
rows={connRows}
|
||||
columns={columns}
|
||||
onRowClick={(e) => onShowDetail(e.row.connectionData)}
|
||||
density="compact"
|
||||
sx={{
|
||||
border: "none",
|
||||
"div:focus": { outline: "none !important" },
|
||||
"& .MuiDataGrid-columnHeader": {
|
||||
userSelect: "none",
|
||||
},
|
||||
}}
|
||||
columnVisibilityModel={columnVisible}
|
||||
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
|
||||
onColumnResize={handleColumnResize}
|
||||
disableColumnMenu={false}
|
||||
/>
|
||||
<ColumnManagerContext value={onOpenColumnManager}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
apiRef={apiRef}
|
||||
hideFooter
|
||||
rows={connRows}
|
||||
columns={columns}
|
||||
onRowClick={(e) => onShowDetail(e.row.connectionData)}
|
||||
density="compact"
|
||||
sx={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
minHeight: 0,
|
||||
"div:focus": { outline: "none !important" },
|
||||
"& .MuiDataGrid-columnHeader": {
|
||||
userSelect: "none",
|
||||
},
|
||||
}}
|
||||
columnVisibilityModel={gridVisibilityModel}
|
||||
onColumnVisibilityModelChange={handleColumnVisibilityChange}
|
||||
onColumnResize={handleColumnResize}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
slotProps={{
|
||||
columnMenu: {
|
||||
slots: {
|
||||
columnMenuColumnsItem: ConnectionColumnMenuColumnsItem,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ConnectionColumnManager
|
||||
open={columnManagerOpen}
|
||||
columns={columnOptions}
|
||||
onClose={onCloseColumnManager}
|
||||
onToggle={handleToggleColumn}
|
||||
onOrderChange={handleManagerOrderChange}
|
||||
onReset={handleResetColumns}
|
||||
/>
|
||||
</ColumnManagerContext>
|
||||
);
|
||||
};
|
||||
|
||||
type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & {
|
||||
onOpenColumnManager: () => void;
|
||||
};
|
||||
|
||||
const ConnectionColumnMenuManageItem = (
|
||||
props: ConnectionColumnMenuManageItemProps,
|
||||
) => {
|
||||
const { onClick, onOpenColumnManager } = props;
|
||||
const rootProps = useGridRootProps();
|
||||
const { t } = useTranslation();
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
onClick(event);
|
||||
onOpenColumnManager();
|
||||
},
|
||||
[onClick, onOpenColumnManager],
|
||||
);
|
||||
|
||||
if (rootProps.disableColumnSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MenuItem = rootProps.slots.baseMenuItem;
|
||||
const Icon = rootProps.slots.columnMenuManageColumnsIcon;
|
||||
|
||||
return (
|
||||
<MenuItem onClick={handleClick} iconStart={<Icon fontSize="small" />}>
|
||||
{t("connections.components.columnManager.title")}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => {
|
||||
const onOpenColumnManager = use(ColumnManagerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridColumnMenuHideItem {...props} />
|
||||
<ConnectionColumnMenuManageItem
|
||||
{...props}
|
||||
onOpenColumnManager={onOpenColumnManager}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -77,6 +77,17 @@ const GRAPH_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
const MIN_FPS = 8;
|
||||
const MAX_FPS = 20;
|
||||
const FPS_ADJUST_INTERVAL = 3000; // ms
|
||||
const FPS_SAMPLE_WINDOW = 12;
|
||||
const STALE_DATA_THRESHOLD = 2500; // ms without fresh data => drop FPS
|
||||
const RESUME_FPS_TARGET = 12;
|
||||
const RESUME_COOLDOWN_MS = 2000;
|
||||
|
||||
const getNow = () =>
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
|
||||
interface EnhancedCanvasTrafficGraphProps {
|
||||
ref?: Ref<EnhancedCanvasTrafficGraphRef>;
|
||||
}
|
||||
@ -105,6 +116,10 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(10);
|
||||
const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier");
|
||||
|
||||
const initialFocusState =
|
||||
typeof document !== "undefined" ? !document.hidden : true;
|
||||
const [isWindowFocused, setIsWindowFocused] = useState(initialFocusState);
|
||||
|
||||
// 悬浮提示状态
|
||||
const [tooltipData, setTooltipData] = useState<TooltipData>({
|
||||
x: 0,
|
||||
@ -122,6 +137,18 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
const lastRenderTimeRef = useRef<number>(0);
|
||||
const isInitializedRef = useRef<boolean>(false);
|
||||
const isWindowFocusedRef = useRef<boolean>(initialFocusState);
|
||||
const fpsControllerRef = useRef<{
|
||||
target: number;
|
||||
samples: number[];
|
||||
lastAdjustTime: number;
|
||||
}>({
|
||||
target: GRAPH_CONFIG.targetFPS,
|
||||
samples: [],
|
||||
lastAdjustTime: 0,
|
||||
});
|
||||
const lastDataTimestampRef = useRef<number>(0);
|
||||
const resumeCooldownRef = useRef<number>(0);
|
||||
|
||||
// 当前显示的数据缓存
|
||||
const [displayData, dispatchDisplayData] = useReducer(
|
||||
@ -129,6 +156,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
[],
|
||||
);
|
||||
const debounceTimeoutRef = useRef<number | null>(null);
|
||||
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS);
|
||||
|
||||
// 主题颜色配置
|
||||
const colors = useMemo(
|
||||
@ -165,6 +193,74 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
};
|
||||
}, [dataPoints, timeRange, getDataForTimeRange, updateDisplayData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayData.length === 0) {
|
||||
lastDataTimestampRef.current = 0;
|
||||
fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS;
|
||||
fpsControllerRef.current.samples = [];
|
||||
fpsControllerRef.current.lastAdjustTime = 0;
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
||||
setCurrentFPS(GRAPH_CONFIG.targetFPS);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestTimestamp =
|
||||
displayData[displayData.length - 1]?.timestamp ?? null;
|
||||
if (latestTimestamp) {
|
||||
lastDataTimestampRef.current = latestTimestamp;
|
||||
}
|
||||
}, [displayData]);
|
||||
|
||||
const handleFocusStateChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
isWindowFocusedRef.current = focused;
|
||||
setIsWindowFocused(focused);
|
||||
|
||||
const highResNow = getNow();
|
||||
lastRenderTimeRef.current = highResNow;
|
||||
|
||||
if (focused) {
|
||||
resumeCooldownRef.current = Date.now();
|
||||
const controller = fpsControllerRef.current;
|
||||
const resumeTarget = Math.max(
|
||||
MIN_FPS,
|
||||
Math.min(controller.target, RESUME_FPS_TARGET),
|
||||
);
|
||||
controller.target = resumeTarget;
|
||||
controller.samples = [];
|
||||
controller.lastAdjustTime = 0;
|
||||
setCurrentFPS(resumeTarget);
|
||||
} else {
|
||||
resumeCooldownRef.current = 0;
|
||||
}
|
||||
},
|
||||
[setIsWindowFocused, setCurrentFPS],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFocus = () => handleFocusStateChange(true);
|
||||
const handleBlur = () => handleFocusStateChange(false);
|
||||
const handleVisibilityChange = () =>
|
||||
handleFocusStateChange(!document.hidden);
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
};
|
||||
}, [handleFocusStateChange]);
|
||||
|
||||
// Y轴坐标计算 - 基于刻度范围的线性映射
|
||||
const calculateY = useCallback(
|
||||
(value: number, height: number, data: ITrafficDataPoint[]): number => {
|
||||
@ -792,31 +888,135 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
tooltipData,
|
||||
]);
|
||||
|
||||
const collectFrameSample = useCallback(
|
||||
(renderDuration: number, frameBudget: number) => {
|
||||
const controller = fpsControllerRef.current;
|
||||
controller.samples.push(renderDuration);
|
||||
if (controller.samples.length > FPS_SAMPLE_WINDOW) {
|
||||
controller.samples.shift();
|
||||
}
|
||||
|
||||
const perfNow = getNow();
|
||||
const lastDataAge =
|
||||
lastDataTimestampRef.current > 0
|
||||
? Date.now() - lastDataTimestampRef.current
|
||||
: null;
|
||||
const isDataStale =
|
||||
typeof lastDataAge === "number" && lastDataAge > STALE_DATA_THRESHOLD;
|
||||
|
||||
let inResumeCooldown = false;
|
||||
if (resumeCooldownRef.current) {
|
||||
const elapsedSinceResume = Date.now() - resumeCooldownRef.current;
|
||||
if (elapsedSinceResume < RESUME_COOLDOWN_MS) {
|
||||
inResumeCooldown = true;
|
||||
} else {
|
||||
resumeCooldownRef.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDataStale && controller.target !== MIN_FPS) {
|
||||
controller.target = MIN_FPS;
|
||||
controller.samples = [];
|
||||
controller.lastAdjustTime = perfNow;
|
||||
setCurrentFPS(controller.target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDataStale &&
|
||||
!inResumeCooldown &&
|
||||
controller.target < GRAPH_CONFIG.targetFPS
|
||||
) {
|
||||
controller.target = Math.min(
|
||||
GRAPH_CONFIG.targetFPS,
|
||||
controller.target + 2,
|
||||
);
|
||||
controller.samples = [];
|
||||
controller.lastAdjustTime = perfNow;
|
||||
setCurrentFPS(controller.target);
|
||||
}
|
||||
|
||||
if (
|
||||
controller.lastAdjustTime !== 0 &&
|
||||
perfNow - controller.lastAdjustTime < FPS_ADJUST_INTERVAL
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.samples.length === 0) return;
|
||||
|
||||
const avgRender =
|
||||
controller.samples.reduce((sum, value) => sum + value, 0) /
|
||||
controller.samples.length;
|
||||
|
||||
let nextTarget = controller.target;
|
||||
|
||||
if (avgRender > frameBudget * 0.75 && controller.target > MIN_FPS) {
|
||||
nextTarget = Math.max(MIN_FPS, controller.target - 2);
|
||||
} else if (
|
||||
avgRender < Math.max(4, frameBudget * 0.4) &&
|
||||
controller.target < MAX_FPS &&
|
||||
!inResumeCooldown
|
||||
) {
|
||||
nextTarget = Math.min(MAX_FPS, controller.target + 2);
|
||||
}
|
||||
|
||||
controller.samples = [];
|
||||
controller.lastAdjustTime = perfNow;
|
||||
|
||||
if (nextTarget !== controller.target) {
|
||||
controller.target = nextTarget;
|
||||
setCurrentFPS(nextTarget);
|
||||
}
|
||||
},
|
||||
[setCurrentFPS],
|
||||
);
|
||||
|
||||
// 受控的动画循环
|
||||
useEffect(() => {
|
||||
const animate = (currentTime: number) => {
|
||||
// 控制帧率,减少不必要的重绘
|
||||
if (
|
||||
currentTime - lastRenderTimeRef.current >=
|
||||
1000 / GRAPH_CONFIG.targetFPS
|
||||
) {
|
||||
drawGraph();
|
||||
lastRenderTimeRef.current = currentTime;
|
||||
if (!isWindowFocused || displayData.length === 0) {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
lastRenderTimeRef.current = getNow();
|
||||
return;
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!isWindowFocusedRef.current) {
|
||||
lastRenderTimeRef.current = getNow();
|
||||
animationFrameRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFPS = fpsControllerRef.current.target;
|
||||
const frameBudget = 1000 / targetFPS;
|
||||
|
||||
if (
|
||||
currentTime - lastRenderTimeRef.current >= frameBudget ||
|
||||
!isInitializedRef.current
|
||||
) {
|
||||
const drawStart = getNow();
|
||||
drawGraph();
|
||||
const drawEnd = getNow();
|
||||
|
||||
lastRenderTimeRef.current = currentTime;
|
||||
collectFrameSample(drawEnd - drawStart, frameBudget);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// 只有在有数据时才开始动画
|
||||
if (displayData.length > 0) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [drawGraph, displayData.length]);
|
||||
}, [drawGraph, displayData.length, isWindowFocused, collectFrameSample]);
|
||||
|
||||
// 切换时间范围
|
||||
const handleTimeRangeClick = useCallback((event: React.MouseEvent) => {
|
||||
@ -977,7 +1177,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
}}
|
||||
>
|
||||
Points: {displayData.length} | Compressed:{" "}
|
||||
{samplerStats.compressedBufferSize}
|
||||
{samplerStats.compressedBufferSize} | FPS: {currentFPS}
|
||||
</Box>
|
||||
|
||||
{/* 悬浮提示框 */}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import {
|
||||
LocationOnOutlined,
|
||||
RefreshOutlined,
|
||||
VisibilityOutlined,
|
||||
VisibilityOffOutlined,
|
||||
VisibilityOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { Box, Typography, Button, Skeleton, IconButton } from "@mui/material";
|
||||
import { useState, useEffect, useCallback, memo } from "react";
|
||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getIpInfo } from "@/services/api";
|
||||
|
||||
205
src/components/setting/mods/auto-backup-settings.tsx
Normal file
205
src/components/setting/mods/auto-backup-settings.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import {
|
||||
InputAdornment,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Fragment, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Switch } from "@/components/base";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
const MIN_INTERVAL_HOURS = 1;
|
||||
const MAX_INTERVAL_HOURS = 168;
|
||||
|
||||
interface AutoBackupState {
|
||||
scheduleEnabled: boolean;
|
||||
intervalHours: number;
|
||||
changeEnabled: boolean;
|
||||
}
|
||||
|
||||
export function AutoBackupSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { verge, patchVerge } = useVerge();
|
||||
const derivedValues = useMemo<AutoBackupState>(() => {
|
||||
return {
|
||||
scheduleEnabled: verge?.enable_auto_backup_schedule ?? false,
|
||||
intervalHours: verge?.auto_backup_interval_hours ?? 24,
|
||||
changeEnabled: verge?.auto_backup_on_change ?? true,
|
||||
};
|
||||
}, [
|
||||
verge?.enable_auto_backup_schedule,
|
||||
verge?.auto_backup_interval_hours,
|
||||
verge?.auto_backup_on_change,
|
||||
]);
|
||||
const [pendingValues, setPendingValues] = useState<AutoBackupState | null>(
|
||||
null,
|
||||
);
|
||||
const values = useMemo(() => {
|
||||
if (!pendingValues) {
|
||||
return derivedValues;
|
||||
}
|
||||
if (
|
||||
pendingValues.scheduleEnabled === derivedValues.scheduleEnabled &&
|
||||
pendingValues.intervalHours === derivedValues.intervalHours &&
|
||||
pendingValues.changeEnabled === derivedValues.changeEnabled
|
||||
) {
|
||||
return derivedValues;
|
||||
}
|
||||
return pendingValues;
|
||||
}, [pendingValues, derivedValues]);
|
||||
const [intervalInputDraft, setIntervalInputDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const applyPatch = useLockFn(
|
||||
async (
|
||||
partial: Partial<AutoBackupState>,
|
||||
payload: Partial<IVergeConfig>,
|
||||
) => {
|
||||
const nextValues = { ...values, ...partial };
|
||||
setPendingValues(nextValues);
|
||||
try {
|
||||
await patchVerge(payload);
|
||||
} catch (error) {
|
||||
showNotice.error(error);
|
||||
setPendingValues(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const disabled = !verge;
|
||||
|
||||
const handleScheduleToggle = (
|
||||
_: ChangeEvent<HTMLInputElement>,
|
||||
checked: boolean,
|
||||
) => {
|
||||
applyPatch(
|
||||
{ scheduleEnabled: checked },
|
||||
{
|
||||
enable_auto_backup_schedule: checked,
|
||||
auto_backup_interval_hours: values.intervalHours,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeToggle = (
|
||||
_: ChangeEvent<HTMLInputElement>,
|
||||
checked: boolean,
|
||||
) => {
|
||||
applyPatch({ changeEnabled: checked }, { auto_backup_on_change: checked });
|
||||
};
|
||||
|
||||
const handleIntervalInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setIntervalInputDraft(event.target.value);
|
||||
};
|
||||
|
||||
const commitIntervalInput = () => {
|
||||
const rawValue = intervalInputDraft ?? values.intervalHours.toString();
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === "") {
|
||||
setIntervalInputDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setIntervalInputDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.min(
|
||||
MAX_INTERVAL_HOURS,
|
||||
Math.max(MIN_INTERVAL_HOURS, Math.round(parsed)),
|
||||
);
|
||||
|
||||
if (clamped === values.intervalHours) {
|
||||
setIntervalInputDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
applyPatch(
|
||||
{ intervalHours: clamped },
|
||||
{ auto_backup_interval_hours: clamped },
|
||||
);
|
||||
setIntervalInputDraft(null);
|
||||
};
|
||||
|
||||
const scheduleDisabled = disabled || !values.scheduleEnabled;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={1} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.scheduleLabel")}
|
||||
secondary={t("settings.modals.backup.auto.scheduleHelper")}
|
||||
/>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={values.scheduleEnabled}
|
||||
onChange={handleScheduleToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={2} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.intervalLabel")}
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.modals.backup.auto.intervalLabel")}
|
||||
size="small"
|
||||
type="number"
|
||||
value={intervalInputDraft ?? values.intervalHours.toString()}
|
||||
disabled={scheduleDisabled}
|
||||
onChange={handleIntervalInputChange}
|
||||
onBlur={commitIntervalInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitIntervalInput();
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 160 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{t("shared.units.hours")}
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
htmlInput: {
|
||||
min: MIN_INTERVAL_HOURS,
|
||||
max: MAX_INTERVAL_HOURS,
|
||||
inputMode: "numeric",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={1} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.changeLabel")}
|
||||
secondary={t("settings.modals.backup.auto.changeHelper")}
|
||||
/>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={values.changeEnabled}
|
||||
onChange={handleChangeToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@ -58,14 +58,6 @@ export const BackupConfigViewer = memo(
|
||||
webdav_username !== username ||
|
||||
webdav_password !== password;
|
||||
|
||||
console.log(
|
||||
"webdavChanged",
|
||||
webdavChanged,
|
||||
webdav_url,
|
||||
webdav_username,
|
||||
webdav_password,
|
||||
);
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
344
src/components/setting/mods/backup-history-viewer.tsx
Normal file
344
src/components/setting/mods/backup-history-viewer.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||
import DownloadRounded from "@mui/icons-material/DownloadRounded";
|
||||
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
||||
import RestoreRounded from "@mui/icons-material/RestoreRounded";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { useLockFn } from "ahooks";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, BaseLoadingOverlay } from "@/components/base";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import {
|
||||
deleteLocalBackup,
|
||||
deleteWebdavBackup,
|
||||
exportLocalBackup,
|
||||
listLocalBackup,
|
||||
listWebDavBackup,
|
||||
restartApp,
|
||||
restoreLocalBackup,
|
||||
restoreWebDavBackup,
|
||||
} from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
|
||||
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
||||
|
||||
type BackupSource = "local" | "webdav";
|
||||
|
||||
interface BackupHistoryViewerProps {
|
||||
open: boolean;
|
||||
source: BackupSource;
|
||||
page: number;
|
||||
onSourceChange: (source: BackupSource) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface BackupRow {
|
||||
filename: string;
|
||||
platform: string;
|
||||
backup_time: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
const confirmAsync = async (message: string) => {
|
||||
const fn = window.confirm as (msg?: string) => boolean;
|
||||
return fn(message);
|
||||
};
|
||||
|
||||
export const BackupHistoryViewer = ({
|
||||
open,
|
||||
source,
|
||||
page,
|
||||
onSourceChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: BackupHistoryViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { verge } = useVerge();
|
||||
const [rows, setRows] = useState<BackupRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const isLocal = source === "local";
|
||||
const isWebDavConfigured = Boolean(
|
||||
verge?.webdav_url && verge?.webdav_username && verge?.webdav_password,
|
||||
);
|
||||
const shouldSkipWebDav = !isLocal && !isWebDavConfigured;
|
||||
const pageSize = 8;
|
||||
const isBusy = loading || isRestarting;
|
||||
|
||||
const buildRow = useCallback((filename: string): BackupRow | null => {
|
||||
const platform = filename.split("-")[0];
|
||||
const match = filename.match(FILENAME_PATTERN);
|
||||
if (!match) return null;
|
||||
return {
|
||||
filename,
|
||||
platform,
|
||||
backup_time: dayjs(match[0], DATE_FORMAT),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchRows = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (shouldSkipWebDav) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = isLocal ? await listLocalBackup() : await listWebDavBackup();
|
||||
setRows(
|
||||
list
|
||||
.map((item) => buildRow(item.filename))
|
||||
.filter((item): item is BackupRow => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setRows([]);
|
||||
showNotice.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [buildRow, isLocal, open, shouldSkipWebDav]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRows();
|
||||
}, [fetchRows]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const pagedRows = rows.slice(
|
||||
currentPage * pageSize,
|
||||
currentPage * pageSize + pageSize,
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (shouldSkipWebDav) {
|
||||
return t("settings.modals.backup.manual.webdav");
|
||||
}
|
||||
if (!total) return t("settings.modals.backup.history.empty");
|
||||
const recent = rows[0]?.backup_time.fromNow();
|
||||
return t("settings.modals.backup.history.summary", {
|
||||
count: total,
|
||||
recent,
|
||||
});
|
||||
}, [rows, shouldSkipWebDav, t, total]);
|
||||
|
||||
const handleDelete = useLockFn(async (filename: string) => {
|
||||
if (isRestarting) return;
|
||||
if (
|
||||
!(await confirmAsync(t("settings.modals.backup.messages.confirmDelete")))
|
||||
)
|
||||
return;
|
||||
if (isLocal) {
|
||||
await deleteLocalBackup(filename);
|
||||
} else {
|
||||
await deleteWebdavBackup(filename);
|
||||
}
|
||||
await fetchRows();
|
||||
});
|
||||
|
||||
const handleRestore = useLockFn(async (filename: string) => {
|
||||
if (isRestarting) return;
|
||||
if (
|
||||
!(await confirmAsync(t("settings.modals.backup.messages.confirmRestore")))
|
||||
)
|
||||
return;
|
||||
if (isLocal) {
|
||||
await restoreLocalBackup(filename);
|
||||
} else {
|
||||
await restoreWebDavBackup(filename);
|
||||
}
|
||||
showNotice.success("settings.modals.backup.messages.restoreSuccess");
|
||||
setIsRestarting(true);
|
||||
window.setTimeout(() => {
|
||||
void restartApp().catch((err: unknown) => {
|
||||
setIsRestarting(false);
|
||||
showNotice.error(err);
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
const handleExport = useLockFn(async (filename: string) => {
|
||||
if (isRestarting) return;
|
||||
if (!isLocal) return;
|
||||
const savePath = await save({ defaultPath: filename });
|
||||
if (!savePath || Array.isArray(savePath)) return;
|
||||
try {
|
||||
await exportLocalBackup(filename, savePath);
|
||||
showNotice.success("settings.modals.backup.messages.localBackupExported");
|
||||
} catch (ignoreError: unknown) {
|
||||
showNotice.error(
|
||||
"settings.modals.backup.messages.localBackupExportFailed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (isRestarting) return;
|
||||
void fetchRows();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.history.title")}
|
||||
contentSx={{ width: 520 }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={onClose}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={{ position: "relative", minHeight: 320 }}>
|
||||
<BaseLoadingOverlay isLoading={isBusy} />
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Tabs
|
||||
value={source}
|
||||
onChange={(_, val) => {
|
||||
if (isBusy) return;
|
||||
onSourceChange(val as BackupSource);
|
||||
onPageChange(0);
|
||||
}}
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
<Tab
|
||||
value="local"
|
||||
label={t("settings.modals.backup.tabs.local")}
|
||||
disabled={isBusy}
|
||||
sx={{ px: 2 }}
|
||||
/>
|
||||
<Tab
|
||||
value="webdav"
|
||||
label={t("settings.modals.backup.tabs.webdav")}
|
||||
disabled={isBusy}
|
||||
sx={{ px: 2 }}
|
||||
/>
|
||||
</Tabs>
|
||||
<IconButton size="small" onClick={handleRefresh} disabled={isBusy}>
|
||||
<RefreshRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{summary}
|
||||
</Typography>
|
||||
|
||||
<List
|
||||
disablePadding
|
||||
subheader={
|
||||
<ListSubheader disableSticky>
|
||||
{t("settings.modals.backup.history.title")}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{pagedRows.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.history.empty") || ""}
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
pagedRows.map((row) => (
|
||||
<ListItem
|
||||
key={`${row.platform}-${row.filename}`}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{isLocal && (
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleExport(row.filename)}
|
||||
>
|
||||
<DownloadRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleDelete(row.filename)}
|
||||
>
|
||||
<DeleteOutline fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleRestore(row.filename)}
|
||||
>
|
||||
<RestoreRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={row.filename}
|
||||
secondary={`${row.platform} · ${row.backup_time.format("YYYY-MM-DD HH:mm")}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography variant="caption">
|
||||
{currentPage + 1} / {pageCount}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={isBusy || currentPage === 0}
|
||||
onClick={() => onPageChange(Math.max(0, currentPage - 1))}
|
||||
>
|
||||
{t("shared.actions.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={isBusy || currentPage >= pageCount - 1}
|
||||
onClick={() =>
|
||||
onPageChange(Math.min(pageCount - 1, currentPage + 1))
|
||||
}
|
||||
>
|
||||
{t("shared.actions.next")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
@ -1,347 +0,0 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
IconButton,
|
||||
Divider,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
} from "@mui/material";
|
||||
import { Typography } from "@mui/material";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { SVGProps, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { restartApp } from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
export type BackupFile = {
|
||||
platform: string;
|
||||
backup_time: Dayjs;
|
||||
allow_apply: boolean;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_ROWS_PER_PAGE = 5;
|
||||
|
||||
type ConfirmFn = (message?: string) => boolean | Promise<boolean>;
|
||||
|
||||
// Normalizes synchronous and async confirm implementations.
|
||||
const confirmAsync = async (message: string): Promise<boolean> => {
|
||||
const confirmFn = window.confirm as unknown as ConfirmFn;
|
||||
return await confirmFn.call(window, message);
|
||||
};
|
||||
|
||||
interface BackupTableViewerProps {
|
||||
datasource: BackupFile[];
|
||||
page: number;
|
||||
onPageChange: (
|
||||
event: React.MouseEvent<HTMLButtonElement> | null,
|
||||
page: number,
|
||||
) => void;
|
||||
total: number;
|
||||
onRefresh: () => Promise<void>;
|
||||
onDelete: (filename: string) => Promise<void>;
|
||||
onRestore: (filename: string) => Promise<void>;
|
||||
onExport?: (filename: string, destination: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const BackupTableViewer = memo(
|
||||
({
|
||||
datasource,
|
||||
page,
|
||||
onPageChange,
|
||||
total,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onExport,
|
||||
}: BackupTableViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDelete = useLockFn(async (filename: string) => {
|
||||
await onDelete(filename);
|
||||
await onRefresh();
|
||||
});
|
||||
|
||||
const handleRestore = useLockFn(async (filename: string) => {
|
||||
await onRestore(filename).then(() => {
|
||||
showNotice.success("settings.modals.backup.messages.restoreSuccess");
|
||||
});
|
||||
await restartApp();
|
||||
});
|
||||
|
||||
const handleExport = useLockFn(async (filename: string) => {
|
||||
if (!onExport) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const savePath = await save({
|
||||
defaultPath: filename,
|
||||
});
|
||||
if (!savePath || Array.isArray(savePath)) {
|
||||
return;
|
||||
}
|
||||
await onExport(filename, savePath);
|
||||
showNotice.success(
|
||||
"settings.modals.backup.messages.localBackupExported",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showNotice.error(
|
||||
"settings.modals.backup.messages.localBackupExportFailed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{t("settings.modals.backup.table.filename")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t("settings.modals.backup.table.backupTime")}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{t("settings.modals.backup.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{datasource.length > 0 ? (
|
||||
datasource.map((file) => {
|
||||
const rowKey = `${file.platform}-${file.filename}-${file.backup_time.valueOf()}`;
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell component="th" scope="row">
|
||||
{file.platform === "windows" ? (
|
||||
<WindowsIcon className="h-full w-full" />
|
||||
) : file.platform === "linux" ? (
|
||||
<LinuxIcon className="h-full w-full" />
|
||||
) : (
|
||||
<MacIcon className="h-full w-full" />
|
||||
)}
|
||||
{file.filename}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{file.backup_time.fromNow()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{onExport && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t(
|
||||
"settings.modals.backup.actions.export",
|
||||
)}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.exportBackup",
|
||||
)}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
await handleExport(file.filename);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
aria-label={t("shared.actions.delete")}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.deleteBackup",
|
||||
)}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await confirmAsync(
|
||||
t(
|
||||
"settings.modals.backup.messages.confirmDelete",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t(
|
||||
"settings.modals.backup.actions.restore",
|
||||
)}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.restoreBackup",
|
||||
)}
|
||||
disabled={!file.allow_apply}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await confirmAsync(
|
||||
t(
|
||||
"settings.modals.backup.messages.confirmRestore",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleRestore(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RestoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 150,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="textSecondary"
|
||||
align="center"
|
||||
>
|
||||
{t("settings.modals.backup.table.noBackups")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[]}
|
||||
component="div"
|
||||
count={total}
|
||||
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
|
||||
page={page}
|
||||
onPageChange={onPageChange}
|
||||
labelRowsPerPage={t("settings.modals.backup.table.rowsPerPage")}
|
||||
/>
|
||||
</TableContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function LinuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 48 48"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#ECEFF1"
|
||||
d="m20.1 16.2l.1 2.3l-1.6 3l-2.5 4.9l-.5 4.1l1.8 5.8l4.1 2.3h6.2l5.8-4.4l2.6-6.9l-6-7.3l-1.7-4.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#263238"
|
||||
d="M34.3 21.9c-1.6-2.3-2.9-3.7-3.6-6.6s.2-2.1-.4-4.6c-.3-1.3-.8-2.2-1.3-2.9c-.6-.7-1.3-1.1-1.7-1.2c-.9-.5-3-1.3-5.6.1c-2.7 1.4-2.4 4.4-1.9 10.5c0 .4-.1.9-.3 1.3c-.4.9-1.1 1.7-1.7 2.4c-.7 1-1.4 2-1.9 3.1c-1.2 2.3-2.3 5.2-2 6.3c.5-.1 6.8 9.5 6.8 9.7c.4-.1 2.1-.1 3.6-.1c2.1-.1 3.3-.2 5 .2c0-.3-.1-.6-.1-.9c0-.6.1-1.1.2-1.8c.1-.5.2-1 .3-1.6c-1 .9-2.8 1.9-4.5 2.2c-1.5.3-4-.2-5.2-1.7c.1 0 .3 0 .4-.1c.3-.1.6-.2.7-.4c.3-.5.1-1-.1-1.3s-1.7-1.4-2.4-2s-1.1-.9-1.5-1.3l-.8-.8c-.2-.2-.3-.4-.4-.5c-.2-.5-.3-1.1-.2-1.9c.1-1.1.5-2 1-3c.2-.4.7-1.2.7-1.2s-1.7 4.2-.8 5.5c0 0 .1-1.3.5-2.6c.3-.9.8-2.2 1.4-2.9s2.1-3.3 2.2-4.9c0-.7.1-1.4.1-1.9c-.4-.4 6.6-1.4 7-.3c.1.4 1.5 4 2.3 5.9c.4.9.9 1.7 1.2 2.7c.3 1.1.5 2.6.5 4.1c0 .3 0 .8-.1 1.3c.2 0 4.1-4.2-.5-7.7c0 0 2.8 1.3 2.9 3.9c.1 2.1-.8 3.8-1 4.1c.1 0 2.1.9 2.2.9c.4 0 1.2-.3 1.2-.3c.1-.3.4-1.1.4-1.4c.7-2.3-1-6-2.6-8.3"
|
||||
/>
|
||||
<g fill="#ECEFF1" transform="translate(0 -2)">
|
||||
<ellipse cx="21.6" cy="15.3" rx="1.3" ry="2" />
|
||||
<ellipse cx="26.1" cy="15.2" rx="1.7" ry="2.3" />
|
||||
</g>
|
||||
<g fill="#212121" transform="translate(0 -2)">
|
||||
<ellipse
|
||||
cx="21.7"
|
||||
cy="15.5"
|
||||
rx="1.2"
|
||||
ry=".7"
|
||||
transform="rotate(-97.204 21.677 15.542)"
|
||||
/>
|
||||
<ellipse cx="26" cy="15.6" rx="1" ry="1.3" />
|
||||
</g>
|
||||
<path
|
||||
fill="#FFC107"
|
||||
d="M39.3 35.6c-.4-.2-1.1-.5-1.7-1.4c-.3-.5-.2-1.9-.7-2.5c-.3-.4-.7-.2-.8-.2c-.9.2-3 1.6-4.4 0c-.2-.2-.5-.5-1-.5s-.7.2-.9.6s-.2.7-.2 1.7c0 .8 0 1.7-.1 2.4c-.2 1.7-.5 2.7-.5 3.7c0 1.1.3 1.8.7 2.1c.3.3.8.5 1.9.5s1.8-.4 2.5-1.1c.5-.5.9-.7 2.3-1.7c1.1-.7 2.8-1.6 3.1-1.9c.2-.2.5-.3.5-.9c0-.5-.4-.7-.7-.8m-20.1.3c-1-1.6-1.1-1.9-1.8-2.9c-.6-1-1.9-2.9-2.7-2.9c-.6 0-.9.3-1.3.7s-.8 1.3-1.5 1.8c-.6.5-2.3.4-2.7 1s.4 1.5.4 3c0 .6-.5 1-.6 1.4c-.1.5-.2.8 0 1.2c.4.6.9.8 4.3 1.5c1.8.4 3.5 1.4 4.6 1.5s3 0 3-2.7c.1-1.6-.8-2-1.7-3.6m1.9-18.1c-.6-.4-1.1-.8-1.1-1.4s.4-.8 1-1.3c.1-.1 1.2-1.1 2.3-1.1s2.4.7 2.9.9c.9.2 1.8.4 1.7 1.1c-.1 1-.2 1.2-1.2 1.7c-.7.2-2 1.3-2.9 1.3c-.4 0-1 0-1.4-.1c-.3-.1-.8-.6-1.3-1.1"
|
||||
/>
|
||||
<path
|
||||
fill="#634703"
|
||||
d="M20.9 17c.2.2.5.4.8.5c.2.1.5.2.5.2h.9c.5 0 1.2-.2 1.9-.6c.7-.3.8-.5 1.3-.7c.5-.3 1-.6.8-.7s-.4 0-1.1.4c-.6.4-1.1.6-1.7.9c-.3.1-.7.3-1 .3h-.9c-.3 0-.5-.1-.8-.2c-.2-.1-.3-.2-.4-.2c-.2-.1-.6-.5-.8-.6c0 0-.2 0-.1.1zm3-2.2c.1.2.3.2.4.3s.2.1.2.1c.1-.1 0-.3-.1-.3c0-.2-.5-.2-.5-.1m-1.6.2c0 .1.2.2.2.1c.1-.1.2-.2.3-.2c.2-.1.1-.2-.2-.2c-.2.1-.2.2-.3.3"
|
||||
/>
|
||||
<path
|
||||
fill="#455A64"
|
||||
d="M32 32.7v.3c.2.4.7.5 1.1.5c.6 0 1.2-.4 1.5-.8c0-.1.1-.2.2-.3c.2-.3.3-.5.4-.6c0 0-.1-.1-.1-.2c-.1-.2-.4-.4-.8-.5c-.3-.1-.8-.2-1-.2c-.9-.1-1.4.2-1.7.5c0 0 .1 0 .1.1c.2.2.3.4.3.7c.1.2 0 .3 0 .5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 16 16"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#0284c7"
|
||||
d="M6.555 1.375L0 2.237v5.45h6.555zM0 13.795l6.555.933V8.313H0zm7.278-5.4l.026 6.378L16 16V8.395zM16 0L7.33 1.244v6.414H16z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MacIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 26 26"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M23.934 18.947c-.598 1.324-.884 1.916-1.652 3.086c-1.073 1.634-2.588 3.673-4.461 3.687c-1.666.014-2.096-1.087-4.357-1.069c-2.261.011-2.732 1.089-4.4 1.072c-1.873-.017-3.307-1.854-4.381-3.485c-3.003-4.575-3.32-9.937-1.464-12.79C4.532 7.425 6.61 6.237 8.561 6.237c1.987 0 3.236 1.092 4.879 1.092c1.594 0 2.565-1.095 4.863-1.095c1.738 0 3.576.947 4.889 2.581c-4.296 2.354-3.598 8.49.742 10.132M16.559 4.408c.836-1.073 1.47-2.587 1.24-4.131c-1.364.093-2.959.964-3.891 2.092c-.844 1.027-1.544 2.553-1.271 4.029c1.488.048 3.028-.839 3.922-1.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,354 +1,213 @@
|
||||
import { Box, Button, Divider, Paper, Tab, Tabs } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import type { Ref } from "react";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { useImperativeHandle, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base";
|
||||
import {
|
||||
deleteLocalBackup,
|
||||
deleteWebdavBackup,
|
||||
listLocalBackup,
|
||||
listWebDavBackup,
|
||||
exportLocalBackup,
|
||||
restoreLocalBackup,
|
||||
restoreWebDavBackup,
|
||||
} from "@/services/cmds";
|
||||
import { BaseDialog, DialogRef } from "@/components/base";
|
||||
import { createLocalBackup, createWebdavBackup } from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
import { BackupConfigViewer } from "./backup-config-viewer";
|
||||
import {
|
||||
BackupFile,
|
||||
BackupTableViewer,
|
||||
DEFAULT_ROWS_PER_PAGE,
|
||||
} from "./backup-table-viewer";
|
||||
import { LocalBackupActions } from "./local-backup-actions";
|
||||
dayjs.extend(customParseFormat);
|
||||
import { AutoBackupSettings } from "./auto-backup-settings";
|
||||
import { BackupHistoryViewer } from "./backup-history-viewer";
|
||||
import { BackupWebdavDialog } from "./backup-webdav-dialog";
|
||||
|
||||
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
|
||||
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
||||
type BackupSource = "local" | "webdav";
|
||||
type CloseButtonPosition = { top: number; left: number } | null;
|
||||
|
||||
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dialogPaper, setDialogPaper] = useReducer(
|
||||
(_: HTMLElement | null, next: HTMLElement | null) => next,
|
||||
null as HTMLElement | null,
|
||||
);
|
||||
const [closeButtonPosition, setCloseButtonPosition] = useReducer(
|
||||
(_: CloseButtonPosition, next: CloseButtonPosition) => next,
|
||||
null as CloseButtonPosition,
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [source, setSource] = useState<BackupSource>("local");
|
||||
const [busyAction, setBusyAction] = useState<BackupSource | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historySource, setHistorySource] = useState<BackupSource>("local");
|
||||
const [historyPage, setHistoryPage] = useState(0);
|
||||
const [webdavDialogOpen, setWebdavDialogOpen] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
setOpen(true);
|
||||
},
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = useCallback(
|
||||
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
|
||||
setPage(page);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openHistory = (target: BackupSource) => {
|
||||
setHistorySource(target);
|
||||
setHistoryPage(0);
|
||||
setHistoryOpen(true);
|
||||
};
|
||||
|
||||
const handleChangeSource = useCallback(
|
||||
(_event: React.SyntheticEvent, newSource: string) => {
|
||||
setSource(newSource as BackupSource);
|
||||
setPage(0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buildBackupFile = useCallback((filename: string) => {
|
||||
const platform = filename.split("-")[0];
|
||||
const fileBackupTimeStr = filename.match(FILENAME_PATTERN);
|
||||
|
||||
if (fileBackupTimeStr === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
|
||||
const allowApply = true;
|
||||
return {
|
||||
filename,
|
||||
platform,
|
||||
backup_time: backupTime,
|
||||
allow_apply: allowApply,
|
||||
} as BackupFile;
|
||||
}, []);
|
||||
|
||||
const getAllBackupFiles = useCallback(async (): Promise<BackupFile[]> => {
|
||||
if (source === "local") {
|
||||
const files = await listLocalBackup();
|
||||
return files
|
||||
.map((file) => buildBackupFile(file.filename))
|
||||
.filter((item): item is BackupFile => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
|
||||
}
|
||||
|
||||
const files = await listWebDavBackup();
|
||||
return files
|
||||
.map((file) => {
|
||||
return buildBackupFile(file.filename);
|
||||
})
|
||||
.filter((item): item is BackupFile => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
|
||||
}, [buildBackupFile, source]);
|
||||
|
||||
const fetchAndSetBackupFiles = useCallback(async () => {
|
||||
const handleBackup = useLockFn(async (target: BackupSource) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const files = await getAllBackupFiles();
|
||||
setBackupFiles(files);
|
||||
setTotal(files.length);
|
||||
setBusyAction(target);
|
||||
if (target === "local") {
|
||||
await createLocalBackup();
|
||||
showNotice.success(
|
||||
"settings.modals.backup.messages.localBackupCreated",
|
||||
);
|
||||
} else {
|
||||
await createWebdavBackup();
|
||||
showNotice.success("settings.modals.backup.messages.backupCreated");
|
||||
}
|
||||
} catch (error) {
|
||||
setBackupFiles([]);
|
||||
setTotal(0);
|
||||
console.error(error);
|
||||
showNotice.error(
|
||||
target === "local"
|
||||
? "settings.modals.backup.messages.localBackupFailed"
|
||||
: "settings.modals.backup.messages.backupFailed",
|
||||
target === "local" ? undefined : { error },
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBusyAction(null);
|
||||
}
|
||||
}, [getAllBackupFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchAndSetBackupFiles();
|
||||
const paper = contentRef.current?.closest(".MuiPaper-root");
|
||||
setDialogPaper((paper as HTMLElement) ?? null);
|
||||
} else {
|
||||
setDialogPaper(null);
|
||||
}
|
||||
}, [open, fetchAndSetBackupFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || dialogPaper) {
|
||||
return;
|
||||
}
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const paper = contentRef.current?.closest(".MuiPaper-root");
|
||||
setDialogPaper((paper as HTMLElement) ?? null);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open, dialogPaper]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogPaper) {
|
||||
setCloseButtonPosition(null);
|
||||
return;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
const rect = dialogPaper.getBoundingClientRect();
|
||||
setCloseButtonPosition({
|
||||
top: rect.bottom - 16,
|
||||
left: rect.right - 24,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updatePosition();
|
||||
});
|
||||
resizeObserver.observe(dialogPaper);
|
||||
}
|
||||
|
||||
const scrollTargets: EventTarget[] = [];
|
||||
const addScrollListener = (target: EventTarget | null) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.addEventListener("scroll", updatePosition, true);
|
||||
scrollTargets.push(target);
|
||||
};
|
||||
|
||||
addScrollListener(window);
|
||||
addScrollListener(dialogPaper);
|
||||
const dialogContent = dialogPaper.querySelector(".MuiDialogContent-root");
|
||||
addScrollListener(dialogContent);
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
scrollTargets.forEach((target) => {
|
||||
target.removeEventListener("scroll", updatePosition, true);
|
||||
});
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}, [dialogPaper]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (filename: string) => {
|
||||
if (source === "local") {
|
||||
await deleteLocalBackup(filename);
|
||||
} else {
|
||||
await deleteWebdavBackup(filename);
|
||||
}
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (filename: string) => {
|
||||
if (source === "local") {
|
||||
await restoreLocalBackup(filename);
|
||||
} else {
|
||||
await restoreWebDavBackup(filename);
|
||||
}
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (filename: string, destination: string) => {
|
||||
await exportLocalBackup(filename, destination);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dataSource = useMemo<BackupFile[]>(
|
||||
() =>
|
||||
backupFiles.slice(
|
||||
page * DEFAULT_ROWS_PER_PAGE,
|
||||
page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE,
|
||||
),
|
||||
[backupFiles, page],
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.title")}
|
||||
contentSx={{
|
||||
minWidth: { xs: 320, sm: 620 },
|
||||
maxWidth: "unset",
|
||||
minHeight: 460,
|
||||
}}
|
||||
contentSx={{ width: { xs: 360, sm: 520 } }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={() => setOpen(false)}
|
||||
onClose={() => setOpen(false)}
|
||||
disableFooter
|
||||
>
|
||||
<Box
|
||||
ref={contentRef}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<BaseLoadingOverlay isLoading={isLoading} />
|
||||
<Paper
|
||||
elevation={2}
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={source}
|
||||
onChange={handleChangeSource}
|
||||
aria-label={t("settings.modals.backup.actions.selectTarget")}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab value="local" label={t("settings.modals.backup.tabs.local")} />
|
||||
<Tab
|
||||
value="webdav"
|
||||
label={t("settings.modals.backup.tabs.webdav")}
|
||||
/>
|
||||
</Tabs>
|
||||
{source === "local" ? (
|
||||
<LocalBackupActions
|
||||
setLoading={setIsLoading}
|
||||
onBackupSuccess={fetchAndSetBackupFiles}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
/>
|
||||
) : (
|
||||
<BackupConfigViewer
|
||||
setLoading={setIsLoading}
|
||||
onBackupSuccess={fetchAndSetBackupFiles}
|
||||
onSaveSuccess={fetchAndSetBackupFiles}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
onInit={fetchAndSetBackupFiles}
|
||||
/>
|
||||
)}
|
||||
<Divider sx={{ marginY: 2 }} />
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<BackupTableViewer
|
||||
datasource={dataSource}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
total={total}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
onDelete={handleDelete}
|
||||
onRestore={handleRestore}
|
||||
onExport={source === "local" ? handleExport : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
{dialogPaper &&
|
||||
closeButtonPosition &&
|
||||
createPortal(
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: closeButtonPosition.top,
|
||||
left: closeButtonPosition.left,
|
||||
transform: "translate(-100%, -100%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: (theme) => theme.zIndex.modal + 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setOpen(false)}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
boxShadow: (theme) => theme.shadows[3],
|
||||
backgroundColor: (theme) => theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
{t("shared.actions.close")}
|
||||
</Button>
|
||||
</Box>,
|
||||
dialogPaper,
|
||||
)}
|
||||
<Typography variant="subtitle1">
|
||||
{t("settings.modals.backup.auto.title")}
|
||||
</Typography>
|
||||
<List disablePadding sx={{ ".MuiListItem-root": { px: 0 } }}>
|
||||
<AutoBackupSettings />
|
||||
</List>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
{t("settings.modals.backup.manual.title")}
|
||||
</Typography>
|
||||
<List disablePadding sx={{ ".MuiListItem-root": { px: 0 } }}>
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "local" as BackupSource,
|
||||
title: t("settings.modals.backup.tabs.local"),
|
||||
description: t("settings.modals.backup.manual.local"),
|
||||
actions: [
|
||||
<LoadingButton
|
||||
key="backup"
|
||||
variant="contained"
|
||||
size="small"
|
||||
loading={busyAction === "local"}
|
||||
onClick={() => handleBackup("local")}
|
||||
>
|
||||
{t("settings.modals.backup.actions.backup")}
|
||||
</LoadingButton>,
|
||||
<Button
|
||||
key="history"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => openHistory("local")}
|
||||
>
|
||||
{t("settings.modals.backup.actions.viewHistory")}
|
||||
</Button>,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "webdav" as BackupSource,
|
||||
title: t("settings.modals.backup.tabs.webdav"),
|
||||
description: t("settings.modals.backup.manual.webdav"),
|
||||
actions: [
|
||||
<LoadingButton
|
||||
key="backup"
|
||||
variant="contained"
|
||||
size="small"
|
||||
loading={busyAction === "webdav"}
|
||||
onClick={() => handleBackup("webdav")}
|
||||
>
|
||||
{t("settings.modals.backup.actions.backup")}
|
||||
</LoadingButton>,
|
||||
<Button
|
||||
key="history"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => openHistory("webdav")}
|
||||
>
|
||||
{t("settings.modals.backup.actions.viewHistory")}
|
||||
</Button>,
|
||||
<Button
|
||||
key="configure"
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setWebdavDialogOpen(true)}
|
||||
>
|
||||
{t("settings.modals.backup.manual.configureWebdav")}
|
||||
</Button>,
|
||||
],
|
||||
},
|
||||
] satisfies Array<{
|
||||
key: BackupSource;
|
||||
title: string;
|
||||
description: string;
|
||||
actions: ReactNode[];
|
||||
}>
|
||||
).map((item, idx) => (
|
||||
<ListItem key={item.key} disableGutters divider={idx === 0}>
|
||||
<Stack spacing={1} sx={{ width: "100%" }}>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
slotProps={{ secondary: { component: "span" } }}
|
||||
secondary={item.description}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
useFlexGap
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
>
|
||||
{item.actions}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<BackupHistoryViewer
|
||||
open={historyOpen}
|
||||
source={historySource}
|
||||
page={historyPage}
|
||||
onSourceChange={setHistorySource}
|
||||
onPageChange={setHistoryPage}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
<BackupWebdavDialog
|
||||
open={webdavDialogOpen}
|
||||
onClose={() => setWebdavDialogOpen(false)}
|
||||
onBackupSuccess={() => openHistory("webdav")}
|
||||
setBusy={(loading) => setBusyAction(loading ? "webdav" : null)}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
87
src/components/setting/mods/backup-webdav-dialog.tsx
Normal file
87
src/components/setting/mods/backup-webdav-dialog.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, BaseLoadingOverlay } from "@/components/base";
|
||||
import { listWebDavBackup } from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
import { BackupConfigViewer } from "./backup-config-viewer";
|
||||
|
||||
interface BackupWebdavDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onBackupSuccess?: () => void;
|
||||
setBusy?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const BackupWebdavDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onBackupSuccess,
|
||||
setBusy,
|
||||
}: BackupWebdavDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLoading = useCallback(
|
||||
(value: boolean) => {
|
||||
setLoading(value);
|
||||
setBusy?.(value);
|
||||
},
|
||||
[setBusy],
|
||||
);
|
||||
|
||||
const refreshWebdav = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
handleLoading(true);
|
||||
try {
|
||||
await listWebDavBackup();
|
||||
if (!options?.silent) {
|
||||
showNotice.success(
|
||||
"settings.modals.backup.messages.webdavRefreshSuccess",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
"settings.modals.backup.messages.webdavRefreshFailed",
|
||||
{ error },
|
||||
);
|
||||
} finally {
|
||||
handleLoading(false);
|
||||
}
|
||||
},
|
||||
[handleLoading],
|
||||
);
|
||||
|
||||
const refreshSilently = useCallback(
|
||||
() => refreshWebdav({ silent: true }),
|
||||
[refreshWebdav],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.webdav.title")}
|
||||
contentSx={{ width: { xs: 360, sm: 520 } }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={onClose}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<BaseLoadingOverlay isLoading={loading} />
|
||||
<BackupConfigViewer
|
||||
setLoading={handleLoading}
|
||||
onBackupSuccess={async () => {
|
||||
await refreshSilently();
|
||||
onBackupSuccess?.();
|
||||
}}
|
||||
onSaveSuccess={refreshSilently}
|
||||
onRefresh={refreshWebdav}
|
||||
onInit={refreshSilently}
|
||||
/>
|
||||
</Box>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user