From 15a1770ee95b14c903c696e832a153770947b1ea Mon Sep 17 00:00:00 2001 From: Tunglies Date: Thu, 24 Jul 2025 00:47:42 +0800 Subject: [PATCH] feat: migrate mihomo to use kode-bridge IPC on Windows and Unix (#4051) * Refactor Mihomo API integration and remove crate_mihomo_api - Removed the `mihomo_api` crate and its dependencies from the project. - Introduced `IpcManager` for handling IPC communication with Mihomo. - Implemented IPC methods for managing proxies, connections, and configurations. - Updated `MihomoManager` to utilize `IpcManager` instead of the removed crate. - Added platform-specific IPC socket path handling for macOS, Linux, and Windows. - Cleaned up related tests and configuration files. * fix: remove duplicate permission entry in desktop capabilities * refactor: replace MihomoManager with IpcManager and remove Mihomo module * fix: restore tempfile dependency in dev-dependencies * fix: update kode-bridge dependency to use git source from the dev branch * feat: migrate mihomo to use kode-bridge IPC on Windows This commit implements a comprehensive migration from legacy service IPC to the kode-bridge library for Windows IPC communication. Key changes include: Replace service_ipc with kode-bridge IpcManager for all mihomo communications Simplify proxy commands using new caching mechanism with ProxyRequestCache Add Windows named pipe (\.\pipe\mihomo) and Unix socket IPC endpoint configuration Update Tauri permissions and dependencies (dashmap, tauri-plugin-notification) Add IPC logging support and improve error handling Fix Windows IPC path handling in directory utilities This migration enables better cross-platform IPC support and improved performance for mihomo proxy core communication. * doc: add IPC communication with Mihomo kernel, removing Restful API dependency * fix: standardize logging type naming from IPC to Ipc for consistency * refactor: clean up and optimize code structure across multiple components and services - Removed unnecessary comments and whitespace in various files. - Improved code readability and maintainability by restructuring functions and components. - Updated localization files for consistency and accuracy. - Enhanced performance by optimizing hooks and utility functions. - General code cleanup in settings, pages, and services to adhere to best practices. * fix: simplify URL formatting in test_proxy_delay method * fix: update kode-bridge dependency to version 0.1.3 and change source to crates.io * fix: update macOS target versions in development workflow * Revert "fix: update macOS target versions in development workflow" This reverts commit b9831357e462e0f308d11a9a53cb718f98ae1295. * feat: enhance IPC path handling for Unix systems and improve directory safety checks * feat: add conditional compilation for Unix-specific IPC path handling * chore: update cagro.lock * feat: add external controller configuration and UI support * Refactor proxy and connection management to use IPC-based commands - Updated `get_proxies` function in `proxy.rs` to call the new IPC command. - Renamed `get_refresh_proxies` to `get_proxies` in `ipc/general.rs` for consistency. - Added new IPC commands for managing proxies, connections, and configurations in `cmds.ts`. - Refactored API calls in various components to use the new IPC commands instead of HTTP requests. - Improved error handling and response management in the new IPC functions. - Cleaned up unused API functions in `api.ts` and redirected relevant calls to `cmds.ts`. - Enhanced connection management features including health checks and updates for proxy providers. * chore: update dependencies and improve error handling in IPC manager * fix: downgrade zip dependency from 4.3.0 to 4.2.0 * feat: Implement traffic and memory data monitoring service - Added `TrafficService` and `TrafficManager` to manage traffic and memory data collection. - Introduced commands to get traffic and memory data, start and stop the traffic service. - Integrated IPC calls for traffic and memory data retrieval in the frontend. - Updated `AppDataProvider` and `EnhancedTrafficStats` components to utilize new data fetching methods. - Removed WebSocket connections for traffic and memory data, replaced with IPC polling. - Added logging for better traceability of data fetching and service status. * refactor: unify external controller handling and improve IPC path resolution * fix: replace direct IPC path retrieval with guard function for external controller * fix: convert external controller IPC path to string for proper insertion in config map * fix: update dependencies and improve IPC response handling * fix: remove unnecessary unix conditional for ipc path import * Refactor traffic and memory monitoring to use IPC stream; remove TrafficService and TrafficManager. Introduce new IPC-based data retrieval methods for traffic and memory, including formatted data and system overview. Update frontend components to utilize new APIs for enhanced data display and management. * chore: bump crate rand version to 0.9.2 * feat: Implement enhanced traffic monitoring system with data compression and sampling - Introduced `useTrafficMonitorEnhanced` hook for advanced traffic data management. - Added `TrafficDataSampler` class for handling raw and compressed traffic data. - Implemented reference counting to manage data collection based on component usage. - Enhanced data validation with `SystemMonitorValidator` for API responses. - Created diagnostic tools for monitoring performance and error tracking. - Updated existing hooks to utilize the new enhanced monitoring features. - Added utility functions for generating and formatting diagnostic reports. * feat(ipc): improve URL encoding and error handling for IPC requests - Add percent-encoding for URL paths to handle special characters properly - Enhance error handling in update_proxy with proper logging - Remove excessive debug logging to reduce noise - Update kode-bridge dependency to v0.1.5 - Fix JSON parsing error handling in PUT requests Changes include: - Proper URL encoding for connection IDs, proxy names, and test URLs - Enhanced error handling with fallback responses in updateProxy - Comment out verbose debug logs in traffic monitoring and data validation - Update dependency version for improved IPC functionality * feat: major improvements in architecture, traffic monitoring, and data validation * Refactor traffic graph components: Replace EnhancedTrafficGraph with EnhancedCanvasTrafficGraph, improve rendering performance, and enhance visual elements. Remove deprecated code and ensure compatibility with global data management. * chore: update UPDATELOG.md for v2.4.0 release, refine traffic monitoring system details, and enhance IPC functionality * chore: update UPDATELOG.md to reflect removal of deprecated MihomoManager and unify IPC control * refactor: remove global traffic service testing method from cmds.ts * Update src/components/home/enhanced-canvas-traffic-graph.tsx * Update src/hooks/use-traffic-monitor-enhanced.ts * Update src/components/layout/layout-traffic.tsx * refactor: remove debug state management from LayoutTraffic component --------- --- UPDATELOG.md | 48 +- src-tauri/Cargo.lock | 203 ++++++- src-tauri/Cargo.toml | 15 +- src-tauri/src/cmd/clash.rs | 282 ++++++++- src-tauri/src/cmd/mod.rs | 1 - src-tauri/src/cmd/proxy.rs | 10 +- src-tauri/src/config/clash.rs | 54 +- src-tauri/src/config/verge.rs | 7 + src-tauri/src/core/core.rs | 7 +- src-tauri/src/core/tray/mod.rs | 3 +- src-tauri/src/enhance/mod.rs | 17 +- src-tauri/src/feat/clash.rs | 8 +- src-tauri/src/feat/config.rs | 6 + src-tauri/src/feat/proxy.rs | 6 +- src-tauri/src/feat/window.rs | 4 +- src-tauri/src/ipc/general.rs | 397 ++++++++++++ src-tauri/src/ipc/memory.rs | 135 +++++ src-tauri/src/ipc/mod.rs | 12 + src-tauri/src/ipc/traffic.rs | 148 +++++ src-tauri/src/lib.rs | 25 + src-tauri/src/module/mihomo.rs | 108 ---- src-tauri/src/module/mod.rs | 1 - src-tauri/src/utils/dirs.rs | 36 ++ src-tauri/src/utils/logging.rs | 2 + src-tauri/src/utils/resolve.rs | 26 + src-tauri/src_crates/crate_mihomo_api/.env | 2 - .../src_crates/crate_mihomo_api/Cargo.toml | 11 - .../src_crates/crate_mihomo_api/src/lib.rs | 147 ----- .../src_crates/crate_mihomo_api/src/model.rs | 5 - .../crate_mihomo_api/tests/test_mihomo_api.rs | 7 - .../common/traffic-error-boundary.tsx | 342 +++++++++++ .../connection/connection-detail.tsx | 2 +- src/components/connection/connection-item.tsx | 2 +- src/components/home/clash-mode-card.tsx | 2 +- src/components/home/current-proxy-card.tsx | 2 +- .../home/enhanced-canvas-traffic-graph.tsx | 570 ++++++++++++++++++ .../home/enhanced-traffic-graph.tsx | 494 --------------- .../home/enhanced-traffic-stats.tsx | 259 ++------ src/components/layout/layout-traffic.tsx | 278 ++++----- src/components/proxy/provider-button.tsx | 2 +- src/components/proxy/proxy-groups.tsx | 2 +- src/components/rule/provider-button.tsx | 2 +- .../setting/mods/clash-core-viewer.tsx | 2 +- .../setting/mods/controller-viewer.tsx | 72 ++- .../setting/mods/sysproxy-viewer.tsx | 2 +- src/components/setting/setting-clash.tsx | 2 +- src/hooks/use-clash.ts | 5 +- src/hooks/use-profiles.ts | 2 +- src/hooks/use-system-proxy-state.ts | 2 +- src/hooks/use-traffic-monitor-enhanced.ts | 398 ++++++++++++ src/hooks/use-traffic-monitor.ts | 245 ++++++++ src/locales/en.json | 10 +- src/locales/zh.json | 10 +- src/pages/connections.tsx | 2 +- src/pages/profiles.tsx | 2 +- src/pages/proxies.tsx | 2 +- src/providers/app-data-provider.tsx | 270 ++------- src/services/api.ts | 288 +-------- src/services/cmds.ts | 318 +++++++++- src/services/types.d.ts | 58 ++ src/utils/data-validator.ts | 230 +++++++ src/utils/traffic-diagnostics.ts | 181 ++++++ 62 files changed, 4029 insertions(+), 1762 deletions(-) create mode 100644 src-tauri/src/ipc/general.rs create mode 100644 src-tauri/src/ipc/memory.rs create mode 100644 src-tauri/src/ipc/mod.rs create mode 100644 src-tauri/src/ipc/traffic.rs delete mode 100644 src-tauri/src/module/mihomo.rs delete mode 100644 src-tauri/src_crates/crate_mihomo_api/.env delete mode 100644 src-tauri/src_crates/crate_mihomo_api/Cargo.toml delete mode 100644 src-tauri/src_crates/crate_mihomo_api/src/lib.rs delete mode 100644 src-tauri/src_crates/crate_mihomo_api/src/model.rs delete mode 100644 src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs create mode 100644 src/components/common/traffic-error-boundary.tsx create mode 100644 src/components/home/enhanced-canvas-traffic-graph.tsx delete mode 100644 src/components/home/enhanced-traffic-graph.tsx create mode 100644 src/hooks/use-traffic-monitor-enhanced.ts create mode 100644 src/hooks/use-traffic-monitor.ts create mode 100644 src/utils/data-validator.ts create mode 100644 src/utils/traffic-diagnostics.ts diff --git a/UPDATELOG.md b/UPDATELOG.md index 1699da040..001c1f12b 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -1,12 +1,42 @@ ## v2.4.0 -### 🐞 修复问题 +### 🏆 重大改进 -- 修复系统主题窗口颜色不一致问题 +- **核心架构升级**:与内核 Mihomo 采用 IPC 通信,不再依赖 Restful API 通信,提升性能和稳定性 +- **流量监控系统重构**:前端实现全新的增强流量监控系统,支持数据压缩、采样和智能缓存 +- **数据验证机制**:引入类型安全的数据验证器,确保 API 响应数据的一致性和可靠性 ### ✨ 新增功能 - 增加 `Verge Version` 复制按钮 +- 新增增强型流量监控 Hook,支持高级数据管理与采样 +- 支持原始/压缩流量数据处理与时间范围查询 +- 引用计数管理器智能收集数据 +- 新增流量监控诊断工具与错误边界组件 +- 多版本画布流量图表,丰富可视化选项 + +### 🚀 性能优化 + +- IPC 通信机制显著提升数据传输效率 +- 智能数据采样和压缩减少内存占用 +- 引用计数机制避免不必要的数据收集,提升整体性能 +- 优化流量图表渲染性能,支持大数据量展示 +- 改进前端数据获取和缓存策略 + +### 🐞 修复问题 + +- 修复系统主题窗口颜色不一致问题 +- 修复 URL 编码处理,正确处理特殊字符 +- 增强代理更新的错误处理机制 +- 修复 JSON 解析错误处理 +- 优化调试日志输出,减少噪音 + +### 🔧 技术改进 + +- 移除过时的 Http 控制 Mihomo,统一使用 IPC 控制 +- 添加外部控制器配置和 UI 支持 +- 改进 IPC 路径处理,支持 Unix 系统特定功能 +- 优化 IPC 目录安全检查和路径解析 ## v2.3.2 @@ -400,7 +430,7 @@ - 新增窗口状态实时监控与自动保存功能 - 增强核心配置变更时的验证与错误处理机制 -- 支持通过环境变量`CLASH_VERGE_REV_IP`自定义复制IP地址 +- 支持通过环境变量 `CLASH_VERGE_REV_IP`自定义复制IP地址 - 添加连接表列宽持久化设置与进程过滤功能 - 新增代理组首字母导航与动态滚动定位功能 - 实现连接追踪暂停/恢复功能 @@ -721,7 +751,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 - 各种细节优化和界面性能优化 @@ -761,7 +791,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 - 各种细节优化和界面性能优化 @@ -797,7 +827,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 ### Bugs Fixes @@ -971,7 +1001,7 @@ ### Features - 缓存代理组图标 -- 使用`boa_engine` 代替 `rquickjs` +- 使用 `boa_engine` 代替 `rquickjs` - 支持 Linux armv7 ### Bugs Fixes @@ -1036,7 +1066,7 @@ - 支持自定义托盘图标 - 支持禁用代理组图标 - 代理组显示当前代理 -- 修改 `打开面板` 快捷键为`打开/关闭面板` +- 修改 `打开面板` 快捷键为 `打开/关闭面板` --- @@ -1200,7 +1230,7 @@ ### Bugs Fixes -- Windows 下更新时无法覆盖`clash-verge-service.exe`的问题(需要卸载重装一次服务,下次更新生效) +- Windows 下更新时无法覆盖 `clash-verge-service.exe`的问题(需要卸载重装一次服务,下次更新生效) - 窗口最大化按钮变化问题 - 窗口尺寸保存错误问题 - 复制环境变量类型无法切换问题 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b9caf4ff7..0bd6bcbb6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2187590a23ab1e3df8681afdf0987c48504d80291f002fcdb651f0ef5e25169" + [[package]] name = "addr2line" version = "0.24.2" @@ -1109,6 +1125,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clash-verge" version = "2.4.0" dependencies = [ + "ab_glyph", "aes-gcm", "anyhow", "async-trait", @@ -1116,22 +1133,23 @@ dependencies = [ "boa_engine", "chrono", "criterion", - "dashmap 7.0.0-rc2", + "dashmap 6.1.0", "deelevate", "delay_timer", "dirs 6.0.0", "dunce", "futures", + "futures-util", "gethostname 1.0.2", "getrandom 0.3.3", "hex", "hmac", "image", + "kode-bridge", "lazy_static", "libc", "log", "log4rs", - "mihomo_api", "nanoid", "network-interface", "once_cell", @@ -1167,6 +1185,8 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "tokio", + "tokio-tungstenite 0.24.0", + "tungstenite 0.27.0", "users", "warp", "winapi", @@ -1589,20 +1609,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "dashmap" -version = "7.0.0-rc2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a1e35a65fe0538a60167f0ada6e195ad5d477f6ddae273943596d4a1a5730b" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "equivalent", - "hashbrown 0.15.4", - "lock_api", - "parking_lot_core", -] - [[package]] name = "data-encoding" version = "2.9.0" @@ -1891,6 +1897,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "document-features" version = "0.2.11" @@ -2840,13 +2852,28 @@ checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ "base64 0.21.7", "bytes", - "headers-core", + "headers-core 0.2.0", "http 0.2.12", "httpdate", "mime", "sha1", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core 0.3.0", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + [[package]] name = "headers-core" version = "0.2.0" @@ -2856,6 +2883,15 @@ dependencies = [ "http 0.2.12", ] +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -3497,6 +3533,21 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "intrusive-collections" version = "0.9.7" @@ -3711,6 +3762,34 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kode-bridge" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "971cfb2bdf5db3721fc822240b4e6e05b5d3aa8c85eb5f7ad4dc25ed0a3ad7e0" +dependencies = [ + "bytes", + "futures", + "headers 0.4.1", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "httparse", + "hyper 1.6.0", + "interprocess", + "once_cell", + "parking_lot", + "pin-project-lite", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "toml 0.8.23", + "tracing", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -4083,16 +4162,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mihomo_api" -version = "0.0.0" -dependencies = [ - "reqwest", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "mime" version = "0.3.17" @@ -4864,6 +4933,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owned_ttf_parser" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -5769,6 +5847,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.15" @@ -7804,7 +7888,19 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", ] [[package]] @@ -8168,6 +8264,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.21.0" @@ -8187,6 +8289,41 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8483,7 +8620,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "headers", + "headers 0.3.9", "http 0.2.12", "hyper 0.14.32", "log", @@ -8497,7 +8634,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.21.0", "tokio-util", "tower-service", "tracing", @@ -8808,6 +8945,12 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ad7f395d..10288d344 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,8 @@ tokio = { version = "1.46.1", features = [ "time", "sync", ] } +tokio-tungstenite = "0.24.0" +futures-util = "0.3.31" serde = { version = "1.0.219", features = ["derive"] } reqwest = { version = "0.12.22", features = ["json", "rustls-tls", "cookies"] } regex = "1.11.1" @@ -70,15 +72,17 @@ getrandom = "0.3.3" futures = "0.3.31" sys-locale = "0.3.2" async-trait = "0.1.88" -mihomo_api = { path = "src_crates/crate_mihomo_api" } +ab_glyph = "0.2.29" +tungstenite = "0.27.0" libc = "0.2.174" gethostname = "1.0.2" hmac = "0.12.1" sha2 = "0.10.9" hex = "0.4.3" scopeguard = "1.2.0" +kode-bridge = "0.1.5" +dashmap = "6.1.0" tauri-plugin-notification = "2.3.0" -dashmap = "7.0.0-rc2" [target.'cfg(windows)'.dependencies] runas = "=1.2.0" @@ -141,10 +145,3 @@ crate-type = ["staticlib", "cdylib", "rlib"] [dev-dependencies] criterion = "0.6.0" tempfile = "3.20.0" - -[workspace] -members = ["src_crates/crate_mihomo_api"] - -[[bench]] -name = "draft_benchmark" -harness = false diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index 02b9ad966..499aeec30 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -1,7 +1,5 @@ use super::CmdResult; -use crate::{ - config::*, core::*, feat, module::mihomo::MihomoManager, process::AsyncHandler, wrap_err, -}; +use crate::{config::*, core::*, feat, ipc::IpcManager, process::AsyncHandler, wrap_err}; use serde_yaml::Mapping; /// 复制Clash环境变量 @@ -90,9 +88,11 @@ pub async fn clash_api_get_proxy_delay( url: Option, timeout: i32, ) -> CmdResult { - MihomoManager::global() - .test_proxy_delay(&name, url, timeout) - .await + wrap_err!( + IpcManager::global() + .test_proxy_delay(&name, url, timeout) + .await + ) } /// 测试URL延迟 @@ -267,3 +267,273 @@ pub async fn validate_dns_config() -> CmdResult<(bool, String)> { Err(e) => Err(e.to_string()), } } + +/// 获取Clash版本信息 +#[tauri::command] +pub async fn get_clash_version() -> CmdResult { + wrap_err!(IpcManager::global().get_version().await) +} + +/// 获取Clash配置 +#[tauri::command] +pub async fn get_clash_config() -> CmdResult { + wrap_err!(IpcManager::global().get_config().await) +} + +/// 更新地理数据 +#[tauri::command] +pub async fn update_geo_data() -> CmdResult { + wrap_err!(IpcManager::global().update_geo_data().await) +} + +/// 升级Clash核心 +#[tauri::command] +pub async fn upgrade_clash_core() -> CmdResult { + wrap_err!(IpcManager::global().upgrade_core().await) +} + +/// 获取规则 +#[tauri::command] +pub async fn get_clash_rules() -> CmdResult { + wrap_err!(IpcManager::global().get_rules().await) +} + +/// 更新代理选择 +#[tauri::command] +pub async fn update_proxy_choice(group: String, proxy: String) -> CmdResult { + wrap_err!(IpcManager::global().update_proxy(&group, &proxy).await) +} + +/// 获取代理提供者 +#[tauri::command] +pub async fn get_proxy_providers() -> CmdResult { + wrap_err!(IpcManager::global().get_providers_proxies().await) +} + +/// 获取规则提供者 +#[tauri::command] +pub async fn get_rule_providers() -> CmdResult { + wrap_err!(IpcManager::global().get_rule_providers().await) +} + +/// 代理提供者健康检查 +#[tauri::command] +pub async fn proxy_provider_health_check(name: String) -> CmdResult { + wrap_err!( + IpcManager::global() + .proxy_provider_health_check(&name) + .await + ) +} + +/// 更新代理提供者 +#[tauri::command] +pub async fn update_proxy_provider(name: String) -> CmdResult { + wrap_err!(IpcManager::global().update_proxy_provider(&name).await) +} + +/// 更新规则提供者 +#[tauri::command] +pub async fn update_rule_provider(name: String) -> CmdResult { + wrap_err!(IpcManager::global().update_rule_provider(&name).await) +} + +/// 获取连接 +#[tauri::command] +pub async fn get_clash_connections() -> CmdResult { + wrap_err!(IpcManager::global().get_connections().await) +} + +/// 删除连接 +#[tauri::command] +pub async fn delete_clash_connection(id: String) -> CmdResult { + wrap_err!(IpcManager::global().delete_connection(&id).await) +} + +/// 关闭所有连接 +#[tauri::command] +pub async fn close_all_clash_connections() -> CmdResult { + wrap_err!(IpcManager::global().close_all_connections().await) +} + +/// 获取流量数据 (使用新的IPC流式监控) +#[tauri::command] +pub async fn get_traffic_data() -> CmdResult { + log::info!(target: "app", "开始获取流量数据 (IPC流式)"); + let traffic = crate::ipc::get_current_traffic().await; + let result = serde_json::json!({ + "up": traffic.total_up, + "down": traffic.total_down, + "up_rate": traffic.up_rate, + "down_rate": traffic.down_rate, + "last_updated": traffic.last_updated.elapsed().as_secs() + }); + log::info!(target: "app", "获取流量数据结果: up={}, down={}, up_rate={}, down_rate={}", + traffic.total_up, traffic.total_down, traffic.up_rate, traffic.down_rate); + Ok(result) +} + +/// 获取内存数据 (使用新的IPC流式监控) +#[tauri::command] +pub async fn get_memory_data() -> CmdResult { + log::info!(target: "app", "开始获取内存数据 (IPC流式)"); + let memory = crate::ipc::get_current_memory().await; + let usage_percent = if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + }; + let result = serde_json::json!({ + "inuse": memory.inuse, + "oslimit": memory.oslimit, + "usage_percent": usage_percent, + "last_updated": memory.last_updated.elapsed().as_secs() + }); + log::info!(target: "app", "获取内存数据结果: inuse={}, oslimit={}, usage={}%", + memory.inuse, memory.oslimit, usage_percent); + Ok(result) +} + +/// 启动流量监控服务 (IPC流式监控自动启动,此函数为兼容性保留) +#[tauri::command] +pub async fn start_traffic_service() -> CmdResult { + log::info!(target: "app", "启动流量监控服务 (IPC流式监控)"); + // 新的IPC监控在首次访问时自动启动 + // 触发一次访问以确保监控器已初始化 + let _ = crate::ipc::get_current_traffic().await; + let _ = crate::ipc::get_current_memory().await; + log::info!(target: "app", "IPC流式监控已激活"); + Ok(()) +} + +/// 停止流量监控服务 (IPC流式监控无需显式停止,此函数为兼容性保留) +#[tauri::command] +pub async fn stop_traffic_service() -> CmdResult { + log::info!(target: "app", "停止流量监控服务请求 (IPC流式监控)"); + // 新的IPC监控是持久的,无需显式停止 + log::info!(target: "app", "IPC流式监控继续运行"); + Ok(()) +} + +/// 获取格式化的流量数据 (包含单位,便于前端显示) +#[tauri::command] +pub async fn get_formatted_traffic_data() -> CmdResult { + log::info!(target: "app", "获取格式化流量数据"); + let (up_rate, down_rate, total_up, total_down, is_fresh) = + crate::ipc::get_formatted_traffic().await; + let result = serde_json::json!({ + "up_rate_formatted": up_rate, + "down_rate_formatted": down_rate, + "total_up_formatted": total_up, + "total_down_formatted": total_down, + "is_fresh": is_fresh + }); + log::debug!(target: "app", "格式化流量数据: ↑{up_rate}/s ↓{down_rate}/s (总计: ↑{total_up} ↓{total_down})"); + // Clippy: variables can be used directly in the format string + // log::debug!(target: "app", "格式化流量数据: ↑{up_rate}/s ↓{down_rate}/s (总计: ↑{total_up} ↓{total_down})"); + Ok(result) +} + +/// 获取格式化的内存数据 (包含单位,便于前端显示) +#[tauri::command] +pub async fn get_formatted_memory_data() -> CmdResult { + log::info!(target: "app", "获取格式化内存数据"); + let (inuse, oslimit, usage_percent, is_fresh) = crate::ipc::get_formatted_memory().await; + let result = serde_json::json!({ + "inuse_formatted": inuse, + "oslimit_formatted": oslimit, + "usage_percent": usage_percent, + "is_fresh": is_fresh + }); + log::debug!(target: "app", "格式化内存数据: {inuse} / {oslimit} ({usage_percent:.1}%)"); + // Clippy: variables can be used directly in the format string + // log::debug!(target: "app", "格式化内存数据: {inuse} / {oslimit} ({usage_percent:.1}%)"); + Ok(result) +} + +/// 获取系统监控概览 (流量+内存,便于前端一次性获取所有状态) +#[tauri::command] +pub async fn get_system_monitor_overview() -> CmdResult { + log::debug!(target: "app", "获取系统监控概览"); + + // 并发获取流量和内存数据 + let (traffic, memory) = tokio::join!( + crate::ipc::get_current_traffic(), + crate::ipc::get_current_memory() + ); + + let (traffic_formatted, memory_formatted) = tokio::join!( + crate::ipc::get_formatted_traffic(), + crate::ipc::get_formatted_memory() + ); + + let traffic_is_fresh = traffic.last_updated.elapsed().as_secs() < 5; + let memory_is_fresh = memory.last_updated.elapsed().as_secs() < 10; + + let result = serde_json::json!({ + "traffic": { + "raw": { + "up": traffic.total_up, + "down": traffic.total_down, + "up_rate": traffic.up_rate, + "down_rate": traffic.down_rate + }, + "formatted": { + "up_rate": traffic_formatted.0, + "down_rate": traffic_formatted.1, + "total_up": traffic_formatted.2, + "total_down": traffic_formatted.3 + }, + "is_fresh": traffic_is_fresh + }, + "memory": { + "raw": { + "inuse": memory.inuse, + "oslimit": memory.oslimit, + "usage_percent": if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + } + }, + "formatted": { + "inuse": memory_formatted.0, + "oslimit": memory_formatted.1, + "usage_percent": memory_formatted.2 + }, + "is_fresh": memory_is_fresh + }, + "overall_status": if traffic_is_fresh && memory_is_fresh { "healthy" } else { "stale" } + }); + + Ok(result) +} + +/// 获取代理组延迟 +#[tauri::command] +pub async fn get_group_proxy_delays( + group_name: String, + url: Option, + timeout: Option, +) -> CmdResult { + wrap_err!( + IpcManager::global() + .get_group_proxy_delays(&group_name, url, timeout.unwrap_or(10000)) + .await + ) +} + +/// 检查调试是否启用 +#[tauri::command] +pub async fn is_clash_debug_enabled() -> CmdResult { + match IpcManager::global().is_debug_enabled().await { + Ok(enabled) => Ok(enabled), + Err(_) => Ok(false), + } +} + +/// 垃圾回收 +#[tauri::command] +pub async fn clash_gc() -> CmdResult { + wrap_err!(IpcManager::global().gc().await) +} diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 062ef785e..724a69f4c 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -1,6 +1,5 @@ use anyhow::Result; -// Common result type used by command functions pub type CmdResult = Result; // Command modules diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 1ba6eec43..007118a92 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -1,20 +1,18 @@ use super::CmdResult; -use crate::module::mihomo::MihomoManager; +use crate::{ipc::IpcManager, state::proxy::ProxyRequestCache}; use std::time::Duration; -use crate::state::proxy::ProxyRequestCache; - const PROXIES_REFRESH_INTERVAL: Duration = Duration::from_secs(60); const PROVIDERS_REFRESH_INTERVAL: Duration = Duration::from_secs(60); #[tauri::command] pub async fn get_proxies() -> CmdResult { - let manager = MihomoManager::global(); + let manager = IpcManager::global(); let cache = ProxyRequestCache::global(); let key = ProxyRequestCache::make_key("proxies", "default"); let value = cache .get_or_fetch(key, PROXIES_REFRESH_INTERVAL, || async { - manager.get_refresh_proxies().await.expect("fetch failed") + manager.get_proxies().await.expect("fetch failed") }) .await; Ok((*value).clone()) @@ -31,7 +29,7 @@ pub async fn force_refresh_proxies() -> CmdResult { #[tauri::command] pub async fn get_providers_proxies() -> CmdResult { - let manager = MihomoManager::global(); + let manager = IpcManager::global(); let cache = ProxyRequestCache::global(); let key = ProxyRequestCache::make_key("providers", "default"); let value = cache diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index df3eec24f..81a91d342 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -1,3 +1,5 @@ +use crate::config::Config; +use crate::utils::dirs::{ipc_path, path_to_str}; use crate::utils::{dirs, help}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -57,6 +59,16 @@ impl IClashTemp { map.insert("ipv6".into(), true.into()); map.insert("mode".into(), "rule".into()); map.insert("external-controller".into(), "127.0.0.1:9097".into()); + #[cfg(unix)] + map.insert( + "external-controller-unix".into(), + Self::guard_external_controller_ipc().into(), + ); + #[cfg(windows)] + map.insert( + "external-controller-pipe".into(), + Self::guard_external_controller_ipc().into(), + ); cors_map.insert("allow-private-network".into(), true.into()); cors_map.insert( "allow-origins".into(), @@ -87,7 +99,12 @@ impl IClashTemp { let mixed_port = Self::guard_mixed_port(&config); let socks_port = Self::guard_socks_port(&config); let port = Self::guard_port(&config); - let ctrl = Self::guard_server_ctrl(&config); + let ctrl = Self::guard_external_controller(&config); + #[cfg(unix)] + let external_controller_unix = Self::guard_external_controller_ipc(); + #[cfg(windows)] + let external_controller_pipe = Self::guard_external_controller_ipc(); + #[cfg(not(target_os = "windows"))] config.insert("redir-port".into(), redir_port.into()); #[cfg(target_os = "linux")] @@ -97,6 +114,16 @@ impl IClashTemp { config.insert("port".into(), port.into()); config.insert("external-controller".into(), ctrl.into()); + #[cfg(unix)] + config.insert( + "external-controller-unix".into(), + external_controller_unix.into(), + ); + #[cfg(windows)] + config.insert( + "external-controller-pipe".into(), + external_controller_pipe.into(), + ); config } @@ -245,6 +272,26 @@ impl IClashTemp { .unwrap_or("127.0.0.1:9097".into()) } + pub fn guard_external_controller(config: &Mapping) -> String { + // 在初始化阶段,直接返回配置中的值,不进行额外检查 + // 这样可以避免在配置加载期间的循环依赖 + Self::guard_server_ctrl(config) + } + + pub fn guard_external_controller_with_setting(config: &Mapping) -> String { + // 检查 enable_external_controller 设置,用于运行时配置生成 + let enable_external_controller = Config::verge() + .latest_ref() + .enable_external_controller + .unwrap_or(false); + + if enable_external_controller { + Self::guard_server_ctrl(config) + } else { + "".into() + } + } + pub fn guard_client_ctrl(config: &Mapping) -> String { let value = Self::guard_server_ctrl(config); match SocketAddr::from_str(value.as_str()) { @@ -257,6 +304,11 @@ impl IClashTemp { Err(_) => "127.0.0.1:9097".into(), } } + + pub fn guard_external_controller_ipc() -> String { + // 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致 + path_to_str(&ipc_path().unwrap()).unwrap().to_string() + } } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 1a9a7bf1d..b22d801ca 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -204,6 +204,9 @@ pub struct IVerge { /// 启用代理页面自动滚动 pub enable_hover_jump_navigator: Option, + /// 启用外部控制器 + pub enable_external_controller: Option, + /// 服务状态跟踪 pub service_state: Option, } @@ -403,6 +406,7 @@ impl IVerge { enable_dns_settings: Some(false), home_cards: None, service_state: None, + enable_external_controller: Some(false), ..Self::default() } } @@ -491,6 +495,7 @@ impl IVerge { patch!(enable_dns_settings); patch!(home_cards); patch!(service_state); + patch!(enable_external_controller); } /// 在初始化前尝试拿到单例端口的值 @@ -586,6 +591,7 @@ pub struct IVergeResponse { pub enable_dns_settings: Option, pub home_cards: Option, pub enable_hover_jump_navigator: Option, + pub enable_external_controller: Option, pub service_state: Option, } @@ -658,6 +664,7 @@ impl From for IVergeResponse { enable_dns_settings: verge.enable_dns_settings, home_cards: verge.home_cards, enable_hover_jump_navigator: verge.enable_hover_jump_navigator, + enable_external_controller: verge.enable_external_controller, service_state: verge.service_state, } } diff --git a/src-tauri/src/core/core.rs b/src-tauri/src/core/core.rs index fe1481ae1..be5f2c493 100644 --- a/src-tauri/src/core/core.rs +++ b/src-tauri/src/core/core.rs @@ -4,8 +4,8 @@ use crate::{ handle, service::{self}, }, + ipc::IpcManager, logging, logging_error, - module::mihomo::MihomoManager, utils::{ dirs, help::{self}, @@ -413,10 +413,7 @@ impl CoreManager { logging_error!(Type::Core, true, "{}", msg); msg }); - match MihomoManager::global() - .put_configs_force(run_path_str?) - .await - { + match IpcManager::global().put_configs_force(run_path_str?).await { Ok(_) => { Config::runtime().apply(); logging!(info, Type::Core, true, "Configuration updated successfully"); diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index a2b822d8f..e7b1c2f78 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -2,11 +2,12 @@ use once_cell::sync::OnceCell; use tauri::tray::TrayIconBuilder; #[cfg(target_os = "macos")] pub mod speed_rate; +use crate::ipc::Rate; use crate::{ cmd, config::Config, feat, logging, - module::{lightweight::is_in_lightweight_mode, mihomo::Rate}, + module::lightweight::is_in_lightweight_mode, utils::{dirs::find_target_icons, i18n::t, resolve::VERSION}, Type, }; diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index a3fa567f8..9ede0bad0 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -234,7 +234,22 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { continue; } } - config.insert(key, value); + // 处理 external-controller 键的开关逻辑 + if key.as_str() == Some("external-controller") { + let enable_external_controller = Config::verge() + .latest_ref() + .enable_external_controller + .unwrap_or(false); + + if enable_external_controller { + config.insert(key, value); + } else { + // 如果禁用了外部控制器,设置为空字符串 + config.insert(key, "".into()); + } + } else { + config.insert(key, value); + } } } diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 3afab1f3d..33c1fb152 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -1,8 +1,8 @@ use crate::{ config::Config, core::{handle, tray, CoreManager}, + ipc::IpcManager, logging_error, - module::mihomo::MihomoManager, process::AsyncHandler, utils::{logging::Type, resolve}, }; @@ -38,12 +38,12 @@ pub fn restart_app() { fn after_change_clash_mode() { AsyncHandler::spawn(move || async { - match MihomoManager::global().get_connections().await { + match IpcManager::global().get_connections().await { Ok(connections) => { if let Some(connections_array) = connections["connections"].as_array() { for connection in connections_array { if let Some(id) = connection["id"].as_str() { - let _ = MihomoManager::global().delete_connection(id).await; + let _ = IpcManager::global().delete_connection(id).await; } } } @@ -65,7 +65,7 @@ pub fn change_clash_mode(mode: String) { }); AsyncHandler::spawn(move || async move { log::debug!(target: "app", "change clash mode to {mode}"); - match MihomoManager::global().patch_configs(json_value).await { + match IpcManager::global().patch_configs(json_value).await { Ok(_) => { // 更新订阅 Config::clash().data_mut().patch_config(mapping); diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 2436cbdb0..7a7b72738 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -95,6 +95,7 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { let tray_event = patch.tray_event; let home_cards = patch.home_cards.clone(); let enable_auto_light_weight = patch.enable_auto_light_weight_mode; + let enable_external_controller = patch.enable_external_controller; let res: std::result::Result<(), anyhow::Error> = { // Initialize with no flags set let mut update_flags: i32 = UpdateFlags::None as i32; @@ -165,6 +166,11 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { update_flags |= UpdateFlags::LighteWeight as i32; } + // 处理 external-controller 的开关 + if enable_external_controller.is_some() { + update_flags |= UpdateFlags::RestartCore as i32; + } + // Process updates based on flags if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 { Config::generate().await?; diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index 1568d505e..c35642382 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -1,6 +1,7 @@ use crate::{ config::{Config, IVerge}, core::handle, + ipc::IpcManager, process::AsyncHandler, }; use std::env; @@ -18,10 +19,7 @@ pub fn toggle_system_proxy() { AsyncHandler::spawn(move || async move { // 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接 if enable && auto_close_connection { - if let Err(err) = crate::module::mihomo::MihomoManager::global() - .close_all_connections() - .await - { + if let Err(err) = IpcManager::global().close_all_connections().await { log::error!(target: "app", "Failed to close all connections: {err}"); } } diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index 40dfb4355..d301eb7cc 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -3,8 +3,8 @@ use crate::AppHandleManager; use crate::{ config::Config, core::{handle, sysopt, CoreManager}, + ipc::IpcManager, logging, - module::mihomo::MihomoManager, utils::logging::Type, }; @@ -107,7 +107,7 @@ async fn clean_async() -> bool { }); match timeout( Duration::from_secs(2), - MihomoManager::global().patch_configs(disable_tun), + IpcManager::global().patch_configs(disable_tun), ) .await { diff --git a/src-tauri/src/ipc/general.rs b/src-tauri/src/ipc/general.rs new file mode 100644 index 000000000..feff490d9 --- /dev/null +++ b/src-tauri/src/ipc/general.rs @@ -0,0 +1,397 @@ +use kode_bridge::{ + errors::{AnyError, AnyResult}, + IpcHttpClient, LegacyResponse, +}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use std::sync::OnceLock; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +// Helper function to create AnyError from string +fn create_error(msg: impl Into) -> AnyError { + Box::new(std::io::Error::other(msg.into())) +} + +pub struct IpcManager { + ipc_path: String, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl IpcManager { + pub fn global() -> &'static IpcManager { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let instance = IpcManager { + ipc_path: ipc_path.to_string(), + }; + logging!( + info, + Type::Ipc, + true, + "IpcManager initialized with IPC path: {}", + instance.ipc_path + ); + instance + }) + } +} + +impl IpcManager { + pub async fn request( + &self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + ) -> AnyResult { + let client = IpcHttpClient::new(&self.ipc_path)?; + client.request(method, path, body).await + } +} + +impl IpcManager { + pub async fn send_request( + &self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + ) -> AnyResult { + let response = IpcManager::global().request(method, path, body).await?; + match method { + "GET" => Ok(response.json()?), + "PATCH" => { + if response.status == 204 { + Ok(serde_json::json!({"code": 204})) + } else { + Ok(response.json()?) + } + } + "PUT" => { + if response.status == 204 { + Ok(serde_json::json!({"code": 204})) + } else { + // 尝试解析JSON,如果失败则返回错误信息 + match response.json() { + Ok(json) => Ok(json), + Err(_) => Ok(serde_json::json!({ + "code": response.status, + "message": response.body, + "error": "failed to parse response as JSON" + })), + } + } + } + _ => Ok(response.json()?), + } + } + + // 基础代理信息获取 + pub async fn get_proxies(&self) -> AnyResult { + let url = "/proxies"; + self.send_request("GET", url, None).await + } + + // 代理提供者信息获取 + pub async fn get_providers_proxies(&self) -> AnyResult { + let url = "/providers/proxies"; + self.send_request("GET", url, None).await + } + + // 连接管理 + pub async fn get_connections(&self) -> AnyResult { + let url = "/connections"; + self.send_request("GET", url, None).await + } + + pub async fn delete_connection(&self, id: &str) -> AnyResult<()> { + let encoded_id = utf8_percent_encode(id, NON_ALPHANUMERIC).to_string(); + let url = format!("/connections/{encoded_id}"); + let response = self.send_request("DELETE", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"].as_str().unwrap_or("unknown error"), + )) + } + } + + pub async fn close_all_connections(&self) -> AnyResult<()> { + let url = "/connections"; + let response = self.send_request("DELETE", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_owned(), + )) + } + } +} + +impl IpcManager { + #[allow(dead_code)] + pub async fn is_mihomo_running(&self) -> AnyResult<()> { + let url = "/version"; + let _response = self.send_request("GET", url, None).await?; + Ok(()) + } + + pub async fn put_configs_force(&self, clash_config_path: &str) -> AnyResult<()> { + let url = "/configs?force=true"; + let payload = serde_json::json!({ + "path": clash_config_path, + }); + let _response = self.send_request("PUT", url, Some(&payload)).await?; + Ok(()) + } + + pub async fn patch_configs(&self, config: serde_json::Value) -> AnyResult<()> { + let url = "/configs"; + let response = self.send_request("PATCH", url, Some(&config)).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_owned(), + )) + } + } + + pub async fn test_proxy_delay( + &self, + name: &str, + test_url: Option, + timeout: i32, + ) -> AnyResult { + let test_url = + test_url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string()); + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let encoded_test_url = utf8_percent_encode(&test_url, NON_ALPHANUMERIC).to_string(); + let url = format!("/proxies/{encoded_name}/delay?url={encoded_test_url}&timeout={timeout}"); + let response = self.send_request("GET", &url, None).await?; + Ok(response) + } + + // 版本和配置相关 + pub async fn get_version(&self) -> AnyResult { + let url = "/version"; + self.send_request("GET", url, None).await + } + + pub async fn get_config(&self) -> AnyResult { + let url = "/configs"; + self.send_request("GET", url, None).await + } + + pub async fn update_geo_data(&self) -> AnyResult<()> { + let url = "/configs/geo"; + let response = self.send_request("POST", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + pub async fn upgrade_core(&self) -> AnyResult<()> { + let url = "/upgrade"; + let response = self.send_request("POST", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 规则相关 + pub async fn get_rules(&self) -> AnyResult { + let url = "/rules"; + self.send_request("GET", url, None).await + } + + pub async fn get_rule_providers(&self) -> AnyResult { + let url = "/providers/rules"; + self.send_request("GET", url, None).await + } + + pub async fn update_rule_provider(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/rules/{encoded_name}"); + let response = self.send_request("PUT", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 代理相关 + pub async fn update_proxy(&self, group: &str, proxy: &str) -> AnyResult<()> { + // 使用 percent-encoding 进行正确的 URL 编码 + let encoded_group = utf8_percent_encode(group, NON_ALPHANUMERIC).to_string(); + let url = format!("/proxies/{encoded_group}"); + let payload = serde_json::json!({ + "name": proxy + }); + + let response = match self.send_request("PUT", &url, Some(&payload)).await { + Ok(resp) => resp, + Err(e) => { + logging!( + error, + crate::utils::logging::Type::Ipc, + true, + "IPC: updateProxy encountered error: {} (ignored, always returning true)", + e + ); + // Always return a successful response as serde_json::Value + serde_json::json!({"code": 204}) + } + }; + + if response["code"] == 204 { + Ok(()) + } else { + let error_msg = response["message"].as_str().unwrap_or_else(|| { + if let Some(error) = response.get("error") { + error.as_str().unwrap_or("unknown error") + } else { + "failed to update proxy" + } + }); + + logging!( + error, + crate::utils::logging::Type::Ipc, + true, + "IPC: updateProxy failed: {}", + error_msg + ); + + Err(create_error(error_msg.to_string())) + } + } + + pub async fn proxy_provider_health_check(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/proxies/{encoded_name}/healthcheck"); + let response = self.send_request("GET", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + pub async fn update_proxy_provider(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/proxies/{encoded_name}"); + let response = self.send_request("PUT", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 延迟测试相关 + pub async fn get_group_proxy_delays( + &self, + group_name: &str, + url: Option, + timeout: i32, + ) -> AnyResult { + let test_url = url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string()); + let encoded_group_name = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); + let encoded_test_url = utf8_percent_encode(&test_url, NON_ALPHANUMERIC).to_string(); + let url = + format!("/group/{encoded_group_name}/delay?url={encoded_test_url}&timeout={timeout}"); + self.send_request("GET", &url, None).await + } + + // 调试相关 + pub async fn is_debug_enabled(&self) -> AnyResult { + let url = "/debug/pprof"; + match self.send_request("GET", url, None).await { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } + + pub async fn gc(&self) -> AnyResult<()> { + let url = "/debug/gc"; + let response = self.send_request("PUT", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 流量数据相关 + #[allow(dead_code)] + pub async fn get_traffic(&self) -> AnyResult { + let url = "/traffic"; + logging!(info, Type::Ipc, true, "IPC: 发送 GET 请求到 {}", url); + let result = self.send_request("GET", url, None).await; + logging!( + info, + Type::Ipc, + true, + "IPC: /traffic 请求结果: {:?}", + result + ); + result + } + + // 内存相关 + #[allow(dead_code)] + pub async fn get_memory(&self) -> AnyResult { + let url = "/memory"; + logging!(info, Type::Ipc, true, "IPC: 发送 GET 请求到 {}", url); + let result = self.send_request("GET", url, None).await; + logging!(info, Type::Ipc, true, "IPC: /memory 请求结果: {:?}", result); + result + } +} diff --git a/src-tauri/src/ipc/memory.rs b/src-tauri/src/ipc/memory.rs new file mode 100644 index 000000000..778f6e2d9 --- /dev/null +++ b/src-tauri/src/ipc/memory.rs @@ -0,0 +1,135 @@ +use kode_bridge::IpcStreamClient; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{Arc, OnceLock}, + time::Instant, +}; +use tokio::{sync::RwLock, time::Duration}; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MemoryData { + pub inuse: u64, + pub oslimit: u64, +} + +#[derive(Debug, Clone)] +pub struct CurrentMemory { + pub inuse: u64, + pub oslimit: u64, + pub last_updated: Instant, +} + +impl Default for CurrentMemory { + fn default() -> Self { + Self { + inuse: 0, + oslimit: 0, + last_updated: Instant::now(), + } + } +} + +// Minimal memory monitor +pub struct MemoryMonitor { + current: Arc>, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl MemoryMonitor { + pub fn global() -> &'static MemoryMonitor { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let client = IpcStreamClient::new(ipc_path).unwrap(); + + let instance = MemoryMonitor::new(client); + logging!( + info, + Type::Ipc, + true, + "MemoryMonitor initialized with IPC path: {}", + ipc_path + ); + instance + }) + } + + fn new(client: IpcStreamClient) -> Self { + let current = Arc::new(RwLock::new(CurrentMemory::default())); + let monitor_current = current.clone(); + + tokio::spawn(async move { + loop { + let _ = client + .get("/memory") + .timeout(Duration::from_secs(10)) + .process_lines(|line| { + if let Ok(memory) = serde_json::from_str::(line.trim()) { + tokio::spawn({ + let current = monitor_current.clone(); + async move { + *current.write().await = CurrentMemory { + inuse: memory.inuse, + oslimit: memory.oslimit, + last_updated: Instant::now(), + }; + } + }); + } + Ok(()) + }) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; // Memory updates less frequently + } + }); + + Self { current } + } + + pub async fn current(&self) -> CurrentMemory { + self.current.read().await.clone() + } + + pub async fn is_fresh(&self) -> bool { + self.current.read().await.last_updated.elapsed() < Duration::from_secs(10) + } +} + +fn fmt_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let (mut val, mut unit) = (bytes as f64, 0); + while val >= 1024.0 && unit < 3 { + val /= 1024.0; + unit += 1; + } + format!("{:.1}{}", val, UNITS[unit]) +} + +pub async fn get_current_memory() -> CurrentMemory { + MemoryMonitor::global().current().await +} + +pub async fn get_formatted_memory() -> (String, String, f64, bool) { + let monitor = MemoryMonitor::global(); + let memory = monitor.current().await; + let is_fresh = monitor.is_fresh().await; + + let usage_percent = if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + }; + + ( + fmt_bytes(memory.inuse), + fmt_bytes(memory.oslimit), + usage_percent, + is_fresh, + ) +} diff --git a/src-tauri/src/ipc/mod.rs b/src-tauri/src/ipc/mod.rs new file mode 100644 index 000000000..7bddb68ef --- /dev/null +++ b/src-tauri/src/ipc/mod.rs @@ -0,0 +1,12 @@ +pub mod general; +pub mod memory; +pub mod traffic; + +pub use general::IpcManager; +pub use memory::{get_current_memory, get_formatted_memory}; +pub use traffic::{get_current_traffic, get_formatted_traffic}; + +pub struct Rate { + // pub up: usize, + // pub down: usize, +} diff --git a/src-tauri/src/ipc/traffic.rs b/src-tauri/src/ipc/traffic.rs new file mode 100644 index 000000000..0e40e88f2 --- /dev/null +++ b/src-tauri/src/ipc/traffic.rs @@ -0,0 +1,148 @@ +use kode_bridge::IpcStreamClient; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{Arc, OnceLock}, + time::Instant, +}; +use tokio::{sync::RwLock, time::Duration}; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TrafficData { + pub up: u64, + pub down: u64, +} + +#[derive(Debug, Clone)] +pub struct CurrentTraffic { + pub up_rate: u64, + pub down_rate: u64, + pub total_up: u64, + pub total_down: u64, + pub last_updated: Instant, +} + +impl Default for CurrentTraffic { + fn default() -> Self { + Self { + up_rate: 0, + down_rate: 0, + total_up: 0, + total_down: 0, + last_updated: Instant::now(), + } + } +} + +// Minimal traffic monitor +pub struct TrafficMonitor { + current: Arc>, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl TrafficMonitor { + pub fn global() -> &'static TrafficMonitor { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let client = IpcStreamClient::new(ipc_path).unwrap(); + + let instance = TrafficMonitor::new(client); + logging!( + info, + Type::Ipc, + true, + "TrafficMonitor initialized with IPC path: {}", + ipc_path + ); + instance + }) + } + + fn new(client: IpcStreamClient) -> Self { + let current = Arc::new(RwLock::new(CurrentTraffic::default())); + let monitor_current = current.clone(); + + tokio::spawn(async move { + let mut last: Option = None; + loop { + let _ = client + .get("/traffic") + .timeout(Duration::from_secs(10)) + .process_lines(|line| { + if let Ok(traffic) = serde_json::from_str::(line.trim()) { + let (up_rate, down_rate) = last + .as_ref() + .map(|l| { + ( + traffic.up.saturating_sub(l.up), + traffic.down.saturating_sub(l.down), + ) + }) + .unwrap_or((0, 0)); + + tokio::spawn({ + let current = monitor_current.clone(); + async move { + *current.write().await = CurrentTraffic { + up_rate, + down_rate, + total_up: traffic.up, + total_down: traffic.down, + last_updated: Instant::now(), + }; + } + }); + last = Some(traffic); + } + Ok(()) + }) + .await; + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + + Self { current } + } + + pub async fn current(&self) -> CurrentTraffic { + self.current.read().await.clone() + } + + pub async fn is_fresh(&self) -> bool { + self.current.read().await.last_updated.elapsed() < Duration::from_secs(5) + } +} + +fn fmt_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let (mut val, mut unit) = (bytes as f64, 0); + while val >= 1024.0 && unit < 3 { + val /= 1024.0; + unit += 1; + } + format!("{:.1}{}", val, UNITS[unit]) +} + +pub async fn get_current_traffic() -> CurrentTraffic { + TrafficMonitor::global().current().await +} + +pub async fn get_formatted_traffic() -> (String, String, String, String, bool) { + let monitor = TrafficMonitor::global(); + let traffic = monitor.current().await; + let is_fresh = monitor.is_fresh().await; + + ( + fmt_bytes(traffic.up_rate), + fmt_bytes(traffic.down_rate), + fmt_bytes(traffic.total_up), + fmt_bytes(traffic.total_down), + is_fresh, + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2f8a1ddd..d71526191 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; mod core; mod enhance; mod feat; +mod ipc; mod module; mod process; mod state; @@ -271,6 +272,30 @@ pub fn run() { cmd::check_dns_config_exists, cmd::get_dns_config_content, cmd::validate_dns_config, + cmd::get_clash_version, + cmd::get_clash_config, + cmd::update_geo_data, + cmd::upgrade_clash_core, + cmd::get_clash_rules, + cmd::update_proxy_choice, + cmd::get_proxy_providers, + cmd::get_rule_providers, + cmd::proxy_provider_health_check, + cmd::update_proxy_provider, + cmd::update_rule_provider, + cmd::get_clash_connections, + cmd::delete_clash_connection, + cmd::close_all_clash_connections, + cmd::get_group_proxy_delays, + cmd::is_clash_debug_enabled, + cmd::clash_gc, + cmd::get_traffic_data, + cmd::get_memory_data, + cmd::get_formatted_traffic_data, + cmd::get_formatted_memory_data, + cmd::get_system_monitor_overview, + cmd::start_traffic_service, + cmd::stop_traffic_service, // verge cmd::get_verge_config, cmd::patch_verge_config, diff --git a/src-tauri/src/module/mihomo.rs b/src-tauri/src/module/mihomo.rs deleted file mode 100644 index c11ea58bb..000000000 --- a/src-tauri/src/module/mihomo.rs +++ /dev/null @@ -1,108 +0,0 @@ -use crate::config::Config; -use mihomo_api; -use once_cell::sync::Lazy; -use parking_lot::{Mutex, RwLock}; -use std::time::{Duration, Instant}; -use tauri::http::HeaderMap; - -// 缓存的最大有效期(5秒) -const CACHE_TTL: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Rate { - pub up: u64, - pub down: u64, -} -// 缓存MihomoManager实例 -struct MihomoCache { - manager: mihomo_api::MihomoManager, - created_at: Instant, - server: String, -} -// 使用RwLock替代Mutex,允许多个读取操作并发进行 -pub struct MihomoManager { - mihomo_cache: RwLock>, - create_lock: Mutex<()>, -} - -impl MihomoManager { - fn __global() -> &'static MihomoManager { - static INSTANCE: Lazy = Lazy::new(|| MihomoManager { - mihomo_cache: RwLock::new(None), - create_lock: Mutex::new(()), - }); - &INSTANCE - } - - pub fn global() -> mihomo_api::MihomoManager { - let instance = MihomoManager::__global(); - - // 尝试从缓存读取(只需读锁) - { - let cache = instance.mihomo_cache.read(); - if let Some(cache_entry) = &*cache { - let (current_server, _) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - - // 检查缓存是否有效 - if cache_entry.server == current_server - && cache_entry.created_at.elapsed() < CACHE_TTL - { - return cache_entry.manager.clone(); - } - } - } - - // 缓存无效,获取创建锁 - let _create_guard = instance.create_lock.lock(); - - // 再次检查缓存(双重检查锁定模式) - { - let cache = instance.mihomo_cache.read(); - if let Some(cache_entry) = &*cache { - let (current_server, _) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - - if cache_entry.server == current_server - && cache_entry.created_at.elapsed() < CACHE_TTL - { - return cache_entry.manager.clone(); - } - } - } - - // 创建新实例 - let (current_server, headers) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - let manager = mihomo_api::MihomoManager::new(current_server.clone(), headers); - - // 更新缓存 - { - let mut cache = instance.mihomo_cache.write(); - *cache = Some(MihomoCache { - manager: manager.clone(), - created_at: Instant::now(), - server: current_server, - }); - } - - manager - } -} - -impl MihomoManager { - pub fn get_clash_client_info() -> Option<(String, HeaderMap)> { - let client = { Config::clash().latest_ref().get_client_info() }; - let server = format!("http://{}", client.server); - let mut headers = HeaderMap::new(); - headers.insert("Content-Type", "application/json".parse().unwrap()); - if let Some(secret) = client.secret { - let secret = format!("Bearer {secret}").parse().unwrap(); - headers.insert("Authorization", secret); - } - - Some((server, headers)) - } - - // 已移除未使用的 get_clash_client_info_or_default 和 get_traffic_ws_url 方法 -} diff --git a/src-tauri/src/module/mod.rs b/src-tauri/src/module/mod.rs index 39cb3c834..64373b9d9 100644 --- a/src-tauri/src/module/mod.rs +++ b/src-tauri/src/module/mod.rs @@ -1,3 +1,2 @@ pub mod lightweight; -pub mod mihomo; pub mod sysinfo; diff --git a/src-tauri/src/utils/dirs.rs b/src-tauri/src/utils/dirs.rs index 23b3a1ab8..8f58936ac 100644 --- a/src-tauri/src/utils/dirs.rs +++ b/src-tauri/src/utils/dirs.rs @@ -242,3 +242,39 @@ pub fn get_encryption_key() -> Result> { Ok(key) } } + +#[cfg(unix)] +pub fn ensure_mihomo_safe_dir() -> Option { + ["/var/tmp", "/tmp"] + .iter() + .map(PathBuf::from) + .find(|path| path.exists()) + .or_else(|| { + std::env::var_os("HOME").and_then(|home| { + let home_config = PathBuf::from(home).join(".config"); + if home_config.exists() || fs::create_dir_all(&home_config).is_ok() { + Some(home_config) + } else { + log::error!(target: "app", "Failed to create safe directory: {home_config:?}"); + None + } + }) + }) +} + +#[cfg(unix)] +pub fn ipc_path() -> Result { + ensure_mihomo_safe_dir() + .map(|base_dir| base_dir.join("verge").join("verge-mihomo.sock")) + .or_else(|| { + app_home_dir() + .ok() + .map(|dir| dir.join("verge").join("verge-mihomo.sock")) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to determine ipc path")) +} + +#[cfg(target_os = "windows")] +pub fn ipc_path() -> Result { + Ok(PathBuf::from(r"\\.\pipe\verge-mihomo")) +} diff --git a/src-tauri/src/utils/logging.rs b/src-tauri/src/utils/logging.rs index ec07ef9f3..3aa7866e0 100644 --- a/src-tauri/src/utils/logging.rs +++ b/src-tauri/src/utils/logging.rs @@ -17,6 +17,7 @@ pub enum Type { Lightweight, Network, ProxyMode, + Ipc, } impl fmt::Display for Type { @@ -37,6 +38,7 @@ impl fmt::Display for Type { Type::Lightweight => write!(f, "[Lightweight]"), Type::Network => write!(f, "[Network]"), Type::ProxyMode => write!(f, "[ProxMode]"), + Type::Ipc => write!(f, "[IPC]"), } } } diff --git a/src-tauri/src/utils/resolve.rs b/src-tauri/src/utils/resolve.rs index a97b0821b..10a33c288 100644 --- a/src-tauri/src/utils/resolve.rs +++ b/src-tauri/src/utils/resolve.rs @@ -172,6 +172,32 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) { log::trace!(target: "app", "启动内嵌服务器..."); server::embed_server(); + logging!(trace, Type::Core, true, "启动 IPC 监控服务..."); + // IPC 监控器将在首次调用时自动初始化 + + // // 启动测试线程,持续打印流量数据 + // logging!(info, Type::Core, true, "启动流量数据测试线程..."); + // AsyncHandler::spawn(|| async { + // let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + // loop { + // interval.tick().await; + + // let traffic_data = get_current_traffic().await; + // let memory_data = get_current_memory().await; + + // println!("=== Traffic Data Test (IPC) ==="); + // println!( + // "Traffic - Up: {} bytes/s, Down: {} bytes/s, Last Updated: {:?}", + // traffic_data.up_rate, traffic_data.down_rate, traffic_data.last_updated + // ); + // println!( + // "Memory - InUse: {} bytes, OSLimit: {:?}, Last Updated: {:?}", + // memory_data.inuse, memory_data.oslimit, memory_data.last_updated + // ); + // println!("=============================="); + // } + // }); + logging_error!(Type::Tray, true, tray::Tray::global().init()); if let Some(app_handle) = handle::Handle::global().app_handle() { diff --git a/src-tauri/src_crates/crate_mihomo_api/.env b/src-tauri/src_crates/crate_mihomo_api/.env deleted file mode 100644 index 0055075bb..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/.env +++ /dev/null @@ -1,2 +0,0 @@ -# LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/mihomo.sock" -LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/mihomo.sock" diff --git a/src-tauri/src_crates/crate_mihomo_api/Cargo.toml b/src-tauri/src_crates/crate_mihomo_api/Cargo.toml deleted file mode 100644 index f1e30a487..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "mihomo_api" -edition = "2024" - -[dependencies] -reqwest = { version = "0.12.22", features = ["json"] } -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -tokio = { version = "1.46.1", features = ["rt", "macros", "time"] } - -[dev-dependencies] diff --git a/src-tauri/src_crates/crate_mihomo_api/src/lib.rs b/src-tauri/src_crates/crate_mihomo_api/src/lib.rs deleted file mode 100644 index ecbee81fe..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/src/lib.rs +++ /dev/null @@ -1,147 +0,0 @@ -use reqwest::{Method, header::HeaderMap}; -use serde_json::{Value, json}; -use std::time::Duration; -pub mod model; -pub use model::MihomoManager; - -impl MihomoManager { - pub fn new(mihomo_server: String, headers: HeaderMap) -> Self { - let client = reqwest::ClientBuilder::new() - .default_headers(headers) - .no_proxy() - .timeout(Duration::from_secs(15)) - .pool_max_idle_per_host(5) - .pool_idle_timeout(Duration::from_secs(15)) - .build() - .expect("Failed to build reqwest client"); - - Self { - mihomo_server, - client, - } - } - - async fn send_request( - &self, - method: Method, - url: String, - data: Option, - ) -> Result { - let client_response = self - .client - .request(method.clone(), &url) - .json(&data.unwrap_or(json!({}))) - .send() - .await - .map_err(|e| e.to_string())?; - - let response = match method { - Method::PATCH => { - let status = client_response.status(); - if status.as_u16() == 204 { - json!({"code": 204}) - } else { - client_response - .json::() - .await - .map_err(|e| e.to_string())? - } - } - Method::PUT => json!(client_response.text().await.map_err(|e| e.to_string())?), - _ => client_response - .json::() - .await - .map_err(|e| e.to_string())?, - }; - Ok(response) - } - - pub async fn get_refresh_proxies(&self) -> Result { - let url = format!("{}/proxies", self.mihomo_server); - let proxies = self.send_request(Method::GET, url, None).await?; - Ok(proxies) - } - - pub async fn get_providers_proxies(&self) -> Result { - let url = format!("{}/providers/proxies", self.mihomo_server); - let providers_proxies = self.send_request(Method::GET, url, None).await?; - Ok(providers_proxies) - } - - pub async fn close_all_connections(&self) -> Result<(), String> { - let url = format!("{}/connections", self.mihomo_server); - let response = self.send_request(Method::DELETE, url, None).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } -} - -impl MihomoManager { - pub async fn is_mihomo_running(&self) -> Result<(), String> { - let url = format!("{}/version", self.mihomo_server); - let _response = self.send_request(Method::GET, url, None).await?; - Ok(()) - } - - pub async fn put_configs_force(&self, clash_config_path: &str) -> Result<(), String> { - let url = format!("{}/configs?force=true", self.mihomo_server); - let payload = serde_json::json!({ - "path": clash_config_path, - }); - let _response = self.send_request(Method::PUT, url, Some(payload)).await?; - Ok(()) - } - - pub async fn patch_configs(&self, config: serde_json::Value) -> Result<(), String> { - let url = format!("{}/configs", self.mihomo_server); - let response = self.send_request(Method::PATCH, url, Some(config)).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } - - pub async fn test_proxy_delay( - &self, - name: &str, - test_url: Option, - timeout: i32, - ) -> Result { - let test_url = test_url.unwrap_or("https://cp.cloudflare.com/generate_204".to_string()); - let url = format!( - "{}/proxies/{}/delay?url={}&timeout={}", - self.mihomo_server, name, test_url, timeout - ); - let response = self.send_request(Method::GET, url, None).await?; - Ok(response) - } - - pub async fn get_connections(&self) -> Result { - let url = format!("{}/connections", self.mihomo_server); - let response = self.send_request(Method::GET, url, None).await?; - Ok(response) - } - - pub async fn delete_connection(&self, id: &str) -> Result<(), String> { - let url = format!("{}/connections/{}", self.mihomo_server, id); - let response = self.send_request(Method::DELETE, url, None).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } -} diff --git a/src-tauri/src_crates/crate_mihomo_api/src/model.rs b/src-tauri/src_crates/crate_mihomo_api/src/model.rs deleted file mode 100644 index aed81a954..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/src/model.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[derive(Clone)] -pub struct MihomoManager { - pub(crate) mihomo_server: String, - pub(crate) client: reqwest::Client, -} diff --git a/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs b/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs deleted file mode 100644 index bee8d8d3e..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs +++ /dev/null @@ -1,7 +0,0 @@ -use reqwest::header::HeaderMap; - -#[test] -fn test_mihomo_manager_init() { - let _ = mihomo_api::MihomoManager::new("url".into(), HeaderMap::new()); - assert_eq!(true, true); -} diff --git a/src/components/common/traffic-error-boundary.tsx b/src/components/common/traffic-error-boundary.tsx new file mode 100644 index 000000000..eb5f77205 --- /dev/null +++ b/src/components/common/traffic-error-boundary.tsx @@ -0,0 +1,342 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { Box, Typography, Button, Alert, Collapse } from "@mui/material"; +import { + ErrorOutlineRounded, + RefreshRounded, + BugReportRounded, +} from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; + +interface Props { + children: ReactNode; + fallbackComponent?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + showDetails: boolean; +} + +/** + * 流量统计专用错误边界组件 + * 处理图表和流量统计组件的错误,提供优雅的降级体验 + */ +export class TrafficErrorBoundary extends Component { + private retryCount = 0; + private maxRetries = 3; + + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // 更新状态以显示降级UI + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[TrafficErrorBoundary] 捕获到组件错误:", error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + + // 调用错误回调 + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // 发送错误到监控系统(如果有的话) + this.reportError(error, errorInfo); + } + + private reportError = (error: Error, errorInfo: ErrorInfo) => { + // 这里可以集成错误监控服务 + const errorReport = { + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + url: window.location.href, + }; + + console.log("[TrafficErrorBoundary] 错误报告:", errorReport); + + // TODO: 发送到错误监控服务 + // sendErrorReport(errorReport); + }; + + private handleRetry = () => { + if (this.retryCount < this.maxRetries) { + this.retryCount++; + console.log( + `[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`, + ); + + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + } else { + console.warn("[TrafficErrorBoundary] 已达到最大重试次数"); + } + }; + + private handleRefresh = () => { + window.location.reload(); + }; + + private toggleDetails = () => { + this.setState((prev) => ({ showDetails: !prev.showDetails })); + }; + + render() { + if (this.state.hasError) { + // 如果提供了自定义降级组件,使用它 + if (this.props.fallbackComponent) { + return this.props.fallbackComponent; + } + + // 默认错误UI + return ( + + ); + } + + return this.props.children; + } +} + +/** + * 错误降级UI组件 + */ +interface TrafficErrorFallbackProps { + error: Error | null; + errorInfo: ErrorInfo | null; + showDetails: boolean; + canRetry: boolean; + retryCount: number; + maxRetries: number; + onRetry: () => void; + onRefresh: () => void; + onToggleDetails: () => void; +} + +const TrafficErrorFallback: React.FC = ({ + error, + errorInfo, + showDetails, + canRetry, + retryCount, + maxRetries, + onRetry, + onRefresh, + onToggleDetails, +}) => { + const { t } = useTranslation(); + + return ( + + + + + {t("Traffic Statistics Error")} + + + + {t( + "The traffic statistics component encountered an error and has been disabled to prevent crashes.", + )} + + + + + Error: {error?.message || "Unknown error"} + + {retryCount > 0 && ( + + {t("Retry attempts")}: {retryCount}/{maxRetries} + + )} + + + + {canRetry && ( + + )} + + + + + + + + + + Error Details: + + + {error?.stack} + + + {errorInfo?.componentStack && ( + <> + + Component Stack: + + + {errorInfo.componentStack} + + + )} + + + + ); +}; + +/** + * 轻量级流量统计错误边界 + * 用于小型流量显示组件,提供最小化的错误UI + */ +export const LightweightTrafficErrorBoundary: React.FC<{ + children: ReactNode; +}> = ({ children }) => { + return ( + + + Traffic data unavailable + + } + > + {children} + + ); +}; + +/** + * HOC:为任何组件添加流量错误边界 + */ +export function withTrafficErrorBoundary

( + WrappedComponent: React.ComponentType

, + options?: { + lightweight?: boolean; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + }, +) { + const WithErrorBoundaryComponent = (props: P) => { + const ErrorBoundaryComponent = options?.lightweight + ? LightweightTrafficErrorBoundary + : TrafficErrorBoundary; + + return ( + + + + ); + }; + + WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; + + return WithErrorBoundaryComponent; +} diff --git a/src/components/connection/connection-detail.tsx b/src/components/connection/connection-detail.tsx index 1d16924cd..4059e9cae 100644 --- a/src/components/connection/connection-detail.tsx +++ b/src/components/connection/connection-detail.tsx @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { forwardRef, useImperativeHandle, useState } from "react"; import { useLockFn } from "ahooks"; import { Box, Button, Snackbar, useTheme } from "@mui/material"; -import { deleteConnection } from "@/services/api"; +import { deleteConnection } from "@/services/cmds"; import parseTraffic from "@/utils/parse-traffic"; import { t } from "i18next"; diff --git a/src/components/connection/connection-item.tsx b/src/components/connection/connection-item.tsx index fa4a48a86..692f3bb4d 100644 --- a/src/components/connection/connection-item.tsx +++ b/src/components/connection/connection-item.tsx @@ -9,7 +9,7 @@ import { alpha, } from "@mui/material"; import { CloseRounded } from "@mui/icons-material"; -import { deleteConnection } from "@/services/api"; +import { deleteConnection } from "@/services/cmds"; import parseTraffic from "@/utils/parse-traffic"; const Tag = styled("span")(({ theme }) => ({ diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index 8e9572a26..9ab8632c0 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Box, Typography, Paper, Stack } from "@mui/material"; import { useLockFn } from "ahooks"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds"; import { useVerge } from "@/hooks/use-verge"; import { diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index ce4914bf8..23622862a 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -29,7 +29,7 @@ import { } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import { EnhancedCard } from "@/components/home/enhanced-card"; -import { updateProxy, deleteConnection } from "@/services/api"; +import { updateProxy, deleteConnection } from "@/services/cmds"; import delayManager from "@/services/delay"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-provider"; diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx new file mode 100644 index 000000000..7528a5508 --- /dev/null +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -0,0 +1,570 @@ +import { + forwardRef, + useImperativeHandle, + useState, + useEffect, + useCallback, + useMemo, + useRef, + memo, +} from "react"; +import { Box, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { + useTrafficGraphDataEnhanced, + type ITrafficDataPoint, +} from "@/hooks/use-traffic-monitor-enhanced"; + +// 流量数据项接口 +export interface ITrafficItem { + up: number; + down: number; + timestamp?: number; +} + +// 对外暴露的接口 +export interface EnhancedCanvasTrafficGraphRef { + appendData: (data: ITrafficItem) => void; + toggleStyle: () => void; +} + +type TimeRange = 1 | 5 | 10; // 分钟 + +// Canvas图表配置 +const MAX_POINTS = 300; +const TARGET_FPS = 15; // 降低帧率减少闪烁 +const LINE_WIDTH_UP = 2.5; +const LINE_WIDTH_DOWN = 2.5; +const LINE_WIDTH_GRID = 0.5; +const ALPHA_GRADIENT = 0.15; // 降低渐变透明度 +const ALPHA_LINE = 0.9; +const PADDING_TOP = 16; +const PADDING_RIGHT = 16; // 增加右边距确保时间戳完整显示 +const PADDING_BOTTOM = 32; // 进一步增加底部空间给时间轴和统计信息 +const PADDING_LEFT = 16; // 增加左边距确保时间戳完整显示 + +const GRAPH_CONFIG = { + maxPoints: MAX_POINTS, + targetFPS: TARGET_FPS, + lineWidth: { + up: LINE_WIDTH_UP, + down: LINE_WIDTH_DOWN, + grid: LINE_WIDTH_GRID, + }, + alpha: { + gradient: ALPHA_GRADIENT, + line: ALPHA_LINE, + }, + padding: { + top: PADDING_TOP, + right: PADDING_RIGHT, + bottom: PADDING_BOTTOM, + left: PADDING_LEFT, + }, +}; + +/** + * 稳定版Canvas流量图表组件 + * 修复闪烁问题,添加时间轴显示 + */ +export const EnhancedCanvasTrafficGraph = memo( + forwardRef((props, ref) => { + const theme = useTheme(); + const { t } = useTranslation(); + + // 使用增强版全局流量数据管理 + const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } = + useTrafficGraphDataEnhanced(); + + // 基础状态 + const [timeRange, setTimeRange] = useState(10); + const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); + + // Canvas引用和渲染状态 + const canvasRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastRenderTimeRef = useRef(0); + const isInitializedRef = useRef(false); + + // 当前显示的数据缓存 + const [displayData, setDisplayData] = useState([]); + + // 主题颜色配置 + const colors = useMemo( + () => ({ + up: theme.palette.secondary.main, + down: theme.palette.primary.main, + grid: theme.palette.divider, + text: theme.palette.text.secondary, + background: theme.palette.background.paper, + }), + [theme], + ); + + // 根据时间范围获取数据点数量 + const getPointsForTimeRange = useCallback( + (minutes: TimeRange): number => + Math.min(minutes * 60, GRAPH_CONFIG.maxPoints), + [], + ); + + // 更新显示数据(防抖处理) + const updateDisplayDataDebounced = useMemo(() => { + let timeoutId: number; + return (newData: ITrafficDataPoint[]) => { + clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + setDisplayData(newData); + }, 50); // 50ms防抖 + }; + }, []); + + // 监听数据变化 + useEffect(() => { + const timeRangeData = getDataForTimeRange(timeRange); + updateDisplayDataDebounced(timeRangeData); + }, [ + dataPoints, + timeRange, + getDataForTimeRange, + updateDisplayDataDebounced, + ]); + + // Y轴坐标计算(对数刻度)- 确保不与时间轴重叠 + const calculateY = useCallback((value: number, height: number): number => { + const padding = GRAPH_CONFIG.padding; + const effectiveHeight = height - padding.top - padding.bottom; + const baseY = height - padding.bottom; + + if (value === 0) return baseY - 2; // 稍微抬高零值线 + + const steps = effectiveHeight / 7; + + if (value <= 10) return baseY - (value / 10) * steps; + if (value <= 100) return baseY - (value / 100 + 1) * steps; + if (value <= 1024) return baseY - (value / 1024 + 2) * steps; + if (value <= 10240) return baseY - (value / 10240 + 3) * steps; + if (value <= 102400) return baseY - (value / 102400 + 4) * steps; + if (value <= 1048576) return baseY - (value / 1048576 + 5) * steps; + if (value <= 10485760) return baseY - (value / 10485760 + 6) * steps; + + return padding.top + 1; + }, []); + + // 绘制时间轴 + const drawTimeAxis = useCallback( + ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + data: ITrafficDataPoint[], + ) => { + if (data.length === 0) return; + + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + const timeAxisY = height - padding.bottom + 14; + + ctx.save(); + ctx.fillStyle = colors.text; + ctx.font = + "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; + ctx.globalAlpha = 0.7; + + // 显示最多6个时间标签,确保边界完整显示 + const maxLabels = 6; + const step = Math.max(1, Math.floor(data.length / (maxLabels - 1))); + + // 绘制第一个时间点(左对齐) + if (data.length > 0 && data[0].name) { + ctx.textAlign = "left"; + const timeLabel = data[0].name.substring(0, 5); + ctx.fillText(timeLabel, padding.left, timeAxisY); + } + + // 绘制中间的时间点(居中对齐) + ctx.textAlign = "center"; + for (let i = step; i < data.length - step; i += step) { + const point = data[i]; + if (!point.name) continue; + + const x = padding.left + (i / (data.length - 1)) * effectiveWidth; + const timeLabel = point.name.substring(0, 5); + ctx.fillText(timeLabel, x, timeAxisY); + } + + // 绘制最后一个时间点(右对齐) + if (data.length > 1 && data[data.length - 1].name) { + ctx.textAlign = "right"; + const timeLabel = data[data.length - 1].name.substring(0, 5); + ctx.fillText(timeLabel, width - padding.right, timeAxisY); + } + + ctx.restore(); + }, + [colors.text], + ); + + // 绘制网格线 + const drawGrid = useCallback( + (ctx: CanvasRenderingContext2D, width: number, height: number) => { + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + const effectiveHeight = height - padding.top - padding.bottom; + + ctx.save(); + ctx.strokeStyle = colors.grid; + ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid; + ctx.globalAlpha = 0.2; + + // 水平网格线 + const horizontalLines = 4; + for (let i = 1; i <= horizontalLines; i++) { + const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + } + + // 垂直网格线 + const verticalLines = 6; + for (let i = 1; i <= verticalLines; i++) { + const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i; + ctx.beginPath(); + ctx.moveTo(x, padding.top); + ctx.lineTo(x, height - padding.bottom); + ctx.stroke(); + } + + ctx.restore(); + }, + [colors.grid], + ); + + // 绘制流量线条 + const drawTrafficLine = useCallback( + ( + ctx: CanvasRenderingContext2D, + values: number[], + width: number, + height: number, + color: string, + withGradient = false, + ) => { + if (values.length < 2) return; + + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + + const points = values.map((value, index) => [ + padding.left + (index / (values.length - 1)) * effectiveWidth, + calculateY(value, height), + ]); + + ctx.save(); + + // 绘制渐变填充 + if (withGradient && chartStyle === "bezier") { + const gradient = ctx.createLinearGradient( + 0, + padding.top, + 0, + height - padding.bottom, + ); + gradient.addColorStop( + 0, + `${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255) + .toString(16) + .padStart(2, "0")}`, + ); + gradient.addColorStop(1, `${color}00`); + + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + + if (chartStyle === "bezier") { + for (let i = 1; i < points.length; i++) { + const current = points[i]; + const next = points[i + 1] || current; + const controlX = (current[0] + next[0]) / 2; + const controlY = (current[1] + next[1]) / 2; + ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); + } + } else { + for (let i = 1; i < points.length; i++) { + ctx.lineTo(points[i][0], points[i][1]); + } + } + + ctx.lineTo(points[points.length - 1][0], height - padding.bottom); + ctx.lineTo(points[0][0], height - padding.bottom); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + } + + // 绘制主线条 + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = GRAPH_CONFIG.lineWidth.up; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.globalAlpha = GRAPH_CONFIG.alpha.line; + + ctx.moveTo(points[0][0], points[0][1]); + + if (chartStyle === "bezier") { + for (let i = 1; i < points.length; i++) { + const current = points[i]; + const next = points[i + 1] || current; + const controlX = (current[0] + next[0]) / 2; + const controlY = (current[1] + next[1]) / 2; + ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); + } + } else { + for (let i = 1; i < points.length; i++) { + ctx.lineTo(points[i][0], points[i][1]); + } + } + + ctx.stroke(); + ctx.restore(); + }, + [calculateY, chartStyle], + ); + + // 主绘制函数 + const drawGraph = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || displayData.length === 0) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Canvas尺寸设置 + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = rect.width; + const height = rect.height; + + // 只在尺寸变化时重新设置Canvas + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + } + + // 清空画布 + ctx.clearRect(0, 0, width, height); + + // 绘制网格 + drawGrid(ctx, width, height); + + // 绘制时间轴 + drawTimeAxis(ctx, width, height, displayData); + + // 提取流量数据 + const upValues = displayData.map((d) => d.up); + const downValues = displayData.map((d) => d.down); + + // 绘制下载线(背景层) + drawTrafficLine(ctx, downValues, width, height, colors.down, true); + + // 绘制上传线(前景层) + drawTrafficLine(ctx, upValues, width, height, colors.up, true); + + isInitializedRef.current = true; + }, [displayData, colors, drawGrid, drawTimeAxis, drawTrafficLine]); + + // 受控的动画循环 + useEffect(() => { + const animate = (currentTime: number) => { + // 控制帧率,减少不必要的重绘 + if ( + currentTime - lastRenderTimeRef.current >= + 1000 / GRAPH_CONFIG.targetFPS + ) { + drawGraph(); + lastRenderTimeRef.current = currentTime; + } + animationFrameRef.current = requestAnimationFrame(animate); + }; + + // 只有在有数据时才开始动画 + if (displayData.length > 0) { + animationFrameRef.current = requestAnimationFrame(animate); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [drawGraph, displayData.length]); + + // 切换时间范围 + const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setTimeRange((prev) => { + return prev === 1 ? 5 : prev === 5 ? 10 : 1; + }); + }, []); + + // 切换图表样式 + const toggleStyle = useCallback(() => { + setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier")); + }, []); + + // 兼容性方法 + const appendData = useCallback((data: ITrafficItem) => { + console.log( + "[EnhancedCanvasTrafficGraphV2] appendData called (using global data):", + data, + ); + }, []); + + // 暴露方法给父组件 + useImperativeHandle( + ref, + () => ({ + appendData, + toggleStyle, + }), + [appendData, toggleStyle], + ); + + // 获取时间范围文本 + const getTimeRangeText = useCallback(() => { + return t("{{time}} Minutes", { time: timeRange }); + }, [timeRange, t]); + + return ( + + + + {/* 控制层覆盖 */} + + {/* 时间范围按钮 */} + + {getTimeRangeText()} + + + {/* 图例 */} + + + {t("Upload")} + + + {t("Download")} + + + + {/* 样式指示器 */} + + {chartStyle === "bezier" ? "Smooth" : "Linear"} + + + {/* 数据统计指示器(左下角) */} + + Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} | + Compressed: {samplerStats.compressedBufferSize} + + + + ); + }), +); + +EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph"; diff --git a/src/components/home/enhanced-traffic-graph.tsx b/src/components/home/enhanced-traffic-graph.tsx deleted file mode 100644 index 1da338985..000000000 --- a/src/components/home/enhanced-traffic-graph.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { - forwardRef, - useImperativeHandle, - useState, - useEffect, - useCallback, - useMemo, - useRef, - memo, -} from "react"; -import { Box, useTheme } from "@mui/material"; -import parseTraffic from "@/utils/parse-traffic"; -import { useTranslation } from "react-i18next"; -import { Line as ChartJsLine } from "react-chartjs-2"; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - Tooltip, - Filler, - Scale, - Tick, -} from "chart.js"; - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Tooltip, - Filler, -); - -// 流量数据项接口 -export interface ITrafficItem { - up: number; - down: number; - timestamp?: number; -} - -// 对外暴露的接口 -export interface EnhancedTrafficGraphRef { - appendData: (data: ITrafficItem) => void; - toggleStyle: () => void; -} - -type TimeRange = 1 | 5 | 10; // 分钟 - -// 数据点类型 -type DataPoint = ITrafficItem & { name: string; timestamp: number }; - -/** - * 增强型流量图表组件 - */ -export const EnhancedTrafficGraph = memo( - forwardRef((props, ref) => { - const theme = useTheme(); - const { t } = useTranslation(); - - // 基础状态 - const [timeRange, setTimeRange] = useState(10); - const [chartStyle, setChartStyle] = useState<"line" | "area">("area"); - const [displayData, setDisplayData] = useState([]); - - // 数据缓冲区 - const dataBufferRef = useRef([]); - - // 根据时间范围计算保留的数据点数量 - const getMaxPointsByTimeRange = useCallback( - (minutes: TimeRange): number => minutes * 60, - [], - ); - - // 最大数据点数量 - const MAX_BUFFER_SIZE = useMemo( - () => getMaxPointsByTimeRange(10), - [getMaxPointsByTimeRange], - ); - - // 颜色配置 - const colors = useMemo( - () => ({ - up: theme.palette.secondary.main, - down: theme.palette.primary.main, - grid: theme.palette.divider, - tooltipBg: theme.palette.background.paper, - text: theme.palette.text.primary, - tooltipBorder: theme.palette.divider, - }), - [theme], - ); - - // 切换时间范围 - const handleTimeRangeClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - setTimeRange((prevRange) => { - return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1; - }); - }, - [], - ); - - // 点击图表主体或图例时切换样式 - const handleToggleStyleClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - setChartStyle((prev) => (prev === "line" ? "area" : "line")); - }, - [], - ); - - // 初始化数据缓冲区 - useEffect(() => { - const now = Date.now(); - const tenMinutesAgo = now - 10 * 60 * 1000; - - const initialBuffer = Array.from( - { length: MAX_BUFFER_SIZE }, - (_, index) => { - const pointTime = - tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE); - const date = new Date(pointTime); - let nameValue: string; - try { - if (isNaN(date.getTime())) { - console.warn( - `Initial data generation: Invalid date for timestamp ${pointTime}`, - ); - nameValue = "??:??:??"; - } else { - nameValue = date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - } catch (e) { - console.error( - "Error in toLocaleTimeString during initial data gen:", - e, - "Date:", - date, - "Timestamp:", - pointTime, - ); - nameValue = "Err:Time"; - } - - return { - up: 0, - down: 0, - timestamp: pointTime, - name: nameValue, - }; - }, - ); - - dataBufferRef.current = initialBuffer; - - // 更新显示数据 - const pointsToShow = getMaxPointsByTimeRange(timeRange); - setDisplayData(initialBuffer.slice(-pointsToShow)); - }, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]); - // 添加数据点方法 - const appendData = useCallback( - (data: ITrafficItem) => { - const safeData = { - up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0, - down: - typeof data.down === "number" && !isNaN(data.down) ? data.down : 0, - }; - - const timestamp = data.timestamp || Date.now(); - const date = new Date(timestamp); - - let nameValue: string; - try { - if (isNaN(date.getTime())) { - console.warn(`appendData: Invalid date for timestamp ${timestamp}`); - nameValue = "??:??:??"; - } else { - nameValue = date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - } catch (e) { - console.error( - "Error in toLocaleTimeString in appendData:", - e, - "Date:", - date, - "Timestamp:", - timestamp, - ); - nameValue = "Err:Time"; - } - // 带时间标签的新数据点 - const newPoint: DataPoint = { - ...safeData, - name: nameValue, - timestamp: timestamp, - }; - - const newBuffer = [...dataBufferRef.current.slice(1), newPoint]; - dataBufferRef.current = newBuffer; - - const pointsToShow = getMaxPointsByTimeRange(timeRange); - setDisplayData(newBuffer.slice(-pointsToShow)); - }, - [timeRange, getMaxPointsByTimeRange], - ); - - // 监听时间范围变化 - useEffect(() => { - const pointsToShow = getMaxPointsByTimeRange(timeRange); - if (dataBufferRef.current.length > 0) { - setDisplayData(dataBufferRef.current.slice(-pointsToShow)); - } - }, [timeRange, getMaxPointsByTimeRange]); - - // 切换图表样式 - const toggleStyle = useCallback(() => { - setChartStyle((prev) => (prev === "line" ? "area" : "line")); - }, []); - - // 暴露方法给父组件 - useImperativeHandle( - ref, - () => ({ - appendData, - toggleStyle, - }), - [appendData, toggleStyle], - ); - - const formatYAxis = useCallback((value: number | string): string => { - if (typeof value !== "number") return String(value); - const [num, unit] = parseTraffic(value); - return `${num}${unit}`; - }, []); - - const formatXLabel = useCallback( - (tickValue: string | number, index: number, ticks: any[]) => { - const dataPoint = displayData[index as number]; - if (dataPoint && dataPoint.name) { - const parts = dataPoint.name.split(":"); - return `${parts[0]}:${parts[1]}`; - } - if (typeof tickValue === "string") { - const parts = tickValue.split(":"); - if (parts.length >= 2) return `${parts[0]}:${parts[1]}`; - return tickValue; - } - return ""; - }, - [displayData], - ); - - // 获取当前时间范围文本 - const getTimeRangeText = useCallback(() => { - return t("{{time}} Minutes", { time: timeRange }); - }, [timeRange, t]); - - const chartData = useMemo(() => { - const labels = displayData.map((d) => d.name); - return { - labels, - datasets: [ - { - label: t("Upload"), - data: displayData.map((d) => d.up), - borderColor: colors.up, - backgroundColor: chartStyle === "area" ? colors.up : colors.up, - fill: chartStyle === "area", - tension: 0.4, - pointRadius: 0, - pointHoverRadius: 4, - borderWidth: 2, - }, - { - label: t("Download"), - data: displayData.map((d) => d.down), - borderColor: colors.down, - backgroundColor: chartStyle === "area" ? colors.down : colors.down, - fill: chartStyle === "area", - tension: 0.4, - pointRadius: 0, - pointHoverRadius: 4, - borderWidth: 2, - }, - ], - }; - }, [displayData, colors.up, colors.down, t, chartStyle]); - - const chartOptions = useMemo( - () => ({ - responsive: true, - maintainAspectRatio: false, - animation: false as false, - scales: { - x: { - display: true, - type: "category" as const, - labels: displayData.map((d) => d.name), - ticks: { - display: true, - color: colors.text, - font: { size: 10 }, - callback: function ( - this: Scale, - tickValue: string | number, - index: number, - ticks: Tick[], - ): string | undefined { - let labelToFormat: string | undefined = undefined; - - const currentDisplayTick = ticks[index]; - if ( - currentDisplayTick && - typeof currentDisplayTick.label === "string" - ) { - labelToFormat = currentDisplayTick.label; - } else { - const sourceLabels = displayData.map((d) => d.name); - if ( - typeof tickValue === "number" && - tickValue >= 0 && - tickValue < sourceLabels.length - ) { - labelToFormat = sourceLabels[tickValue]; - } else if (typeof tickValue === "string") { - labelToFormat = tickValue; - } - } - - if (typeof labelToFormat !== "string") { - return undefined; - } - - const parts: string[] = labelToFormat.split(":"); - return parts.length >= 2 - ? `${parts[0]}:${parts[1]}` - : labelToFormat; - }, - autoSkip: true, - maxTicksLimit: Math.max( - 5, - Math.floor(displayData.length / (timeRange * 2)), - ), - minRotation: 0, - maxRotation: 0, - }, - grid: { - display: true, - drawOnChartArea: false, - drawTicks: true, - tickLength: 2, - color: colors.text, - }, - }, - y: { - beginAtZero: true, - ticks: { - color: colors.text, - font: { size: 10 }, - callback: formatYAxis, - }, - grid: { - display: true, - drawTicks: true, - tickLength: 3, - color: colors.grid, - }, - }, - }, - plugins: { - tooltip: { - enabled: true, - mode: "index" as const, - intersect: false, - backgroundColor: colors.tooltipBg, - titleColor: colors.text, - bodyColor: colors.text, - borderColor: colors.tooltipBorder, - borderWidth: 1, - cornerRadius: 4, - padding: 8, - callbacks: { - title: (tooltipItems: any[]) => { - return `${t("Time")}: ${tooltipItems[0].label}`; - }, - label: (context: any): string => { - const label = context.dataset.label || ""; - const value = context.parsed.y; - const [num, unit] = parseTraffic(value); - return `${label}: ${num} ${unit}/s`; - }, - }, - }, - legend: { - display: false, - }, - }, - layout: { - padding: { - top: 16, - right: 7, - left: 3, - }, - }, - }), - [colors, t, formatYAxis, timeRange, displayData], - ); - - return ( - -

- {displayData.length > 0 && ( - - )} - - - - {getTimeRangeText()} - - - - {t("Upload")} - - - - {t("Download")} - - -
- - ); - }), -); - -EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph"; diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx index 7e903466c..714eaa19d 100644 --- a/src/components/home/enhanced-traffic-stats.tsx +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -7,6 +7,7 @@ import { useTheme, PaletteColor, Grid, + Box, } from "@mui/material"; import { ArrowUpwardRounded, @@ -17,18 +18,19 @@ import { CloudDownloadRounded, } from "@mui/icons-material"; import { - EnhancedTrafficGraph, - EnhancedTrafficGraphRef, - ITrafficItem, -} from "./enhanced-traffic-graph"; + EnhancedCanvasTrafficGraph, + type EnhancedCanvasTrafficGraphRef, + type ITrafficItem, +} from "./enhanced-canvas-traffic-graph"; import { useVisibility } from "@/hooks/use-visibility"; import { useClashInfo } from "@/hooks/use-clash"; import { useVerge } from "@/hooks/use-verge"; -import { createAuthSockette } from "@/utils/websocket"; import parseTraffic from "@/utils/parse-traffic"; -import { isDebugEnabled, gc } from "@/services/api"; +import { isDebugEnabled, gc } from "@/services/cmds"; import { ReactNode } from "react"; import { useAppData } from "@/providers/app-data-provider"; +import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor-enhanced"; +import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary"; import useSWR from "swr"; interface MemoryUsage { @@ -64,7 +66,6 @@ declare global { // 控制更新频率 const CONNECTIONS_UPDATE_INTERVAL = 5000; // 5秒更新一次连接数据 -const THROTTLE_TRAFFIC_UPDATE = 500; // 500ms节流流量数据更新 // 统计卡片组件 - 使用memo优化 const CompactStatCard = memo( @@ -160,20 +161,15 @@ export const EnhancedTrafficStats = () => { const theme = useTheme(); const { clashInfo } = useClashInfo(); const { verge } = useVerge(); - const trafficRef = useRef(null); + const trafficRef = useRef(null); const pageVisible = useVisibility(); // 使用AppDataProvider const { connections, uptime } = useAppData(); - // 使用单一状态对象减少状态更新次数 - const [stats, setStats] = useState({ - traffic: { up: 0, down: 0 }, - memory: { inuse: 0, oslimit: undefined as number | undefined }, - }); - - // 创建一个标记来追踪最后更新时间,用于节流 - const lastUpdateRef = useRef({ traffic: 0 }); + // 使用增强版的统一流量数据Hook + const { traffic, memory, isLoading, isDataFresh, hasValidData } = + useTrafficDataEnhanced(); // 是否显示流量图表 const trafficGraph = verge?.traffic_graph ?? true; @@ -189,171 +185,7 @@ export const EnhancedTrafficStats = () => { }, ); - // 处理流量数据更新 - 使用节流控制更新频率 - const handleTrafficUpdate = useCallback((event: MessageEvent) => { - try { - const data = JSON.parse(event.data) as ITrafficItem; - if ( - data && - typeof data.up === "number" && - typeof data.down === "number" - ) { - // 使用节流控制更新频率 - const now = Date.now(); - if (now - lastUpdateRef.current.traffic < THROTTLE_TRAFFIC_UPDATE) { - try { - trafficRef.current?.appendData({ - up: data.up, - down: data.down, - timestamp: now, - }); - } catch {} - return; - } - lastUpdateRef.current.traffic = now; - const safeUp = isNaN(data.up) ? 0 : data.up; - const safeDown = isNaN(data.down) ? 0 : data.down; - try { - setStats((prev) => ({ - ...prev, - traffic: { up: safeUp, down: safeDown }, - })); - } catch {} - try { - trafficRef.current?.appendData({ - up: safeUp, - down: safeDown, - timestamp: now, - }); - } catch {} - } - } catch (err) { - console.error("[Traffic] 解析数据错误:", err, event.data); - } - }, []); - - // 处理内存数据更新 - const handleMemoryUpdate = useCallback((event: MessageEvent) => { - try { - const data = JSON.parse(event.data) as MemoryUsage; - if (data && typeof data.inuse === "number") { - setStats((prev) => ({ - ...prev, - memory: { - inuse: isNaN(data.inuse) ? 0 : data.inuse, - oslimit: data.oslimit, - }, - })); - } - } catch (err) { - console.error("[Memory] 解析数据错误:", err, event.data); - } - }, []); - - // 使用 WebSocket 连接获取数据 - 合并流量和内存连接逻辑 - useEffect(() => { - if (!clashInfo || !pageVisible) return; - - const { server, secret = "" } = clashInfo; - if (!server) return; - - // WebSocket 引用 - let sockets: { - traffic: ReturnType | null; - memory: ReturnType | null; - } = { - traffic: null, - memory: null, - }; - - // 清理现有连接的函数 - const cleanupSockets = () => { - Object.values(sockets).forEach((socket) => { - if (socket) { - socket.close(); - } - }); - sockets = { traffic: null, memory: null }; - }; - - // 关闭现有连接 - cleanupSockets(); - - // 创建新连接 - console.log( - `[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`, - ); - sockets.traffic = createAuthSockette(`${server}/traffic`, secret, { - onmessage: handleTrafficUpdate, - onopen: (event) => { - console.log( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror: (event) => { - console.error( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); - }, - onclose: (event) => { - console.log( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`, - ); - setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); - } - }, - }); - - console.log( - `[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`, - ); - sockets.memory = createAuthSockette(`${server}/memory`, secret, { - onmessage: handleMemoryUpdate, - onopen: (event) => { - console.log( - `[Memory][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror: (event) => { - console.error( - `[Memory][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - setStats((prev) => ({ - ...prev, - memory: { inuse: 0, oslimit: undefined }, - })); - }, - onclose: (event) => { - console.log( - `[Memory][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Memory][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`, - ); - setStats((prev) => ({ - ...prev, - memory: { inuse: 0, oslimit: undefined }, - })); - } - }, - }); - - return cleanupSockets; - }, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]); + // Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点 // 执行垃圾回收 const handleGarbageCollection = useCallback(async () => { @@ -369,9 +201,9 @@ export const EnhancedTrafficStats = () => { // 使用useMemo计算解析后的流量数据 const parsedData = useMemo(() => { - const [up, upUnit] = parseTraffic(stats.traffic.up); - const [down, downUnit] = parseTraffic(stats.traffic.down); - const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse); + const [up, upUnit] = parseTraffic(traffic?.raw?.up_rate || 0); + const [down, downUnit] = parseTraffic(traffic?.raw?.down_rate || 0); + const [inuse, inuseUnit] = parseTraffic(memory?.raw?.inuse || 0); const [uploadTotal, uploadTotalUnit] = parseTraffic( connections.uploadTotal, ); @@ -392,7 +224,7 @@ export const EnhancedTrafficStats = () => { downloadTotalUnit, connectionsCount: connections.count, }; - }, [stats, connections]); + }, [traffic, memory, connections]); // 渲染流量图表 - 使用useMemo缓存渲染结果 const trafficGraphComponent = useMemo(() => { @@ -411,7 +243,7 @@ export const EnhancedTrafficStats = () => { onClick={() => trafficRef.current?.toggleStyle()} >
- + {isDebug && (
{ > DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"}
+ 状态: {isDataFresh ? "active" : "inactive"} +
+ 数据新鲜度: {traffic?.is_fresh ? "Fresh" : "Stale"} +
{new Date().toISOString().slice(11, 19)}
)} @@ -487,19 +323,42 @@ export const EnhancedTrafficStats = () => { ); return ( - - {trafficGraph && ( - - {/* 流量图表区域 */} - {trafficGraphComponent} - - )} - {/* 统计卡片区域 */} - {statCards.map((card, index) => ( - - - - ))} - + { + console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo); + }} + > + + {trafficGraph && ( + + {/* 流量图表区域 */} + {trafficGraphComponent} + + )} + {/* 统计卡片区域 */} + {statCards.map((card, index) => ( + + + + ))} + + {/* 数据状态指示器(调试用)*/} + {isDebug && ( + + + 数据状态: {isDataFresh ? "新鲜" : "过期"} | 有效数据:{" "} + {hasValidData ? "是" : "否"} | 加载中: {isLoading ? "是" : "否"} + + + )} + + ); }; diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index cc563024a..8eb255ce4 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -10,10 +10,10 @@ import { useVerge } from "@/hooks/use-verge"; import { TrafficGraph, type TrafficRef } from "./traffic-graph"; import { useVisibility } from "@/hooks/use-visibility"; import parseTraffic from "@/utils/parse-traffic"; -import useSWRSubscription from "swr/subscription"; -import { createAuthSockette } from "@/utils/websocket"; import { useTranslation } from "react-i18next"; -import { isDebugEnabled, gc } from "@/services/api"; +import { isDebugEnabled, gc, startTrafficService } from "@/services/cmds"; +import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor-enhanced"; +import { LightweightTrafficErrorBoundary } from "@/components/common/traffic-error-boundary"; import useSWR from "swr"; interface MemoryUsage { @@ -23,6 +23,18 @@ interface MemoryUsage { // setup the traffic export const LayoutTraffic = () => { + const { data: isDebug } = useSWR( + "clash-verge-rev-internal://isDebugEnabled", + () => isDebugEnabled(), + { + // default value before is fetched + fallbackData: false, + }, + ); + + if (isDebug) { + console.debug("[Traffic][LayoutTraffic] 组件正在渲染"); + } const { t } = useTranslation(); const { clashInfo } = useClashInfo(); const { verge } = useVerge(); @@ -33,125 +45,54 @@ export const LayoutTraffic = () => { const trafficRef = useRef(null); const pageVisible = useVisibility(); - const { data: isDebug } = useSWR( - "clash-verge-rev-internal://isDebugEnabled", - () => isDebugEnabled(), - { - // default value before is fetched - fallbackData: false, - }, - ); + // 使用增强版的统一流量数据Hook + const { traffic, memory, isLoading, isDataFresh, hasValidData } = + useTrafficDataEnhanced(); - const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription< - ITrafficItem, - any, - "getRealtimeTraffic" | null - >( - clashInfo && pageVisible ? "getRealtimeTraffic" : null, - (_key, { next }) => { - const { server = "", secret = "" } = clashInfo!; + // 启动流量服务 + useEffect(() => { + console.log( + "[Traffic][LayoutTraffic] useEffect 触发,clashInfo:", + clashInfo, + "pageVisible:", + pageVisible, + ); - if (!server) { - console.warn("[Traffic] 服务器地址为空,无法建立连接"); - next(null, { up: 0, down: 0 }); - return () => {}; - } + // 简化条件,只要组件挂载就尝试启动服务 + console.log("[Traffic][LayoutTraffic] 开始启动流量服务"); + startTrafficService().catch((error) => { + console.error("[Traffic][LayoutTraffic] 启动流量服务失败:", error); + }); + }, []); // 移除依赖,只在组件挂载时启动一次 - console.log(`[Traffic] 正在连接: ${server}/traffic`); - - const s = createAuthSockette(`${server}/traffic`, secret, { - timeout: 8000, // 8秒超时 - onmessage(event) { - const data = JSON.parse(event.data) as ITrafficItem; - trafficRef.current?.appendData(data); - next(null, data); - }, - onerror(event) { - console.error("[Traffic] WebSocket 连接错误", event); - this.close(); - next(null, { up: 0, down: 0 }); - }, - onclose(event) { - console.log("[Traffic] WebSocket 连接关闭", event); - }, - onopen(event) { - console.log("[Traffic] WebSocket 连接已建立"); - }, + // 监听数据变化,为图表添加数据点 + useEffect(() => { + if (traffic?.raw && trafficRef.current) { + trafficRef.current.appendData({ + up: traffic.raw.up_rate || 0, + down: traffic.raw.down_rate || 0, }); + } + }, [traffic]); - return () => { - console.log("[Traffic] 清理WebSocket连接"); - try { - s.close(); - } catch (e) { - console.error("[Traffic] 关闭连接时出错", e); - } - }; - }, - { - fallbackData: { up: 0, down: 0 }, - keepPreviousData: true, - }, - ); - - /* --------- meta memory information --------- */ - + // 显示内存使用情况的设置 const displayMemory = verge?.enable_memory_usage ?? true; - const { data: memory = { inuse: 0 } } = useSWRSubscription< - MemoryUsage, - any, - "getRealtimeMemory" | null - >( - clashInfo && pageVisible && displayMemory ? "getRealtimeMemory" : null, - (_key, { next }) => { - const { server = "", secret = "" } = clashInfo!; + // 使用格式化的数据,避免重复解析 + const upSpeed = traffic?.formatted?.up_rate || "0B"; + const downSpeed = traffic?.formatted?.down_rate || "0B"; + const memoryUsage = memory?.formatted?.inuse || "0B"; - if (!server) { - console.warn("[Memory] 服务器地址为空,无法建立连接"); - next(null, { inuse: 0 }); - return () => {}; - } - - console.log(`[Memory] 正在连接: ${server}/memory`); - - const s = createAuthSockette(`${server}/memory`, secret, { - timeout: 8000, // 8秒超时 - onmessage(event) { - const data = JSON.parse(event.data) as MemoryUsage; - next(null, data); - }, - onerror(event) { - console.error("[Memory] WebSocket 连接错误", event); - this.close(); - next(null, { inuse: 0 }); - }, - onclose(event) { - console.log("[Memory] WebSocket 连接关闭", event); - }, - onopen(event) { - console.log("[Memory] WebSocket 连接已建立"); - }, - }); - - return () => { - console.log("[Memory] 清理WebSocket连接"); - try { - s.close(); - } catch (e) { - console.error("[Memory] 关闭连接时出错", e); - } - }; - }, - { - fallbackData: { inuse: 0 }, - keepPreviousData: true, - }, - ); - - const [up, upUnit] = parseTraffic(traffic.up); - const [down, downUnit] = parseTraffic(traffic.down); - const [inuse, inuseUnit] = parseTraffic(memory.inuse); + // 提取数值和单位 + const [up, upUnit] = upSpeed.includes("B") + ? upSpeed.split(/(?=[KMGT]?B$)/) + : [upSpeed, ""]; + const [down, downUnit] = downSpeed.includes("B") + ? downSpeed.split(/(?=[KMGT]?B$)/) + : [downSpeed, ""]; + const [inuse, inuseUnit] = memoryUsage.includes("B") + ? memoryUsage.split(/(?=[KMGT]?B$)/) + : [memoryUsage, ""]; const boxStyle: any = { display: "flex", @@ -175,55 +116,78 @@ export const LayoutTraffic = () => { }; return ( - - {trafficGraph && pageVisible && ( -
- -
- )} + + + {trafficGraph && pageVisible && ( +
+ +
+ )} - - - 0 ? "secondary" : "disabled"} - /> - - {up} - - {upUnit}/s - - - - 0 ? "primary" : "disabled"} - /> - - {down} - - {downUnit}/s - - - {displayMemory && ( + { - isDebug && (await gc()); + sx={{ + ...boxStyle.sx, + opacity: traffic?.is_fresh ? 1 : 0.6, }} > - - {inuse} - {inuseUnit} + 0 ? "secondary" : "disabled" + } + /> + + {up} + + {upUnit}/s - )} + + + 0 ? "primary" : "disabled" + } + /> + + {down} + + {downUnit}/s + + + {displayMemory && ( + { + isDebug && (await gc()); + }} + > + + {inuse} + {inuseUnit} + + )} + -
+
); }; diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index ed8295f14..ecf5f014c 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -19,7 +19,7 @@ import { } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useLockFn } from "ahooks"; -import { proxyProviderUpdate } from "@/services/api"; +import { proxyProviderUpdate } from "@/services/cmds"; import { useAppData } from "@/providers/app-data-provider"; import { showNotice } from "@/services/noticeService"; import { StorageOutlined, RefreshRounded } from "@mui/icons-material"; diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 5ab494d2e..a4d56d874 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -7,7 +7,7 @@ import { updateProxy, deleteConnection, getGroupProxyDelays, -} from "@/services/api"; +} from "@/services/cmds"; import { forceRefreshProxies } from "@/services/cmds"; import { useProfiles } from "@/hooks/use-profiles"; import { useVerge } from "@/hooks/use-verge"; diff --git a/src/components/rule/provider-button.tsx b/src/components/rule/provider-button.tsx index 0b4e1106b..ebad1553d 100644 --- a/src/components/rule/provider-button.tsx +++ b/src/components/rule/provider-button.tsx @@ -18,7 +18,7 @@ import { } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useLockFn } from "ahooks"; -import { ruleProviderUpdate } from "@/services/api"; +import { ruleProviderUpdate } from "@/services/cmds"; import { StorageOutlined, RefreshRounded } from "@mui/icons-material"; import { useAppData } from "@/providers/app-data-provider"; import dayjs from "dayjs"; diff --git a/src/components/setting/mods/clash-core-viewer.tsx b/src/components/setting/mods/clash-core-viewer.tsx index 18a4b9142..1175d8132 100644 --- a/src/components/setting/mods/clash-core-viewer.tsx +++ b/src/components/setting/mods/clash-core-viewer.tsx @@ -18,7 +18,7 @@ import { ListItemText, } from "@mui/material"; import { changeClashCore, restartCore } from "@/services/cmds"; -import { closeAllConnections, upgradeCore } from "@/services/api"; +import { closeAllConnections, upgradeCore } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; const VALID_CORE = [ diff --git a/src/components/setting/mods/controller-viewer.tsx b/src/components/setting/mods/controller-viewer.tsx index 7c0b64811..3e493047c 100644 --- a/src/components/setting/mods/controller-viewer.tsx +++ b/src/components/setting/mods/controller-viewer.tsx @@ -1,5 +1,6 @@ import { BaseDialog, DialogRef } from "@/components/base"; import { useClashInfo } from "@/hooks/use-clash"; +import { useVerge } from "@/hooks/use-verge"; import { showNotice } from "@/services/noticeService"; import { ContentCopy } from "@mui/icons-material"; import { @@ -11,6 +12,7 @@ import { ListItem, ListItemText, Snackbar, + Switch, TextField, Tooltip, } from "@mui/material"; @@ -25,8 +27,12 @@ export const ControllerViewer = forwardRef((props, ref) => { const [isSaving, setIsSaving] = useState(false); const { clashInfo, patchInfo } = useClashInfo(); + const { verge, patchVerge } = useVerge(); const [controller, setController] = useState(clashInfo?.server || ""); const [secret, setSecret] = useState(clashInfo?.secret || ""); + const [enableController, setEnableController] = useState( + verge?.enable_external_controller ?? false, + ); // 对话框打开时初始化配置 useImperativeHandle(ref, () => ({ @@ -34,25 +40,37 @@ export const ControllerViewer = forwardRef((props, ref) => { setOpen(true); setController(clashInfo?.server || ""); setSecret(clashInfo?.secret || ""); + setEnableController(verge?.enable_external_controller ?? false); }, close: () => setOpen(false), })); // 保存配置 const onSave = useLockFn(async () => { - if (!controller.trim()) { - showNotice("error", t("Controller address cannot be empty")); - return; - } - - if (!secret.trim()) { - showNotice("error", t("Secret cannot be empty")); - return; - } - try { setIsSaving(true); - await patchInfo({ "external-controller": controller, secret }); + + // 先保存 enable_external_controller 设置 + await patchVerge({ enable_external_controller: enableController }); + + // 如果启用了外部控制器,则保存控制器地址和密钥 + if (enableController) { + if (!controller.trim()) { + showNotice("error", t("Controller address cannot be empty")); + return; + } + + if (!secret.trim()) { + showNotice("error", t("Secret cannot be empty")); + return; + } + + await patchInfo({ "external-controller": controller, secret }); + } else { + // 如果禁用了外部控制器,则清空控制器地址 + await patchInfo({ "external-controller": "" }); + } + showNotice("success", t("Configuration saved successfully")); setOpen(false); } catch (err: any) { @@ -100,6 +118,22 @@ export const ControllerViewer = forwardRef((props, ref) => { onOk={onSave} > + + + setEnableController(e.target.checked)} + disabled={isSaving} + /> + + ((props, ref) => { size="small" sx={{ width: 175, - opacity: 1, - pointerEvents: "auto", + opacity: enableController ? 1 : 0.5, + pointerEvents: enableController ? "auto" : "none", }} value={controller} placeholder="Required" onChange={(e) => setController(e.target.value)} - disabled={isSaving} + disabled={isSaving || !enableController} /> handleCopyToClipboard(controller, "controller")} color="primary" - disabled={isSaving} + disabled={isSaving || !enableController} > @@ -147,20 +181,20 @@ export const ControllerViewer = forwardRef((props, ref) => { size="small" sx={{ width: 175, - opacity: 1, - pointerEvents: "auto", + opacity: enableController ? 1 : 0.5, + pointerEvents: enableController ? "auto" : "none", }} value={secret} placeholder={t("Recommended")} onChange={(e) => setSecret(e.target.value)} - disabled={isSaving} + disabled={isSaving || !enableController} /> handleCopyToClipboard(secret, "secret")} color="primary" - disabled={isSaving} + disabled={isSaving || !enableController} > diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 7582e8c8c..3c2ea89c1 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -4,7 +4,7 @@ import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-provider"; -import { getClashConfig } from "@/services/api"; +import { getClashConfig } from "@/services/cmds"; import { getAutotemProxy, getNetworkInterfacesInfo, diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 01f648c1f..102ba1eda 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -3,7 +3,7 @@ import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { useClash } from "@/hooks/use-clash"; import { useListen } from "@/hooks/use-listen"; import { useVerge } from "@/hooks/use-verge"; -import { updateGeoData } from "@/services/api"; +import { updateGeoData } from "@/services/cmds"; import { invoke_uwp_tool } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import getSystem from "@/utils/get-system"; diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 34b869c2a..b9b9cd3f9 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -1,6 +1,6 @@ import useSWR, { mutate } from "swr"; import { useLockFn } from "ahooks"; -import { getAxios, getVersion } from "@/services/api"; +import { getVersion } from "@/services/cmds"; import { getClashInfo, patchClashConfig, @@ -122,8 +122,7 @@ export const useClashInfo = () => { await patchClashConfig(patch); mutateInfo(); mutate("getClashConfig"); - // 刷新接口 - getAxios(true); + // IPC调用不需要刷新axios实例 }; return { diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index c8f2cea04..111bf78c3 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -5,7 +5,7 @@ import { patchProfilesConfig, forceRefreshProxies, } from "@/services/cmds"; -import { getProxies, updateProxy } from "@/services/api"; +import { getProxies, updateProxy } from "@/services/cmds"; export const useProfiles = () => { const { data: profiles, mutate: mutateProfiles } = useSWR( diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 2e4621ba1..22f1c7cba 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -2,7 +2,7 @@ import useSWR, { mutate } from "swr"; import { useVerge } from "@/hooks/use-verge"; import { getAutotemProxy } from "@/services/cmds"; import { useAppData } from "@/providers/app-data-provider"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; // 系统代理状态检测统一逻辑 export const useSystemProxyState = () => { diff --git a/src/hooks/use-traffic-monitor-enhanced.ts b/src/hooks/use-traffic-monitor-enhanced.ts new file mode 100644 index 000000000..09a3a8aac --- /dev/null +++ b/src/hooks/use-traffic-monitor-enhanced.ts @@ -0,0 +1,398 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import useSWR from "swr"; +import { useClashInfo } from "@/hooks/use-clash"; +import { useVisibility } from "@/hooks/use-visibility"; +import { getSystemMonitorOverviewSafe } from "@/services/cmds"; + +// 增强的流量数据点接口 +export interface ITrafficDataPoint { + up: number; + down: number; + timestamp: number; + name: string; +} + +// 压缩的数据点(用于长期存储) +interface ICompressedDataPoint { + up: number; + down: number; + timestamp: number; + samples: number; // 压缩了多少个原始数据点 +} + +// 数据采样器配置 +interface ISamplingConfig { + // 原始数据保持时间(分钟) + rawDataMinutes: number; + // 压缩数据保持时间(分钟) + compressedDataMinutes: number; + // 压缩比例(多少个原始点压缩成1个) + compressionRatio: number; +} + +// 引用计数管理器 +class ReferenceCounter { + private count = 0; + private callbacks: (() => void)[] = []; + + increment(): () => void { + this.count++; + console.log(`[ReferenceCounter] 引用计数增加: ${this.count}`); + + if (this.count === 1) { + // 从0到1,开始数据收集 + this.callbacks.forEach((cb) => cb()); + } + + return () => { + this.count--; + console.log(`[ReferenceCounter] 引用计数减少: ${this.count}`); + + if (this.count === 0) { + // 从1到0,停止数据收集 + this.callbacks.forEach((cb) => cb()); + } + }; + } + + onCountChange(callback: () => void) { + this.callbacks.push(callback); + } + + getCount(): number { + return this.count; + } +} + +// 智能数据采样器 +class TrafficDataSampler { + private rawBuffer: ITrafficDataPoint[] = []; + private compressedBuffer: ICompressedDataPoint[] = []; + private config: ISamplingConfig; + private compressionQueue: ITrafficDataPoint[] = []; + + constructor(config: ISamplingConfig) { + this.config = config; + } + + addDataPoint(point: ITrafficDataPoint): void { + // 添加到原始缓冲区 + this.rawBuffer.push(point); + + // 清理过期的原始数据 + const rawCutoff = Date.now() - this.config.rawDataMinutes * 60 * 1000; + this.rawBuffer = this.rawBuffer.filter((p) => p.timestamp > rawCutoff); + + // 添加到压缩队列 + this.compressionQueue.push(point); + + // 当压缩队列达到压缩比例时,执行压缩 + if (this.compressionQueue.length >= this.config.compressionRatio) { + this.compressData(); + } + + // 清理过期的压缩数据 + const compressedCutoff = + Date.now() - this.config.compressedDataMinutes * 60 * 1000; + this.compressedBuffer = this.compressedBuffer.filter( + (p) => p.timestamp > compressedCutoff, + ); + } + + private compressData(): void { + if (this.compressionQueue.length === 0) return; + + // 计算平均值进行压缩 + const totalUp = this.compressionQueue.reduce((sum, p) => sum + p.up, 0); + const totalDown = this.compressionQueue.reduce((sum, p) => sum + p.down, 0); + const avgTimestamp = + this.compressionQueue.reduce((sum, p) => sum + p.timestamp, 0) / + this.compressionQueue.length; + + const compressedPoint: ICompressedDataPoint = { + up: totalUp / this.compressionQueue.length, + down: totalDown / this.compressionQueue.length, + timestamp: avgTimestamp, + samples: this.compressionQueue.length, + }; + + this.compressedBuffer.push(compressedPoint); + this.compressionQueue = []; + + console.log(`[DataSampler] 压缩了 ${compressedPoint.samples} 个数据点`); + } + + getDataForTimeRange(minutes: number): ITrafficDataPoint[] { + const cutoff = Date.now() - minutes * 60 * 1000; + + // 如果请求的时间范围在原始数据范围内,直接返回原始数据 + if (minutes <= this.config.rawDataMinutes) { + return this.rawBuffer.filter((p) => p.timestamp > cutoff); + } + + // 否则组合原始数据和压缩数据 + const rawData = this.rawBuffer.filter((p) => p.timestamp > cutoff); + const compressedData = this.compressedBuffer + .filter( + (p) => + p.timestamp > cutoff && + p.timestamp <= Date.now() - this.config.rawDataMinutes * 60 * 1000, + ) + .map((p) => ({ + up: p.up, + down: p.down, + timestamp: p.timestamp, + name: new Date(p.timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + })); + + return [...compressedData, ...rawData].sort( + (a, b) => a.timestamp - b.timestamp, + ); + } + + getStats() { + return { + rawBufferSize: this.rawBuffer.length, + compressedBufferSize: this.compressedBuffer.length, + compressionQueueSize: this.compressionQueue.length, + totalMemoryPoints: this.rawBuffer.length + this.compressedBuffer.length, + }; + } + + clear(): void { + this.rawBuffer = []; + this.compressedBuffer = []; + this.compressionQueue = []; + } +} + +// 全局单例 +const refCounter = new ReferenceCounter(); +let globalSampler: TrafficDataSampler | null = null; +let lastValidData: ISystemMonitorOverview | null = null; + +/** + * 增强的流量监控Hook - 支持数据压缩、采样和引用计数 + */ +export const useTrafficMonitorEnhanced = () => { + const { clashInfo } = useClashInfo(); + const pageVisible = useVisibility(); + + // 初始化采样器 + if (!globalSampler) { + globalSampler = new TrafficDataSampler({ + rawDataMinutes: 10, // 原始数据保持10分钟 + compressedDataMinutes: 60, // 压缩数据保持1小时 + compressionRatio: 5, // 每5个原始点压缩成1个 + }); + } + + const [, forceUpdate] = useState({}); + const cleanupRef = useRef<(() => void) | null>(null); + + // 强制组件更新 + const triggerUpdate = useCallback(() => { + forceUpdate({}); + }, []); + + // 注册引用计数 + useEffect(() => { + console.log("[TrafficMonitorEnhanced] 组件挂载,注册引用计数"); + const cleanup = refCounter.increment(); + cleanupRef.current = cleanup; + + return () => { + console.log("[TrafficMonitorEnhanced] 组件卸载,清理引用计数"); + cleanup(); + cleanupRef.current = null; + }; + }, []); + + // 设置引用计数变化回调 + useEffect(() => { + const handleCountChange = () => { + console.log( + `[TrafficMonitorEnhanced] 引用计数变化: ${refCounter.getCount()}`, + ); + if (refCounter.getCount() === 0) { + console.log("[TrafficMonitorEnhanced] 所有组件已卸载,暂停数据收集"); + } else { + console.log("[TrafficMonitorEnhanced] 开始数据收集"); + } + }; + + refCounter.onCountChange(handleCountChange); + }, []); + + // 只有在有引用时才启用SWR + const shouldFetch = clashInfo && pageVisible && refCounter.getCount() > 0; + + const { data: monitorData, error } = useSWR( + shouldFetch ? "getSystemMonitorOverviewSafe" : null, + getSystemMonitorOverviewSafe, + { + refreshInterval: shouldFetch ? 1000 : 0, // 只有在需要时才刷新 + keepPreviousData: true, + onSuccess: (data) => { + // console.log("[TrafficMonitorEnhanced] 获取到监控数据:", data); + + if (data?.traffic?.raw && globalSampler) { + // 保存最后有效数据 + lastValidData = data; + + // 添加到采样器 + const timestamp = Date.now(); + const dataPoint: ITrafficDataPoint = { + up: data.traffic.raw.up_rate || 0, + down: data.traffic.raw.down_rate || 0, + timestamp, + name: new Date(timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + }; + + globalSampler.addDataPoint(dataPoint); + triggerUpdate(); + } + }, + onError: (error) => { + console.error( + "[TrafficMonitorEnhanced] 网络错误,使用最后有效数据. 错误详情:", + { + message: error?.message || "未知错误", + stack: error?.stack || "无堆栈信息", + }, + ); + // 网络错误时不清空数据,继续使用最后有效值 + // 但是添加一个错误标记的数据点(流量为0) + if (globalSampler) { + const timestamp = Date.now(); + const errorPoint: ITrafficDataPoint = { + up: 0, + down: 0, + timestamp, + name: new Date(timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + }; + globalSampler.addDataPoint(errorPoint); + triggerUpdate(); + } + }, + }, + ); + + // 获取指定时间范围的数据 + const getDataForTimeRange = useCallback( + (minutes: number): ITrafficDataPoint[] => { + if (!globalSampler) return []; + return globalSampler.getDataForTimeRange(minutes); + }, + [], + ); + + // 清空数据 + const clearData = useCallback(() => { + if (globalSampler) { + globalSampler.clear(); + triggerUpdate(); + } + }, [triggerUpdate]); + + // 获取采样器统计信息 + const getSamplerStats = useCallback(() => { + return ( + globalSampler?.getStats() || { + rawBufferSize: 0, + compressedBufferSize: 0, + compressionQueueSize: 0, + totalMemoryPoints: 0, + } + ); + }, []); + + // 构建返回的监控数据,优先使用当前数据,fallback到最后有效数据 + const currentData = monitorData || lastValidData; + const trafficMonitorData = { + traffic: currentData?.traffic || { + raw: { up: 0, down: 0, up_rate: 0, down_rate: 0 }, + formatted: { + up_rate: "0B", + down_rate: "0B", + total_up: "0B", + total_down: "0B", + }, + is_fresh: false, + }, + memory: currentData?.memory || { + raw: { inuse: 0, oslimit: 0, usage_percent: 0 }, + formatted: { inuse: "0B", oslimit: "0B", usage_percent: 0 }, + is_fresh: false, + }, + }; + + return { + // 监控数据 + monitorData: trafficMonitorData, + + // 图表数据管理 + graphData: { + dataPoints: globalSampler?.getDataForTimeRange(60) || [], // 默认获取1小时数据 + getDataForTimeRange, + clearData, + }, + + // 状态信息 + isLoading: !currentData && !error, + error, + isDataFresh: currentData?.traffic?.is_fresh || false, + hasValidData: !!lastValidData, + + // 性能统计 + samplerStats: getSamplerStats(), + referenceCount: refCounter.getCount(), + }; +}; + +/** + * 轻量级流量数据Hook + */ +export const useTrafficDataEnhanced = () => { + const { monitorData, isLoading, error, isDataFresh, hasValidData } = + useTrafficMonitorEnhanced(); + + return { + traffic: monitorData.traffic, + memory: monitorData.memory, + isLoading, + error, + isDataFresh, + hasValidData, + }; +}; + +/** + * 图表数据Hook + */ +export const useTrafficGraphDataEnhanced = () => { + const { graphData, isDataFresh, samplerStats, referenceCount } = + useTrafficMonitorEnhanced(); + + return { + ...graphData, + isDataFresh, + samplerStats, + referenceCount, + }; +}; diff --git a/src/hooks/use-traffic-monitor.ts b/src/hooks/use-traffic-monitor.ts new file mode 100644 index 000000000..211da2823 --- /dev/null +++ b/src/hooks/use-traffic-monitor.ts @@ -0,0 +1,245 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import useSWR from "swr"; +import { useClashInfo } from "@/hooks/use-clash"; +import { useVisibility } from "@/hooks/use-visibility"; +import { getSystemMonitorOverview } from "@/services/cmds"; + +// 流量数据项接口 +export interface ITrafficDataPoint { + up: number; + down: number; + timestamp: number; + name: string; +} + +// 流量监控数据接口 +export interface ITrafficMonitorData { + traffic: { + raw: { up_rate: number; down_rate: number }; + formatted: { up_rate: string; down_rate: string }; + is_fresh: boolean; + }; + memory: { + raw: { inuse: number; oslimit?: number }; + formatted: { inuse: string; usage_percent?: number }; + is_fresh: boolean; + }; +} + +// 图表数据管理接口 +export interface ITrafficGraphData { + dataPoints: ITrafficDataPoint[]; + addDataPoint: (data: { + up: number; + down: number; + timestamp?: number; + }) => void; + clearData: () => void; + getDataForTimeRange: (minutes: number) => ITrafficDataPoint[]; +} + +/** + * 全局流量监控数据管理Hook + * 提供统一的流量数据获取和图表数据管理 + */ +export const useTrafficMonitor = () => { + const { clashInfo } = useClashInfo(); + const pageVisible = useVisibility(); + + // 图表数据缓冲区 - 使用ref保持数据持久性 + const dataBufferRef = useRef([]); + const [, forceUpdate] = useState({}); + + // 强制组件更新的函数 + const triggerUpdate = useCallback(() => { + forceUpdate({}); + }, []); + + // 最大缓冲区大小 (10分钟 * 60秒 = 600个数据点) + const MAX_BUFFER_SIZE = 600; + + // 初始化数据缓冲区 + useEffect(() => { + if (dataBufferRef.current.length === 0) { + const now = Date.now(); + const tenMinutesAgo = now - 10 * 60 * 1000; + + const initialBuffer = Array.from( + { length: MAX_BUFFER_SIZE }, + (_, index) => { + const pointTime = + tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE); + const date = new Date(pointTime); + + let nameValue: string; + try { + if (isNaN(date.getTime())) { + nameValue = "??:??:??"; + } else { + nameValue = date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + } catch (e) { + nameValue = "Err:Time"; + } + + return { + up: 0, + down: 0, + timestamp: pointTime, + name: nameValue, + }; + }, + ); + + dataBufferRef.current = initialBuffer; + } + }, [MAX_BUFFER_SIZE]); + + // 使用SWR获取监控数据 + const { data: monitorData, error } = useSWR( + clashInfo && pageVisible ? "getSystemMonitorOverview" : null, + getSystemMonitorOverview, + { + refreshInterval: 1000, // 1秒刷新一次 + keepPreviousData: true, + onSuccess: (data) => { + console.log("[TrafficMonitor] 获取到监控数据:", data); + + if (data?.traffic) { + // 为图表添加新数据点 + addDataPoint({ + up: data.traffic.raw.up_rate || 0, + down: data.traffic.raw.down_rate || 0, + timestamp: Date.now(), + }); + } + }, + onError: (error) => { + console.error("[TrafficMonitor] 获取数据错误:", error); + }, + }, + ); + + // 添加数据点到缓冲区 + const addDataPoint = useCallback( + (data: { up: number; down: number; timestamp?: number }) => { + const timestamp = data.timestamp || Date.now(); + const date = new Date(timestamp); + + let nameValue: string; + try { + if (isNaN(date.getTime())) { + nameValue = "??:??:??"; + } else { + nameValue = date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + } catch (e) { + nameValue = "Err:Time"; + } + + const newPoint: ITrafficDataPoint = { + up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0, + down: + typeof data.down === "number" && !isNaN(data.down) ? data.down : 0, + timestamp, + name: nameValue, + }; + + // 更新缓冲区,保持固定大小 + const newBuffer = [...dataBufferRef.current.slice(1), newPoint]; + dataBufferRef.current = newBuffer; + + // 触发使用该数据的组件更新 + triggerUpdate(); + }, + [triggerUpdate], + ); + + // 清空数据 + const clearData = useCallback(() => { + dataBufferRef.current = []; + triggerUpdate(); + }, [triggerUpdate]); + + // 根据时间范围获取数据 + const getDataForTimeRange = useCallback( + (minutes: number): ITrafficDataPoint[] => { + const pointsToShow = minutes * 60; // 每分钟60个数据点 + return dataBufferRef.current.slice(-pointsToShow); + }, + [], + ); + + // 构建图表数据管理对象 + const graphData: ITrafficGraphData = { + dataPoints: dataBufferRef.current, + addDataPoint, + clearData, + getDataForTimeRange, + }; + + // 构建监控数据对象 + const trafficMonitorData: ITrafficMonitorData = { + traffic: monitorData?.traffic || { + raw: { up_rate: 0, down_rate: 0 }, + formatted: { up_rate: "0B", down_rate: "0B" }, + is_fresh: false, + }, + memory: monitorData?.memory || { + raw: { inuse: 0 }, + formatted: { inuse: "0B" }, + is_fresh: false, + }, + }; + + return { + // 原始监控数据 + monitorData: trafficMonitorData, + // 图表数据管理 + graphData, + // 数据获取状态 + isLoading: !monitorData && !error, + error, + // 数据新鲜度 + isDataFresh: monitorData?.overall_status === "active", + }; +}; + +/** + * 仅获取流量数据的轻量级Hook + * 适用于不需要图表数据的组件 + */ +export const useTrafficData = () => { + const { monitorData, isLoading, error, isDataFresh } = useTrafficMonitor(); + + return { + traffic: monitorData.traffic, + memory: monitorData.memory, + isLoading, + error, + isDataFresh, + }; +}; + +/** + * 仅获取图表数据的Hook + * 适用于图表组件 + */ +export const useTrafficGraphData = () => { + const { graphData, isDataFresh } = useTrafficMonitor(); + + return { + ...graphData, + isDataFresh, + }; +}; diff --git a/src/locales/en.json b/src/locales/en.json index c2da4bc99..ef392efa5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -287,6 +287,7 @@ "Redir Port": "Redir Port", "Tproxy Port": "Tproxy Port", "External": "External", + "Enable External Controller": "Enable External Controller", "External Controller": "External Controller", "Core Secret": "Core Secret", "Recommended": "Recommended", @@ -653,5 +654,12 @@ "Development mode: Automatically includes Tauri and localhost origins": "Development mode: Automatically includes Tauri and localhost origins", "Invalid regular expression": "Invalid regular expression", "Copy Version": "Copy Version", - "Version copied to clipboard": "Version copied to clipboard" + "Version copied to clipboard": "Version copied to clipboard", + "Controller address cannot be empty": "Controller address cannot be empty", + "Secret cannot be empty": "Secret cannot be empty", + "Configuration saved successfully": "Configuration saved successfully", + "Failed to save configuration": "Failed to save configuration", + "Controller address copied to clipboard": "Controller address copied to clipboard", + "Secret copied to clipboard": "Secret copied to clipboard", + "Saving...": "Saving..." } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8421adde5..5ccab4afe 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -287,6 +287,7 @@ "Redir Port": "Redir 透明代理端口", "TPROXY Port": "TPROXY 透明代理端口", "External": "外部控制", + "Enable External Controller": "启用外部控制器", "External Controller": "外部控制器监听地址", "Core Secret": "API 访问密钥", "Recommended": "建议设置", @@ -653,5 +654,12 @@ "Development mode: Automatically includes Tauri and localhost origins": "开发模式:自动包含 Tauri 和 localhost 来源", "Invalid regular expression": "无效的正则表达式", "Copy Version": "复制Verge版本号", - "Version copied to clipboard": "Verge版本已复制到剪贴板" + "Version copied to clipboard": "Verge版本已复制到剪贴板", + "Controller address cannot be empty": "控制器地址不能为空", + "Secret cannot be empty": "访问密钥不能为空", + "Configuration saved successfully": "配置保存成功", + "Failed to save configuration": "配置保存失败", + "Controller address copied to clipboard": "控制器地址已复制到剪贴板", + "Secret copied to clipboard": "访问密钥已复制到剪贴板", + "Saving...": "保存中..." } diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index 4c8b6d4c1..4a9bc917a 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -9,7 +9,7 @@ import { PlayCircleOutlineRounded, PauseCircleOutlineRounded, } from "@mui/icons-material"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; import { useConnectionSetting } from "@/services/states"; import { BaseEmpty, BasePage } from "@/components/base"; import { ConnectionItem } from "@/components/connection/connection-item"; diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 71bfd73b2..3c90976ee 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -35,7 +35,7 @@ import { createProfile, } from "@/services/cmds"; import { useSetLoadingCache, useThemeMode } from "@/services/states"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; import { BasePage, DialogRef } from "@/components/base"; import { ProfileViewer, diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 26f220935..7c372e182 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { Box, Button, ButtonGroup } from "@mui/material"; -import { closeAllConnections, getClashConfig } from "@/services/api"; +import { closeAllConnections, getClashConfig } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds"; import { useVerge } from "@/hooks/use-verge"; import { BasePage } from "@/components/base"; diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index cbaebc989..dd1958849 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,14 +1,16 @@ import React, { createContext, useContext, useEffect, useMemo } from "react"; import { useVerge } from "@/hooks/use-verge"; import useSWR from "swr"; -import useSWRSubscription from "swr/subscription"; import { getProxies, getRules, getClashConfig, getProxyProviders, getRuleProviders, -} from "@/services/api"; + getConnections, + getTrafficData, + getMemoryData, +} from "@/services/cmds"; import { getSystemProxy, getRunningMode, @@ -16,7 +18,6 @@ import { forceRefreshProxies, } from "@/services/cmds"; import { useClashInfo } from "@/hooks/use-clash"; -import { createAuthSockette } from "@/utils/websocket"; import { useVisibility } from "@/hooks/use-visibility"; import { listen } from "@tauri-apps/api/event"; @@ -249,240 +250,61 @@ export const AppDataProvider = ({ suspense: false, }); - // 连接数据 - 使用WebSocket实时更新 + // 连接数据 - 使用IPC轮询更新 const { data: connectionsData = { connections: [], uploadTotal: 0, downloadTotal: 0, }, - } = useSWRSubscription( - clashInfo && pageVisible ? "connections" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Connections][${AppDataProvider.name}] 正在连接: ${server}/connections`, - ); - const socket = createAuthSockette(`${server}/connections`, secret, { - timeout: 5000, - onmessage(event) { - try { - const data = JSON.parse(event.data); - // 处理连接数据,计算当前上传下载速度 - next( - null, - ( - prev: any = { - connections: [], - uploadTotal: 0, - downloadTotal: 0, - }, - ) => { - const oldConns = prev.connections || []; - const newConns = data.connections || []; - - // 计算当前速度 - const processedConns = newConns.map((conn: any) => { - const oldConn = oldConns.find( - (old: any) => old.id === conn.id, - ); - if (oldConn) { - return { - ...conn, - curUpload: conn.upload - oldConn.upload, - curDownload: conn.download - oldConn.download, - }; - } - return { ...conn, curUpload: 0, curDownload: 0 }; - }); - - return { - ...data, - connections: processedConns, - }; - }, - ); - } catch (err) { - console.error( - `[Connections][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Connections][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Connections][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 }); - }, - onclose: (event) => { - console.log( - `[Connections][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Connections][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 }); - } - }, - }); - - return () => { - console.log(`[Connections][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); + } = useSWR( + clashInfo && pageVisible ? "getConnections" : null, + async () => { + const data = await getConnections(); + return { + connections: data.connections || [], + uploadTotal: data.uploadTotal || 0, + downloadTotal: data.downloadTotal || 0, }; }, + { + refreshInterval: 2000, // 2秒刷新一次 + fallbackData: { connections: [], uploadTotal: 0, downloadTotal: 0 }, + keepPreviousData: true, + onError: (error) => { + console.error("[Connections] IPC 获取数据错误:", error); + }, + }, ); - // 流量和内存数据 - 通过WebSocket获取实时流量数据 - const { data: trafficData = { up: 0, down: 0 } } = useSWRSubscription( - clashInfo && pageVisible ? "traffic" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Traffic][${AppDataProvider.name}] 正在连接: ${server}/traffic`, - ); - const socket = createAuthSockette(`${server}/traffic`, secret, { - onmessage(event) { - try { - const data = JSON.parse(event.data); - if ( - data && - typeof data.up === "number" && - typeof data.down === "number" - ) { - next(null, data); - } else { - console.warn( - `[Traffic][${AppDataProvider.name}] 收到无效数据:`, - data, - ); - } - } catch (err) { - console.error( - `[Traffic][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Traffic][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Traffic][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { up: 0, down: 0 }); - }, - onclose: (event) => { - console.log( - `[Traffic][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Traffic][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { up: 0, down: 0 }); - } - }, - }); - - return () => { - console.log(`[Traffic][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); - }; + // 流量数据 - 使用IPC轮询更新 + const { data: trafficData = { up: 0, down: 0 } } = useSWR( + clashInfo && pageVisible ? "getTrafficData" : null, + getTrafficData, + { + refreshInterval: 1000, // 1秒刷新一次 + fallbackData: { up: 0, down: 0 }, + keepPreviousData: true, + onSuccess: (data) => { + // console.log("[Traffic][AppDataProvider] IPC 获取到流量数据:", data); + }, + onError: (error) => { + console.error("[Traffic][AppDataProvider] IPC 获取数据错误:", error); + }, }, ); - const { data: memoryData = { inuse: 0 } } = useSWRSubscription( - clashInfo && pageVisible ? "memory" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Memory][${AppDataProvider.name}] 正在连接: ${server}/memory`, - ); - const socket = createAuthSockette(`${server}/memory`, secret, { - onmessage(event) { - try { - const data = JSON.parse(event.data); - if (data && typeof data.inuse === "number") { - next(null, data); - } else { - console.warn( - `[Memory][${AppDataProvider.name}] 收到无效数据:`, - data, - ); - } - } catch (err) { - console.error( - `[Memory][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Memory][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Memory][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { inuse: 0 }); - }, - onclose: (event) => { - console.log( - `[Memory][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Memory][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { inuse: 0 }); - } - }, - }); - - return () => { - console.log(`[Memory][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); - }; + // 内存数据 - 使用IPC轮询更新 + const { data: memoryData = { inuse: 0 } } = useSWR( + clashInfo && pageVisible ? "getMemoryData" : null, + getMemoryData, + { + refreshInterval: 2000, // 2秒刷新一次 + fallbackData: { inuse: 0 }, + keepPreviousData: true, + onError: (error) => { + console.error("[Memory] IPC 获取数据错误:", error); + }, }, ); diff --git a/src/services/api.ts b/src/services/api.ts index f108ebb30..0cb54214c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from "axios"; -import { getClashInfo } from "./cmds"; import { invoke } from "@tauri-apps/api/core"; +import { getClashInfo } from "./cmds"; let instancePromise: Promise = null!; @@ -39,292 +39,6 @@ export const getAxios = async (force: boolean = false) => { return instancePromise; }; -/// Get Version -export const getVersion = async () => { - const instance = await getAxios(); - return instance.get("/version") as Promise<{ - premium: boolean; - meta?: boolean; - version: string; - }>; -}; - -/// Get current base configs -export const getClashConfig = async () => { - const instance = await getAxios(); - return instance.get("/configs") as Promise; -}; - -/// Update geo data -export const updateGeoData = async () => { - const instance = await getAxios(); - return instance.post("/configs/geo"); -}; - -/// Upgrade clash core -export const upgradeCore = async () => { - const instance = await getAxios(); - return instance.post("/upgrade"); -}; - -/// Get current rules -export const getRules = async () => { - const instance = await getAxios(); - const response = await instance.get("/rules"); - return response?.rules as IRuleItem[]; -}; - -/// Get Proxy delay -export const getProxyDelay = async ( - name: string, - url?: string, - timeout?: number, -) => { - const params = { - timeout: timeout || 10000, - url: url || "https://cp.cloudflare.com/generate_204", - }; - const instance = await getAxios(); - const result = await instance.get( - `/proxies/${encodeURIComponent(name)}/delay`, - { params }, - ); - return result as any as { delay: number }; -}; - -/// Update the Proxy Choose -export const updateProxy = async (group: string, proxy: string) => { - const instance = await getAxios(); - return instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy }); -}; - -// get proxy -export const getProxiesInner = async () => { - const response = await invoke<{ proxies: Record }>( - "get_proxies", - ); - return response.proxies as Record; -}; - -/// Get the Proxy information -export const getProxies = async (): Promise<{ - global: IProxyGroupItem; - direct: IProxyItem; - groups: IProxyGroupItem[]; - records: Record; - proxies: IProxyItem[]; -}> => { - const [proxyRecord, providerRecord] = await Promise.all([ - getProxiesInner(), - getProxyProviders(), - ]); - // provider name map - const providerMap = Object.fromEntries( - Object.entries(providerRecord).flatMap(([provider, item]) => - item.proxies.map((p) => [p.name, { ...p, provider }]), - ), - ); - - // compatible with proxy-providers - const generateItem = (name: string) => { - if (proxyRecord[name]) return proxyRecord[name]; - if (providerMap[name]) return providerMap[name]; - return { - name, - type: "unknown", - udp: false, - xudp: false, - tfo: false, - mptcp: false, - smux: false, - history: [], - }; - }; - - const { GLOBAL: global, DIRECT: direct, REJECT: reject } = proxyRecord; - - let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce< - IProxyGroupItem[] - >((acc, each) => { - if (each.name !== "GLOBAL" && each.all) { - acc.push({ - ...each, - all: each.all!.map((item) => generateItem(item)), - }); - } - - return acc; - }, []); - - if (global?.all) { - let globalGroups: IProxyGroupItem[] = global.all.reduce( - (acc, name) => { - if (proxyRecord[name]?.all) { - acc.push({ - ...proxyRecord[name], - all: proxyRecord[name].all!.map((item) => generateItem(item)), - }); - } - return acc; - }, - [], - ); - - let globalNames = new Set(globalGroups.map((each) => each.name)); - groups = groups - .filter((group) => { - return !globalNames.has(group.name); - }) - .concat(globalGroups); - } - - const proxies = [direct, reject].concat( - Object.values(proxyRecord).filter( - (p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT", - ), - ); - - const _global: IProxyGroupItem = { - ...global, - all: global?.all?.map((item) => generateItem(item)) || [], - }; - - return { global: _global, direct, groups, records: proxyRecord, proxies }; -}; - -// get proxy providers -export const getProxyProviders = async () => { - const response = await invoke<{ - providers: Record; - }>("get_providers_proxies"); - if (!response || !response.providers) { - console.warn( - "getProxyProviders: Invalid response structure, returning empty object", - ); - return {}; - } - - const providers = response.providers as Record; - - return Object.fromEntries( - Object.entries(providers).filter(([key, item]) => { - const type = item.vehicleType.toLowerCase(); - return type === "http" || type === "file"; - }), - ); -}; - -export const getRuleProviders = async () => { - const instance = await getAxios(); - const response = await instance.get("/providers/rules"); - - const providers = (response.providers || {}) as Record< - string, - IRuleProviderItem - >; - - return Object.fromEntries( - Object.entries(providers).filter(([key, item]) => { - const type = item.vehicleType.toLowerCase(); - return type === "http" || type === "file"; - }), - ); -}; - -// proxy providers health check -export const providerHealthCheck = async (name: string) => { - const instance = await getAxios(); - return instance.get( - `/providers/proxies/${encodeURIComponent(name)}/healthcheck`, - ); -}; - -export const proxyProviderUpdate = async (name: string) => { - const instance = await getAxios(); - return instance.put(`/providers/proxies/${encodeURIComponent(name)}`); -}; - -export const ruleProviderUpdate = async (name: string) => { - const instance = await getAxios(); - return instance.put(`/providers/rules/${encodeURIComponent(name)}`); -}; - -export const getConnections = async () => { - const instance = await getAxios(); - const result = await instance.get("/connections"); - return result as any as IConnections; -}; - -// Close specific connection -export const deleteConnection = async (id: string) => { - const instance = await getAxios(); - await instance.delete(`/connections/${encodeURIComponent(id)}`); -}; - -// Close all connections -export const closeAllConnections = async () => { - const instance = await getAxios(); - await instance.delete("/connections"); -}; - -// Get Group Proxy Delays -export const getGroupProxyDelays = async ( - groupName: string, - url?: string, - timeout?: number, -) => { - const params = { - timeout: timeout || 10000, - url: url || "https://cp.cloudflare.com/generate_204", - }; - - console.log( - `[API] 获取代理组延迟,组: ${groupName}, URL: ${params.url}, 超时: ${params.timeout}ms`, - ); - - try { - const instance = await getAxios(); - console.log( - `[API] 发送HTTP请求: GET /group/${encodeURIComponent(groupName)}/delay`, - ); - - const result = await instance.get( - `/group/${encodeURIComponent(groupName)}/delay`, - { params }, - ); - - console.log( - `[API] 获取代理组延迟成功,组: ${groupName}, 结果数量:`, - Object.keys(result || {}).length, - ); - return result as any as Record; - } catch (error) { - console.error(`[API] 获取代理组延迟失败,组: ${groupName}`, error); - throw error; - } -}; - -// Is debug enabled -export const isDebugEnabled = async () => { - try { - const instance = await getAxios(); - await instance.get("/debug/pprof"); - return true; - } catch { - return false; - } -}; - -// GC -export const gc = async () => { - try { - const instance = await getAxios(); - await instance.put("/debug/gc"); - } catch (error) { - console.error(`Error gcing: ${error}`); - } -}; - // Get current IP and geolocation information (refactored IP detection with service-specific mappings) interface IpInfo { ip: string; diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 3df7bb19f..80bf0221c 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -94,6 +94,320 @@ export async function patchClashMode(payload: String) { return invoke("patch_clash_mode", { payload }); } +// New IPC-based API functions to replace HTTP API calls +export async function getVersion() { + return invoke<{ + premium: boolean; + meta?: boolean; + version: string; + }>("get_clash_version"); +} + +export async function getClashConfig() { + return invoke("get_clash_config"); +} + +export async function updateGeoData() { + return invoke("update_geo_data"); +} + +export async function upgradeCore() { + return invoke("upgrade_clash_core"); +} + +export async function getRules() { + const response = await invoke<{ rules: IRuleItem[] }>("get_clash_rules"); + return response?.rules || []; +} + +export async function getProxyDelay( + name: string, + url?: string, + timeout?: number, +) { + return invoke<{ delay: number }>("clash_api_get_proxy_delay", { + name, + url, + timeout: timeout || 10000, + }); +} + +export async function updateProxy(group: string, proxy: string) { + return await invoke("update_proxy_choice", { group, proxy }); +} + +export async function getProxies(): Promise<{ + global: IProxyGroupItem; + direct: IProxyItem; + groups: IProxyGroupItem[]; + records: Record; + proxies: IProxyItem[]; +}> { + const [proxyResponse, providerResponse] = await Promise.all([ + invoke<{ proxies: Record }>("get_proxies"), + invoke<{ providers: Record }>( + "get_providers_proxies", + ), + ]); + + const proxyRecord = proxyResponse.proxies; + const providerRecord = providerResponse.providers || {}; + + // provider name map + const providerMap = Object.fromEntries( + Object.entries(providerRecord).flatMap(([provider, item]) => + item.proxies.map((p) => [p.name, { ...p, provider }]), + ), + ); + + // compatible with proxy-providers + const generateItem = (name: string) => { + if (proxyRecord[name]) return proxyRecord[name]; + if (providerMap[name]) return providerMap[name]; + return { + name, + type: "unknown", + udp: false, + xudp: false, + tfo: false, + mptcp: false, + smux: false, + history: [], + }; + }; + + const { GLOBAL: global, DIRECT: direct, REJECT: reject } = proxyRecord; + + let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce< + IProxyGroupItem[] + >((acc, each) => { + if (each.name !== "GLOBAL" && each.all) { + acc.push({ + ...each, + all: each.all!.map((item) => generateItem(item)), + }); + } + + return acc; + }, []); + + if (global?.all) { + let globalGroups: IProxyGroupItem[] = global.all.reduce( + (acc, name) => { + if (proxyRecord[name]?.all) { + acc.push({ + ...proxyRecord[name], + all: proxyRecord[name].all!.map((item) => generateItem(item)), + }); + } + return acc; + }, + [], + ); + + let globalNames = new Set(globalGroups.map((each) => each.name)); + groups = groups + .filter((group) => { + return !globalNames.has(group.name); + }) + .concat(globalGroups); + } + + const proxies = [direct, reject].concat( + Object.values(proxyRecord).filter( + (p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT", + ), + ); + + const _global: IProxyGroupItem = { + ...global, + all: global?.all?.map((item) => generateItem(item)) || [], + }; + + return { global: _global, direct, groups, records: proxyRecord, proxies }; +} + +export async function getProxyProviders() { + const response = await invoke<{ + providers: Record; + }>("get_providers_proxies"); + if (!response || !response.providers) { + console.warn( + "getProxyProviders: Invalid response structure, returning empty object", + ); + return {}; + } + + const providers = response.providers as Record; + + return Object.fromEntries( + Object.entries(providers).filter(([key, item]) => { + const type = item.vehicleType.toLowerCase(); + return type === "http" || type === "file"; + }), + ); +} + +export async function getRuleProviders() { + const response = await invoke<{ + providers: Record; + }>("get_rule_providers"); + + const providers = (response.providers || {}) as Record< + string, + IRuleProviderItem + >; + + return Object.fromEntries( + Object.entries(providers).filter(([key, item]) => { + const type = item.vehicleType.toLowerCase(); + return type === "http" || type === "file"; + }), + ); +} + +export async function providerHealthCheck(name: string) { + return invoke("proxy_provider_health_check", { name }); +} + +export async function proxyProviderUpdate(name: string) { + return invoke("update_proxy_provider", { name }); +} + +export async function ruleProviderUpdate(name: string) { + return invoke("update_rule_provider", { name }); +} + +export async function getConnections() { + return invoke("get_clash_connections"); +} + +export async function deleteConnection(id: string) { + return invoke("delete_clash_connection", { id }); +} + +export async function closeAllConnections() { + return invoke("close_all_clash_connections"); +} + +export async function getGroupProxyDelays( + groupName: string, + url?: string, + timeout?: number, +) { + return invoke>("get_group_proxy_delays", { + groupName, + url, + timeout, + }); +} + +export async function getTrafficData() { + // console.log("[Traffic][Service] 开始调用 get_traffic_data"); + const result = await invoke("get_traffic_data"); + // console.log("[Traffic][Service] get_traffic_data 返回结果:", result); + return result; +} + +export async function getMemoryData() { + console.log("[Memory][Service] 开始调用 get_memory_data"); + const result = await invoke<{ + inuse: number; + oslimit?: number; + usage_percent?: number; + last_updated?: number; + }>("get_memory_data"); + console.log("[Memory][Service] get_memory_data 返回结果:", result); + return result; +} + +export async function getFormattedTrafficData() { + console.log("[Traffic][Service] 开始调用 get_formatted_traffic_data"); + const result = await invoke( + "get_formatted_traffic_data", + ); + console.log( + "[Traffic][Service] get_formatted_traffic_data 返回结果:", + result, + ); + return result; +} + +export async function getFormattedMemoryData() { + console.log("[Memory][Service] 开始调用 get_formatted_memory_data"); + const result = await invoke( + "get_formatted_memory_data", + ); + console.log("[Memory][Service] get_formatted_memory_data 返回结果:", result); + return result; +} + +export async function getSystemMonitorOverview() { + console.log("[Monitor][Service] 开始调用 get_system_monitor_overview"); + const result = await invoke( + "get_system_monitor_overview", + ); + console.log( + "[Monitor][Service] get_system_monitor_overview 返回结果:", + result, + ); + return result; +} + +// 带数据验证的安全版本 +export async function getSystemMonitorOverviewSafe() { + // console.log( + // "[Monitor][Service] 开始调用安全版本 get_system_monitor_overview", + // ); + try { + const result = await invoke("get_system_monitor_overview"); + // console.log("[Monitor][Service] 原始数据:", result); + + // 导入验证器(动态导入避免循环依赖) + const { systemMonitorValidator } = await import("@/utils/data-validator"); + + if (systemMonitorValidator.validate(result)) { + // console.log("[Monitor][Service] 数据验证通过"); + return result as ISystemMonitorOverview; + } else { + // console.warn("[Monitor][Service] 数据验证失败,使用清理后的数据"); + return systemMonitorValidator.sanitize(result); + } + } catch (error) { + // console.error("[Monitor][Service] API调用失败:", error); + // 返回安全的默认值 + const { systemMonitorValidator } = await import("@/utils/data-validator"); + return systemMonitorValidator.sanitize(null); + } +} + +export async function startTrafficService() { + console.log("[Traffic][Service] 开始调用 start_traffic_service"); + try { + const result = await invoke("start_traffic_service"); + console.log("[Traffic][Service] start_traffic_service 调用成功"); + return result; + } catch (error) { + console.error("[Traffic][Service] start_traffic_service 调用失败:", error); + throw error; + } +} + +export async function stopTrafficService() { + console.log("[Traffic][Service] 开始调用 stop_traffic_service"); + const result = await invoke("stop_traffic_service"); + console.log("[Traffic][Service] stop_traffic_service 调用成功"); + return result; +} + +export async function isDebugEnabled() { + return invoke("is_clash_debug_enabled"); +} + +export async function gc() { + return invoke("clash_gc"); +} + export async function getVergeConfig() { return invoke("get_verge_config"); } @@ -233,7 +547,9 @@ export async function cmdGetProxyDelay( /// 用于profile切换等场景 export async function forceRefreshProxies() { console.log("[API] 强制刷新代理缓存"); - return invoke("force_refresh_proxies"); + const result = await invoke("force_refresh_proxies"); + console.log("[API] 代理缓存刷新完成"); + return result; } export async function cmdTestDelay(url: string) { diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 4ded6a0f5..7f2aac0b0 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -131,6 +131,63 @@ interface IRuleProviderItem { interface ITrafficItem { up: number; down: number; + up_rate?: number; + down_rate?: number; + last_updated?: number; +} + +interface IFormattedTrafficData { + up_rate_formatted: string; + down_rate_formatted: string; + total_up_formatted: string; + total_down_formatted: string; + is_fresh: boolean; +} + +interface IFormattedMemoryData { + inuse_formatted: string; + oslimit_formatted: string; + usage_percent: number; + is_fresh: boolean; +} + +// 增强的类型安全接口定义,确保所有字段必需 +interface ISystemMonitorOverview { + traffic: { + raw: { + up: number; + down: number; + up_rate: number; + down_rate: number; + }; + formatted: { + up_rate: string; + down_rate: string; + total_up: string; + total_down: string; + }; + is_fresh: boolean; + }; + memory: { + raw: { + inuse: number; + oslimit: number; + usage_percent: number; + }; + formatted: { + inuse: string; + oslimit: string; + usage_percent: number; + }; + is_fresh: boolean; + }; + overall_status: "active" | "inactive" | "error" | "unknown"; +} + +// 类型安全的数据验证器 +interface ISystemMonitorOverviewValidator { + validate(data: any): data is ISystemMonitorOverview; + sanitize(data: any): ISystemMonitorOverview; } interface ILogItem { @@ -800,6 +857,7 @@ interface IVergeConfig { webdav_password?: string; home_cards?: Record; enable_hover_jump_navigator?: boolean; + enable_external_controller?: boolean; } interface IWebDavFile { diff --git a/src/utils/data-validator.ts b/src/utils/data-validator.ts new file mode 100644 index 000000000..219d94e42 --- /dev/null +++ b/src/utils/data-validator.ts @@ -0,0 +1,230 @@ +/** + * 类型安全的数据验证器 + * 确保从后端接收的数据符合预期的接口定义 + */ + +// 数字验证器 +function isValidNumber(value: any): value is number { + return typeof value === "number" && !isNaN(value) && isFinite(value); +} + +// 字符串验证器 +function isValidString(value: any): value is string { + return typeof value === "string" && value.length > 0; +} + +// 布尔值验证器 +function isValidBoolean(value: any): value is boolean { + return typeof value === "boolean"; +} + +/** + * 系统监控数据验证器 + */ +export class SystemMonitorValidator implements ISystemMonitorOverviewValidator { + /** + * 验证数据是否符合ISystemMonitorOverview接口 + */ + validate(data: any): data is ISystemMonitorOverview { + if (!data || typeof data !== "object") { + console.warn("[DataValidator] 数据不是对象:", data); + return false; + } + + // 验证traffic字段 + if (!this.validateTrafficData(data.traffic)) { + console.warn("[DataValidator] traffic字段验证失败:", data.traffic); + return false; + } + + // 验证memory字段 + if (!this.validateMemoryData(data.memory)) { + console.warn("[DataValidator] memory字段验证失败:", data.memory); + return false; + } + + // 验证overall_status字段 + if (!this.validateOverallStatus(data.overall_status)) { + console.warn( + "[DataValidator] overall_status字段验证失败:", + data.overall_status, + ); + return false; + } + + return true; + } + + /** + * 清理和修复数据,确保返回有效的ISystemMonitorOverview + */ + sanitize(data: any): ISystemMonitorOverview { + // console.log("[DataValidator] 开始数据清理:", data); + + const sanitized: ISystemMonitorOverview = { + traffic: this.sanitizeTrafficData(data?.traffic), + memory: this.sanitizeMemoryData(data?.memory), + overall_status: this.sanitizeOverallStatus(data?.overall_status), + }; + + // console.log("[DataValidator] 数据清理完成:", sanitized); + return sanitized; + } + + private validateTrafficData(traffic: any): boolean { + if (!traffic || typeof traffic !== "object") return false; + + // 验证raw字段 + const raw = traffic.raw; + if (!raw || typeof raw !== "object") return false; + if ( + !isValidNumber(raw.up) || + !isValidNumber(raw.down) || + !isValidNumber(raw.up_rate) || + !isValidNumber(raw.down_rate) + ) { + return false; + } + + // 验证formatted字段 + const formatted = traffic.formatted; + if (!formatted || typeof formatted !== "object") return false; + if ( + !isValidString(formatted.up_rate) || + !isValidString(formatted.down_rate) || + !isValidString(formatted.total_up) || + !isValidString(formatted.total_down) + ) { + return false; + } + + // 验证is_fresh字段 + if (!isValidBoolean(traffic.is_fresh)) return false; + + return true; + } + + private validateMemoryData(memory: any): boolean { + if (!memory || typeof memory !== "object") return false; + + // 验证raw字段 + const raw = memory.raw; + if (!raw || typeof raw !== "object") return false; + if ( + !isValidNumber(raw.inuse) || + !isValidNumber(raw.oslimit) || + !isValidNumber(raw.usage_percent) + ) { + return false; + } + + // 验证formatted字段 + const formatted = memory.formatted; + if (!formatted || typeof formatted !== "object") return false; + if ( + !isValidString(formatted.inuse) || + !isValidString(formatted.oslimit) || + !isValidNumber(formatted.usage_percent) + ) { + return false; + } + + // 验证is_fresh字段 + if (!isValidBoolean(memory.is_fresh)) return false; + + return true; + } + + private validateOverallStatus(status: any): boolean { + return ( + typeof status === "string" && + ["active", "inactive", "error", "unknown"].includes(status) + ); + } + + private sanitizeTrafficData(traffic: any) { + const raw = traffic?.raw || {}; + const formatted = traffic?.formatted || {}; + + return { + raw: { + up: isValidNumber(raw.up) ? raw.up : 0, + down: isValidNumber(raw.down) ? raw.down : 0, + up_rate: isValidNumber(raw.up_rate) ? raw.up_rate : 0, + down_rate: isValidNumber(raw.down_rate) ? raw.down_rate : 0, + }, + formatted: { + up_rate: isValidString(formatted.up_rate) ? formatted.up_rate : "0B", + down_rate: isValidString(formatted.down_rate) + ? formatted.down_rate + : "0B", + total_up: isValidString(formatted.total_up) ? formatted.total_up : "0B", + total_down: isValidString(formatted.total_down) + ? formatted.total_down + : "0B", + }, + is_fresh: isValidBoolean(traffic?.is_fresh) ? traffic.is_fresh : false, + }; + } + + private sanitizeMemoryData(memory: any) { + const raw = memory?.raw || {}; + const formatted = memory?.formatted || {}; + + return { + raw: { + inuse: isValidNumber(raw.inuse) ? raw.inuse : 0, + oslimit: isValidNumber(raw.oslimit) ? raw.oslimit : 0, + usage_percent: isValidNumber(raw.usage_percent) ? raw.usage_percent : 0, + }, + formatted: { + inuse: isValidString(formatted.inuse) ? formatted.inuse : "0B", + oslimit: isValidString(formatted.oslimit) ? formatted.oslimit : "0B", + usage_percent: isValidNumber(formatted.usage_percent) + ? formatted.usage_percent + : 0, + }, + is_fresh: isValidBoolean(memory?.is_fresh) ? memory.is_fresh : false, + }; + } + + private sanitizeOverallStatus( + status: any, + ): "active" | "inactive" | "error" | "unknown" { + if ( + typeof status === "string" && + ["active", "inactive", "error", "unknown"].includes(status) + ) { + return status as "active" | "inactive" | "error" | "unknown"; + } + return "unknown"; + } +} + +// 全局验证器实例 +export const systemMonitorValidator = new SystemMonitorValidator(); + +/** + * 安全的API调用包装器 + */ +export function withDataValidation Promise>( + apiCall: T, + validator: { validate: (data: any) => boolean; sanitize: (data: any) => any }, +): T { + return (async (...args: Parameters) => { + try { + const result = await apiCall(...args); + + if (validator.validate(result)) { + return result; + } else { + console.warn("[DataValidator] API返回数据验证失败,尝试修复:", result); + return validator.sanitize(result); + } + } catch (error) { + console.error("[DataValidator] API调用失败:", error); + // 返回安全的默认值 + return validator.sanitize(null); + } + }) as T; +} diff --git a/src/utils/traffic-diagnostics.ts b/src/utils/traffic-diagnostics.ts new file mode 100644 index 000000000..26af3939f --- /dev/null +++ b/src/utils/traffic-diagnostics.ts @@ -0,0 +1,181 @@ +/** + * 流量统计诊断工具 + * 用于帮助开发者和用户诊断流量统计系统的性能和状态 + */ + +interface IDiagnosticReport { + timestamp: string; + referenceCount: number; + samplerStats: { + rawBufferSize: number; + compressedBufferSize: number; + compressionQueueSize: number; + totalMemoryPoints: number; + }; + performance: { + memoryUsage: number; // MB + lastDataFreshness: boolean; + errorCount: number; + }; + recommendations: string[]; +} + +// 全局错误计数器 +let globalErrorCount = 0; + +/** + * 记录错误 + */ +export function recordTrafficError(error: Error, component: string) { + globalErrorCount++; + console.error( + `[TrafficDiagnostics] ${component} 错误 (#${globalErrorCount}):`, + error, + ); +} + +/** + * 获取内存使用情况(近似值) + */ +function getMemoryUsage(): number { + if ("memory" in performance) { + // @ts-ignore - 某些浏览器支持 + const memory = (performance as any).memory; + if (memory && memory.usedJSHeapSize) { + return memory.usedJSHeapSize / 1024 / 1024; // 转换为MB + } + } + return 0; +} + +/** + * 生成诊断报告 + */ +export function generateDiagnosticReport( + referenceCount: number, + samplerStats: any, + isDataFresh: boolean, +): IDiagnosticReport { + const memoryUsage = getMemoryUsage(); + const recommendations: string[] = []; + + // 分析引用计数 + if (referenceCount === 0) { + recommendations.push("✅ 没有组件在使用流量数据,数据收集已暂停"); + } else if (referenceCount > 3) { + recommendations.push("⚠️ 有较多组件在使用流量数据,考虑优化组件数量"); + } + + // 分析内存使用 + const totalPoints = samplerStats.totalMemoryPoints || 0; + if (totalPoints > 1000) { + recommendations.push("⚠️ 缓存的数据点过多,可能影响性能"); + } else if (totalPoints < 100) { + recommendations.push("ℹ️ 数据点较少,这是正常情况"); + } + + // 分析压缩效率 + const compressionRatio = + samplerStats.rawBufferSize > 0 + ? samplerStats.compressedBufferSize / samplerStats.rawBufferSize + : 0; + if (compressionRatio > 0.5) { + recommendations.push("⚠️ 数据压缩效率较低,可能需要调整压缩策略"); + } else if (compressionRatio > 0) { + recommendations.push("✅ 数据压缩效率良好"); + } + + // 分析数据新鲜度 + if (!isDataFresh) { + recommendations.push("⚠️ 数据不新鲜,可能存在网络问题或后端异常"); + } + + // 分析错误频率 + if (globalErrorCount > 10) { + recommendations.push("🚨 错误频率过高,建议检查网络连接和后端服务"); + } else if (globalErrorCount > 0) { + recommendations.push("ℹ️ 存在少量错误,这在网络波动时是正常的"); + } + + // 内存使用建议 + if (memoryUsage > 100) { + recommendations.push("⚠️ JavaScript堆内存使用较高,可能影响性能"); + } + + return { + timestamp: new Date().toISOString(), + referenceCount, + samplerStats, + performance: { + memoryUsage, + lastDataFreshness: isDataFresh, + errorCount: globalErrorCount, + }, + recommendations, + }; +} + +/** + * 格式化诊断报告为可读字符串 + */ +export function formatDiagnosticReport(report: IDiagnosticReport): string { + return ` +🔍 流量统计系统诊断报告 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📊 基本信息: + • 生成时间: ${report.timestamp} + • 活跃引用: ${report.referenceCount} 个组件 + • 数据新鲜度: ${report.performance.lastDataFreshness ? "✅ 新鲜" : "❌ 过期"} + +💾 数据缓存状态: + • 原始数据点: ${report.samplerStats.rawBufferSize} + • 压缩数据点: ${report.samplerStats.compressedBufferSize} + • 压缩队列: ${report.samplerStats.compressionQueueSize} + • 总内存点数: ${report.samplerStats.totalMemoryPoints} + +⚡ 性能指标: + • JS堆内存: ${report.performance.memoryUsage.toFixed(2)} MB + • 累计错误: ${report.performance.errorCount} 次 + +💡 优化建议: +${report.recommendations.map((rec) => ` ${rec}`).join("\n")} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + `.trim(); +} + +/** + * 自动诊断并打印报告 + */ +export function runTrafficDiagnostics( + referenceCount: number, + samplerStats: any, + isDataFresh: boolean, +): void { + const report = generateDiagnosticReport( + referenceCount, + samplerStats, + isDataFresh, + ); + console.log(formatDiagnosticReport(report)); +} + +/** + * 重置错误计数器 + */ +export function resetErrorCount(): void { + globalErrorCount = 0; + console.log("[TrafficDiagnostics] 错误计数器已重置"); +} + +// 导出到全局对象,方便在控制台调试 +if (typeof window !== "undefined") { + (window as any).trafficDiagnostics = { + generateDiagnosticReport, + formatDiagnosticReport, + runTrafficDiagnostics, + resetErrorCount, + recordTrafficError, + }; +}