├── .editorconfig ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── index.html ├── package.json ├── preview ├── 1.png └── 2.png ├── public └── doay.png ├── release.md ├── rustfmt.toml ├── src-tauri ├── .gitignore ├── Cargo.toml ├── build.rs ├── capabilities │ ├── default.json │ └── desktop.json ├── icons │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── info.plist ├── logger │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── src │ ├── args.rs │ ├── cleanup.rs │ ├── config.rs │ ├── dirs.rs │ ├── fs.rs │ ├── http.rs │ ├── lib.rs │ ├── log.rs │ ├── main.rs │ ├── network │ │ ├── linux.rs │ │ ├── macos.rs │ │ ├── mod.rs │ │ └── windows.rs │ ├── ray.rs │ ├── scan_ports.rs │ ├── setting.rs │ ├── setup.rs │ ├── sys_info.rs │ └── web.rs └── tauri.conf.json ├── src ├── App.css ├── App.tsx ├── component │ ├── AutoCompleteField.tsx │ ├── CodeViewer.tsx │ ├── PageHeader.tsx │ ├── PasswordInput.tsx │ ├── SelectField.tsx │ ├── SpeedGauge.tsx │ ├── useAlert.tsx │ ├── useAlertDialog.tsx │ ├── useAppBar.tsx │ ├── useCard.tsx │ ├── useChip.tsx │ ├── useDialog.tsx │ ├── useServerImport.tsx │ └── useSnackbar.tsx ├── context │ └── ThemeProvider.tsx ├── hook │ ├── useDebounce.ts │ ├── useFullHeight.ts │ ├── useInitLogLevel.ts │ ├── useNoBackspaceNav.ts │ ├── useVisibility.ts │ └── useWindowFocused.ts ├── main.tsx ├── util │ ├── concurrency.ts │ ├── config.ts │ ├── crypto.ts │ ├── dns.ts │ ├── highlightLog.ts │ ├── invoke.ts │ ├── network.ts │ ├── proxy.ts │ ├── ray.ts │ ├── rule.ts │ ├── server.ts │ ├── serverConf.ts │ ├── serverOption.ts │ ├── serverSpeed.ts │ ├── subscription.ts │ ├── tauri.ts │ ├── util.ts │ └── validate.ts ├── view │ ├── Dns.tsx │ ├── DnsModeEditor.tsx │ ├── DnsTable.tsx │ ├── Home.tsx │ ├── HomeBase.tsx │ ├── HomeRay.tsx │ ├── HttpTest.tsx │ ├── Log.tsx │ ├── LogDetail.tsx │ ├── Process.tsx │ ├── Rule.tsx │ ├── RuleAdvanced.tsx │ ├── RuleModeEditor.tsx │ ├── ScanPorts.tsx │ ├── Server.tsx │ ├── ServerCreate.tsx │ ├── ServerExport.tsx │ ├── ServerImport.tsx │ ├── ServerUpdate.tsx │ ├── Setting.tsx │ ├── SettingBase.tsx │ ├── SettingProxy.tsx │ ├── SettingRay.tsx │ ├── SettingWeb.tsx │ ├── SpeedTest.tsx │ ├── Subscription.tsx │ ├── SysInfo.tsx │ ├── TerminalCmd.tsx │ ├── Tool.tsx │ └── server │ │ ├── SsForm.tsx │ │ ├── TrojanForm.tsx │ │ ├── VlessForm.tsx │ │ └── VmessForm.tsx └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | max_line_length = 180 5 | 6 | indent_style = space 7 | indent_size = 4 8 | 9 | charset = utf-8 10 | end_of_line = lf 11 | 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | [*.json] 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | src-tauri/target 27 | test* 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "tauri-apps.tauri-vscode", 4 | "rust-lang.rust-analyzer" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doay 2 | 3 | 基于 Rust + TypeScript 开发的跨平台 Xray GUI 客户端,采用现代化架构设计,现已支持 MacOS、Windows 和 Linux,未来计划移植至 4 | iOS 和 Android 平台 5 | 6 | ## 支持协议 7 | 8 | - VLESS / VMESS / Shadowsocks / Trojan / SOCKS / HTTP / HTTPS 9 | 10 | ## 界面预览 11 | 12 | | 设置 | 工具 | 13 | |--------------------|--------------------| 14 | | ![](preview/1.png) | ![](preview/2.png) | 15 | 16 | ## 名字由来 17 | 18 | - dog + day = Doay 19 | - Doay 是一只每天都爱笑的小狗,时间久了,眯眼眯到看不清世界,最后连毛都笑绿了 20 | ![icon.png](src-tauri/icons/icon.png) 21 | 22 | ## 🍎 macOS 安装温馨提示 23 | 24 | 由于 Doay 未进行开发者签名,macOS 在首次安装时可能会提示: 25 | 26 | > “无法验证开发者” 或 “应用已损坏” 27 | 28 | 这是 macOS 的安全机制导致的,**并不代表 Doay 有任何风险**。请按照以下任一方法解决该问题。 29 | 30 | ### ✅ 方法一:通过系统设置放行 31 | 32 | 1. 打开 **系统设置 > 隐私与安全** 33 | 2. 在底部找到 Doay 的提示 34 | 3. 点击 **“仍然打开”** 35 | 4. 再次尝试启动 Doay 应用 36 | 37 | ### ✅ 方法二:使用终端解除隐私限制 38 | 39 | 如果你熟悉终端,可执行以下命令来移除 macOS 的安全限制: 40 | 41 | ``` 42 | xattr -r -d com.apple.quarantine /Applications/Doay.app 43 | ``` 44 | 45 | 如果上面命令失败,或权限不足,请使用管理员权限执行: 46 | 47 | ``` 48 | sudo xattr -rc /Applications/Doay.app 49 | ``` 50 | 51 | ### 🔐 安全说明 52 | 53 | 这些命令仅用于**解除 macOS 自动加上的“隔离标记”(quarantine)**,不会改动应用内容或系统设置。Doay 完全离线,无后门,不含任何恶意行为,**请放心使用**。 54 | 55 | ## 设计宗旨 56 | 57 | - 极简设计:界面简洁直观,操作路径最短化 58 | - 零学习成本:新手用户无需教程即可上手使用 59 | - 轻量高效:核心功能突出,避免功能臃肿 60 | - 跨平台设计:考虑移植成本,简化程序架构设计 61 | 62 | ## 架构优势 63 | 64 | - 跨平台:支持 MacOS、Windows 和 Linux,未来计划移植至 iOS 和 Android 平台 65 | - 极致性能:基于 Rust 构建的高性能后端,资源占用低,执行效率媲美 C/C++ 66 | - 内存优化:相比 Swift、Golang、Java、C# 等语言,Rust 内存占用更低,性能更优 67 | - 代码健壮:强类型语言 Rust + TypeScript,显著降低 Bug 发生率 68 | - 维护便捷:现代化架构设计,TypeScript 构建前端,依托 Web 生态,保证高效开发和便捷维护 69 | - 灵活扩展:低耦合架构设计,可轻松移植 Flutter、Electron 等技术栈,可快速扩展至 iOS、Android 等平台 70 | 71 | ## 特色功能 72 | 73 | - 启动极快:Doay 可在 1 秒内完成启动,从点击到界面显示几乎无延迟,提升使用体验 74 | - 安装包小:安装包仅 20 MB 左右,节省下载时间 75 | - 易用规则:访问(路由)规则配置简单灵活,小白用户易上手,高阶用户也能自由配置,可简可繁 76 | - DNS 设置:DNS 规则直观强大,普通用户可轻松使用,进阶用户可按需细调 77 | - 多种导入:支持手动添加、剪切板导入、二维码识别(摄像头、截图、图片文件) 78 | - 多种主题:内建浅色与深色主题,可自动适配系统外观,也支持手动切换,贴合用户使用习惯 79 | - 贴心订阅:不仅支持 JSON 接口链接,还支持 HTML 页面订阅,更方便获取免费机场节点 80 | - 日志查看:独创彩色日志查看器,支持 GB 级大日志的流畅浏览,智能高亮关键信息,大幅提升问题排查效率 81 | - 系统监控:实时监测 CPU、内存、磁盘、温度与网络资源,百分比展示,清晰掌握系统运行状况 82 | - 任务管理:查看并管理系统进程,支持结束任务和资源监控,更高效定位异常程序 83 | - 请求测试:便捷发送 HTTP 请求,支持查看请求头与 HTML 源码,方便连接验证代理服务器和接口调试 84 | - 网速测试:内置公网 IP 查看、Ping、抖动和上下行网速测试,真实反映代理服务器的网络带宽与连接质量 85 | - 端口扫描:高效扫描本机或远程主机的开放端口,支持设置扫描范围、线程数与超时时间,助力网络安全检测 86 | - 流量统计:实时监控网络流量,分类展示上传/下载情况,辅助识别异常网络行为 87 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Doay 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doay", 3 | "private": true, 4 | "version": "1.0.9", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "tauri": "tauri" 12 | }, 13 | "dependencies": { 14 | "@codemirror/lang-html": "^6.4.9", 15 | "@codemirror/lang-json": "^6.0.1", 16 | "@emotion/react": "^11.14.0", 17 | "@emotion/styled": "^11.14.0", 18 | "@mui/icons-material": "^7", 19 | "@mui/material": "^7", 20 | "@mui/x-charts": "^8.1.0", 21 | "@tauri-apps/api": "^2", 22 | "@tauri-apps/plugin-autostart": "~2", 23 | "@tauri-apps/plugin-clipboard-manager": "~2", 24 | "@tauri-apps/plugin-dialog": "~2", 25 | "@tauri-apps/plugin-opener": "^2", 26 | "@uiw/react-codemirror": "^4.23.12", 27 | "html5-qrcode": "^2.3.8", 28 | "qrcode.react": "^4.2.0", 29 | "react": "^19", 30 | "react-dom": "^19", 31 | "react-router-dom": "^7", 32 | "react-window": "^1.8.11" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.23.0", 36 | "@tauri-apps/cli": "^2", 37 | "@types/react": "^19", 38 | "@types/react-dom": "^19", 39 | "@types/react-window": "^1.8.8", 40 | "@vitejs/plugin-react-swc": "^3", 41 | "eslint": "^9", 42 | "eslint-plugin-react-hooks": "^5.1.0", 43 | "eslint-plugin-react-refresh": "^0.4.19", 44 | "globals": "^16.0.0", 45 | "typescript": "~5.8.2", 46 | "vite": "^6.2.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /preview/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/preview/1.png -------------------------------------------------------------------------------- /preview/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/preview/2.png -------------------------------------------------------------------------------- /public/doay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/public/doay.png -------------------------------------------------------------------------------- /release.md: -------------------------------------------------------------------------------- 1 | ## 界面预览 2 | 3 | | 设置 | 工具 | 4 | |--------------------|--------------------| 5 | | ![](preview/1.png) | ![](preview/2.png) | 6 | 7 | ## 🍎 macOS 安装温馨提示 8 | 9 | 由于 Doay 未进行开发者签名,macOS 在首次安装时可能会提示: 10 | 11 | > “无法验证开发者” 或 “应用已损坏” 12 | 13 | 这是 macOS 的安全机制导致的,**并不代表 Doay 有任何风险**。请按照以下任一方法解决该问题。 14 | 15 | ### ✅ 方法一:通过系统设置放行 16 | 17 | 1. 打开 **系统设置 > 隐私与安全** 18 | 2. 在底部找到 Doay 的提示 19 | 3. 点击 **“仍然打开”** 20 | 4. 再次尝试启动 Doay 应用 21 | 22 | ### ✅ 方法二:使用终端解除隐私限制 23 | 24 | 如果你熟悉终端,可执行以下命令来移除 macOS 的安全限制: 25 | 26 | ``` 27 | xattr -r -d com.apple.quarantine /Applications/Doay.app 28 | ``` 29 | 30 | 如果上面命令失败,或权限不足,请使用管理员权限执行: 31 | 32 | ``` 33 | sudo xattr -rc /Applications/Doay.app 34 | ``` 35 | 36 | ### 🔐 安全说明 37 | 38 | 这些命令仅用于**解除 macOS 自动加上的“隔离标记”(quarantine)**,不会改动应用内容或系统设置。Doay 完全离线,无后门,不含任何恶意行为,**请放心使用**。 39 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | newline_style = "Unix" 2 | use_small_heuristics = "Default" 3 | reorder_imports = true 4 | reorder_modules = true 5 | remove_nested_parens = true 6 | edition = "2021" 7 | merge_derives = true 8 | use_try_shorthand = false 9 | use_field_init_shorthand = false 10 | force_explicit_abi = true 11 | 12 | # normalize_comments = true 13 | # wrap_comments = true 14 | 15 | max_width = 160 16 | hard_tabs = false 17 | tab_spaces = 4 18 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "doay" 3 | version = "1.0.9" 4 | description = "A Doay App" 5 | authors = ["Doay"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant, but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "doay_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | logger = { path = "./logger" } 22 | 23 | log = "0.4" 24 | env_logger = "0.11" 25 | 26 | dirs = "6" 27 | chrono = "0.4" 28 | sysinfo = "0.35" 29 | once_cell = "1" 30 | regex = "1.11" 31 | reqwest = { version = "0.12", features = ["socks", "stream", "gzip", "brotli"] } 32 | futures-util = "0.3" 33 | 34 | #nix = { version = "0.29", features = ["fs"] } 35 | #rustc_version = "0.4" 36 | #tokio = { version = "1", features = ["full"] } 37 | tokio = { version = "1", features = ["net", "fs", "io-util", "sync", "time", "rt-multi-thread"] } 38 | 39 | actix-files = "0.6" 40 | actix-web = "4" 41 | 42 | serde = { version = "1", features = ["derive"] } 43 | serde_json = "1" 44 | 45 | tauri = { version = "2", features = ["tray-icon", "image-png"] } 46 | tauri-plugin-opener = "2" 47 | tauri-plugin-clipboard-manager = "2" 48 | tauri-plugin-dialog = "2" 49 | 50 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 51 | tauri-plugin-autostart = "2" 52 | tauri-plugin-single-instance = "2" 53 | 54 | [target.'cfg(windows)'.dependencies] 55 | winapi = { version = "0.3.9", features = ["wininet"] } 56 | 57 | [profile.release] 58 | #opt-level = "z" # 优化体积(0 1 2 3 "s" 或 "z") 59 | #lto = true # 启用 Link Time Optimization 60 | #codegen-units = 1 # 降低并发编译数量,提高优化质量 61 | #strip = true # 去除调试符号 62 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | let output = Command::new("rustc").arg("-V").output().expect("Failed to execute rustc"); 5 | let version = String::from_utf8_lossy(&output.stdout); 6 | println!("cargo:rustc-env=RUSTC_VERSION={}", version.trim()); 7 | 8 | tauri_build::build() 9 | } 10 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "autostart:default", 10 | "core:default", 11 | "core:window:default", 12 | "core:window:allow-hide", 13 | "core:window:allow-show", 14 | "core:window:allow-set-title", 15 | "core:window:allow-set-focus", 16 | "core:window:allow-set-theme", 17 | "core:window:allow-set-always-on-top", 18 | "core:webview:default", 19 | "clipboard-manager:default", 20 | "clipboard-manager:allow-write-text", 21 | "clipboard-manager:allow-read-text", 22 | "clipboard-manager:allow-write-image", 23 | "clipboard-manager:allow-read-image", 24 | "dialog:default", 25 | "opener:default" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": [ 4 | "macOS", 5 | "windows", 6 | "linux" 7 | ], 8 | "windows": [ 9 | "main" 10 | ], 11 | "permissions": [] 12 | } 13 | -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doayapp/doay/11691e55d669108eb762fdd33610d5d062112b53/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSCameraUsageDescription 6 | 允许访问摄像头权限 7 | 8 | 9 | -------------------------------------------------------------------------------- /src-tauri/logger/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "logger" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | chrono = "0.4.40" 8 | once_cell = "1.21.0" 9 | -------------------------------------------------------------------------------- /src-tauri/logger/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! 这是一个日志记录模块,提供了日志记录功能 2 | //! 支持不同级别的日志(如 None, Error, Warn, Info, Debug, Trace) 3 | //! 可以将日志输出到控制台和文件,并支持日志文件的自动轮转 4 | 5 | /** 6 | 用例: 7 | [dependencies] 8 | logger = { path = "./logger" } 9 | 10 | use logger::{debug, error, info, trace, warn}; 11 | 12 | fn main() { 13 | logger::set_log_level("info"); 14 | logger::set_log_filepath("logs/main.log").unwrap_or_else(|e| { 15 | eprintln!("设置日志文件路径失败: {}", e); 16 | }); 17 | logger::set_log_max_size(2 * 1024 * 1024); 18 | 19 | println!("{:?}", logger::get_log_config()); 20 | println!("{:?}", logger::get_log_writer()); 21 | 22 | // 测试性能的循环 23 | for i in 0..3 { 24 | error!("这是一个日志,{}", i); 25 | warn!("这是一个日志,{}", i); 26 | info!("这是一个日志,{}", i); 27 | debug!("这是一个日志,{}", i); 28 | trace!("这是一个日志,{}", i); 29 | } 30 | } 31 | */ 32 | use chrono::Local; 33 | use once_cell::sync::Lazy; 34 | use std::fs::{self, File}; 35 | use std::io::{self, BufWriter, Write}; 36 | use std::path::PathBuf; 37 | use std::sync::{Mutex, MutexGuard}; 38 | 39 | // 日志级别 40 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 41 | pub enum LogLevel { 42 | None, 43 | Error, 44 | Warn, 45 | Info, 46 | Debug, 47 | Trace, 48 | } 49 | 50 | // 日志配置 51 | #[derive(Debug)] 52 | pub struct LogConfig { 53 | pub log_level: LogLevel, 54 | pub log_filepath: Option, 55 | pub log_max_size: u64, 56 | } 57 | 58 | static LOG_CONFIG: Lazy> = Lazy::new(|| { 59 | Mutex::new(LogConfig { 60 | log_level: LogLevel::Trace, 61 | log_filepath: None, 62 | log_max_size: 5 * 1024 * 1024, // 设置默认值为 5MB 63 | }) 64 | }); 65 | 66 | static LOG_WRITER: Lazy>>> = Lazy::new(|| Mutex::new(None)); 67 | 68 | // 获取全局配置 69 | pub fn get_log_config() -> MutexGuard<'static, LogConfig> { 70 | LOG_CONFIG.lock().unwrap() 71 | } 72 | 73 | // 初始化 BufWriter 74 | fn init_log_writer(filepath: &PathBuf) -> io::Result<()> { 75 | let mut writer = LOG_WRITER.lock().unwrap(); 76 | if writer.is_none() { 77 | let file = File::options().append(true).create(true).open(filepath)?; 78 | *writer = Some(BufWriter::new(file)); 79 | } 80 | Ok(()) 81 | } 82 | 83 | // 设置 BufWriter 84 | fn set_log_writer(filepath: &PathBuf) -> io::Result<()> { 85 | let mut writer = LOG_WRITER.lock().unwrap(); 86 | let file = File::options().append(true).create(true).open(filepath)?; 87 | *writer = Some(BufWriter::new(file)); 88 | Ok(()) 89 | } 90 | 91 | // 获取 BufWriter 92 | pub fn get_log_writer() -> MutexGuard<'static, Option>> { 93 | LOG_WRITER.lock().unwrap() 94 | } 95 | 96 | // 日志级别字符串 97 | pub fn level_str(level: LogLevel) -> &'static str { 98 | match level { 99 | LogLevel::None => "none", 100 | LogLevel::Error => "error", 101 | LogLevel::Warn => "warn", 102 | LogLevel::Info => "info", 103 | LogLevel::Debug => "debug", 104 | LogLevel::Trace => "trace", 105 | } 106 | } 107 | 108 | pub fn log(level: LogLevel, message: &str) -> Result<(), io::Error> { 109 | let config = get_log_config(); 110 | // 如果当前日志级别低于设置的级别,则不记录 111 | if level > config.log_level || config.log_level == LogLevel::None { 112 | // println!("{:?} 大于 {:?}", level, config.log_level); 113 | return Ok(()); 114 | } 115 | 116 | let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string(); 117 | let log_message = format!("{} [{}] {}\n", timestamp, level_str(level), message); 118 | 119 | // 输出到控制台 120 | match level { 121 | LogLevel::None => (), 122 | LogLevel::Error => println!("\x1b[31m{}\x1b[0m", log_message.trim_end()), 123 | LogLevel::Warn => println!("\x1b[33m{}\x1b[0m", log_message.trim_end()), 124 | LogLevel::Info => println!("\x1b[32m{}\x1b[0m", log_message.trim_end()), 125 | LogLevel::Debug => println!("\x1b[34m{}\x1b[0m", log_message.trim_end()), 126 | LogLevel::Trace => println!("\x1b[35m{}\x1b[0m", log_message.trim_end()), 127 | } 128 | 129 | if let Some(log_filepath) = &config.log_filepath { 130 | init_log_writer(log_filepath)?; // 初始化 BufWriter 131 | if let Some(writer) = get_log_writer().as_mut() { 132 | writer.write_all(log_message.as_bytes())?; // 写入日志,性能比 writeln! 高 133 | writer.flush()?; 134 | } 135 | 136 | // 判断文件大小是否超过最大值 137 | let metadata = fs::metadata(log_filepath)?; 138 | if metadata.len() > config.log_max_size { 139 | let file_stem = log_filepath.file_stem().and_then(|stem| stem.to_str()).unwrap_or("log"); 140 | let extension = log_filepath.extension().and_then(|ext| ext.to_str()).unwrap_or("log"); 141 | let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f"); 142 | let bak_filepath = log_filepath.with_file_name(format!("{}.{}.{}", file_stem, timestamp, extension)); 143 | fs::rename(log_filepath, bak_filepath)?; 144 | set_log_writer(log_filepath)?; 145 | } 146 | } 147 | 148 | Ok(()) 149 | } 150 | 151 | // 设置日志级别 152 | pub fn set_log_level(level_str: &str) { 153 | let mut config = get_log_config(); 154 | config.log_level = match level_str.to_lowercase().as_str() { 155 | "error" => LogLevel::Error, 156 | "warn" => LogLevel::Warn, 157 | "info" => LogLevel::Info, 158 | "debug" => LogLevel::Debug, 159 | "trace" => LogLevel::Trace, 160 | _ => LogLevel::None, // 匹配不上的情况设置为 None 161 | }; 162 | } 163 | 164 | // 设置日志文件路径 165 | pub fn set_log_filepath(filepath: &str) -> io::Result<()> { 166 | let mut config = get_log_config(); 167 | config.log_filepath = Some(PathBuf::from(filepath)); 168 | if let Some(log_filepath) = config.log_filepath.as_ref() { 169 | // 如果有值,创建父目录 170 | if let Some(parent_dir) = log_filepath.parent() { 171 | if !parent_dir.exists() { 172 | fs::create_dir_all(parent_dir)?; 173 | } 174 | } 175 | } 176 | set_log_writer(&PathBuf::from(filepath))?; 177 | Ok(()) 178 | } 179 | 180 | // 设置日志文件最大大小(单位:字节) 181 | pub fn set_log_max_size(size: u64) { 182 | let mut config = get_log_config(); 183 | config.log_max_size = size 184 | } 185 | 186 | // 定义日志宏 187 | #[macro_export] 188 | macro_rules! error { 189 | ($($arg:tt)*) => { 190 | if let Err(e) = $crate::log($crate::LogLevel::Error, &format!($($arg)*)) { 191 | eprintln!("Failed to log: {}", e); 192 | } 193 | }; 194 | } 195 | 196 | #[macro_export] 197 | macro_rules! warn { 198 | ($($arg:tt)*) => { 199 | if let Err(e) = $crate::log($crate::LogLevel::Warn, &format!($($arg)*)) { 200 | eprintln!("Failed to log: {}", e); 201 | } 202 | }; 203 | } 204 | 205 | #[macro_export] 206 | macro_rules! info { 207 | ($($arg:tt)*) => { 208 | if let Err(e) = $crate::log($crate::LogLevel::Info, &format!($($arg)*)) { 209 | eprintln!("Failed to log: {}", e); 210 | } 211 | }; 212 | } 213 | 214 | #[macro_export] 215 | macro_rules! debug { 216 | ($($arg:tt)*) => { 217 | if let Err(e) = $crate::log($crate::LogLevel::Debug, &format!($($arg)*)) { 218 | eprintln!("Failed to log: {}", e); 219 | } 220 | }; 221 | } 222 | 223 | #[macro_export] 224 | macro_rules! trace { 225 | ($($arg:tt)*) => { 226 | if let Err(e) = $crate::log($crate::LogLevel::Trace, &format!($($arg)*)) { 227 | eprintln!("Failed to log: {}", e); 228 | } 229 | }; 230 | } 231 | -------------------------------------------------------------------------------- /src-tauri/src/args.rs: -------------------------------------------------------------------------------- 1 | use logger::trace; 2 | use once_cell::sync::Lazy; 3 | use std::env; 4 | use std::sync::Mutex; 5 | 6 | static QUIET_MODE: Lazy> = Lazy::new(|| Mutex::new(false)); 7 | 8 | pub fn parse_args() { 9 | let args: Vec = env::args().collect(); 10 | trace!("Arguments: {:?}", args); 11 | 12 | if args.len() == 3 && args[1] == "-s" && args[2] == "quiet" { 13 | let mut quiet_mode = QUIET_MODE.lock().unwrap(); 14 | *quiet_mode = true; 15 | } 16 | } 17 | 18 | pub fn is_quiet_mode() -> bool { 19 | let quiet_mode = QUIET_MODE.lock().unwrap(); 20 | *quiet_mode 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/cleanup.rs: -------------------------------------------------------------------------------- 1 | use crate::network; 2 | use crate::ray; 3 | use logger::info; 4 | use tauri::{plugin::Plugin, AppHandle, RunEvent, Runtime}; 5 | 6 | pub struct CleanupPlugin; 7 | 8 | impl Plugin for CleanupPlugin { 9 | fn name(&self) -> &'static str { 10 | "cleanup-plugin" 11 | } 12 | 13 | fn on_event(&mut self, _app: &AppHandle, event: &RunEvent) { 14 | match event { 15 | RunEvent::Exit => { 16 | exit_cleanly(); 17 | } 18 | _ => {} 19 | } 20 | } 21 | } 22 | 23 | pub fn init() -> CleanupPlugin { 24 | CleanupPlugin {} 25 | } 26 | 27 | pub fn exit_cleanly() { 28 | network::disable_proxies(); 29 | let _ = ray::stop() && ray::force_kill(); 30 | info!("Cleanup completed"); 31 | } 32 | -------------------------------------------------------------------------------- /src-tauri/src/dirs.rs: -------------------------------------------------------------------------------- 1 | use dirs; 2 | use once_cell::sync::Lazy; 3 | use serde_json::{json, Value}; 4 | use std::env; 5 | 6 | static APP_DATA_DIR: Lazy> = Lazy::new(|| dirs::data_dir().map(|dir| dir.join("doay"))); 7 | 8 | pub fn get_app_data_dir() -> Option { 9 | APP_DATA_DIR.clone() 10 | } 11 | 12 | pub fn get_doay_app_dir_str() -> String { 13 | APP_DATA_DIR.as_ref().and_then(|p| p.to_str().map(str::to_string)).unwrap_or_default() 14 | } 15 | 16 | pub fn get_doay_conf_dir() -> Option { 17 | get_app_data_dir().map(|dir| dir.join("conf")) 18 | } 19 | 20 | pub fn get_doay_logs_dir() -> Option { 21 | get_app_data_dir().map(|dir| dir.join("logs")) 22 | } 23 | 24 | pub fn get_doay_web_server_dir() -> Option { 25 | get_app_data_dir().map(|dir| dir.join("web_server")) 26 | } 27 | 28 | pub fn get_dirs_json() -> Value { 29 | json!({ 30 | "executable_path": env::current_exe().ok(), 31 | "current_dir": env::current_dir().ok(), 32 | "home_dir": dirs::home_dir(), 33 | "data_dir": dirs::data_dir(), 34 | "audio_dir": dirs::audio_dir(), 35 | "cache_dir": dirs::cache_dir(), 36 | "config_dir": dirs::config_dir(), 37 | "desktop_dir": dirs::desktop_dir(), 38 | "document_dir": dirs::document_dir(), 39 | "download_dir": dirs::download_dir(), 40 | "font_dir": dirs::font_dir(), 41 | "picture_dir": dirs::picture_dir(), 42 | "public_dir": dirs::public_dir(), 43 | "video_dir": dirs::video_dir(), 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src-tauri/src/fs.rs: -------------------------------------------------------------------------------- 1 | use logger::{debug, error}; 2 | use std::fs; 3 | use std::io::Write; 4 | 5 | pub fn save_text_file(path: &str, content: &str) -> bool { 6 | if path.contains("../") || path.contains("..\\") { 7 | error!("Blocked potentially malicious path: {}", path); 8 | return false; 9 | } 10 | 11 | match fs::File::create(path) { 12 | Ok(mut file) => { 13 | if let Err(e) = file.write_all(content.as_bytes()) { 14 | error!("Failed to write text file: {}", e); 15 | false 16 | } else { 17 | debug!("Text file saved successfully: {}", path); 18 | true 19 | } 20 | } 21 | Err(e) => { 22 | error!("Failed to create text file: {}", e); 23 | false 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | doay_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/network/linux.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use logger::{error, info}; 3 | use std::process::Command; 4 | 5 | const GSETTINGS_PROXY: &str = "gsettings set org.gnome.system.proxy"; 6 | 7 | pub fn command(cmd: &str) -> bool { 8 | let cmd = cmd.trim(); 9 | if cmd.is_empty() { 10 | return false; 11 | } 12 | 13 | let status = Command::new("sh").arg("-c").arg(cmd).status(); 14 | 15 | match status { 16 | Ok(status) if status.success() => { 17 | info!("Command [{}] executed successfully", cmd); 18 | true 19 | } 20 | Ok(status) => { 21 | error!("Command [{}] failed with status: {}", cmd, status); 22 | false 23 | } 24 | Err(e) => { 25 | error!("Failed to execute command [{}]: {}", cmd, e); 26 | false 27 | } 28 | } 29 | } 30 | 31 | pub fn enable_auto_proxy() -> bool { 32 | let config = config::get_config(); 33 | let url = format!("http://{}:{}/doay/proxy.js", config.web_server_host, config.web_server_port); 34 | command(&format!("{} autoconfig-url '{}'", GSETTINGS_PROXY, url)) && command(&format!("{} mode 'auto'", GSETTINGS_PROXY)) 35 | } 36 | 37 | pub fn enable_socks_proxy() -> bool { 38 | let config = config::get_config(); 39 | command(&format!("{}.socks host '{}'", GSETTINGS_PROXY, config.ray_host)) 40 | && command(&format!("{}.socks port {}", GSETTINGS_PROXY, config.ray_socks_port)) 41 | && command(&format!("{} mode 'manual'", GSETTINGS_PROXY)) 42 | } 43 | 44 | pub fn enable_web_proxy() -> bool { 45 | let config = config::get_config(); 46 | command(&format!("{}.http host '{}'", GSETTINGS_PROXY, config.ray_host)) 47 | && command(&format!("{}.http port {}", GSETTINGS_PROXY, config.ray_http_port)) 48 | && command(&format!("{} mode 'manual'", GSETTINGS_PROXY)) 49 | } 50 | 51 | pub fn enable_secure_web_proxy() -> bool { 52 | let config = config::get_config(); 53 | command(&format!("{}.https host '{}'", GSETTINGS_PROXY, config.ray_host)) 54 | && command(&format!("{}.https port {}", GSETTINGS_PROXY, config.ray_http_port)) 55 | && command(&format!("{} mode 'manual'", GSETTINGS_PROXY)) 56 | } 57 | 58 | pub fn disable_auto_proxy() -> bool { 59 | command(&format!("{} mode 'none'", GSETTINGS_PROXY)) 60 | } 61 | 62 | pub fn disable_socks_proxy() -> bool { 63 | command(&format!("{}.socks host ''", GSETTINGS_PROXY)) && command(&format!("{}.socks port 0", GSETTINGS_PROXY)) 64 | } 65 | 66 | pub fn disable_web_proxy() -> bool { 67 | command(&format!("{}.http host ''", GSETTINGS_PROXY)) && command(&format!("{}.http port 0", GSETTINGS_PROXY)) 68 | } 69 | 70 | pub fn disable_secure_web_proxy() -> bool { 71 | command(&format!("{}.https host ''", GSETTINGS_PROXY)) && command(&format!("{}.https port 0", GSETTINGS_PROXY)) 72 | } 73 | 74 | pub fn disable_proxies() -> bool { 75 | disable_auto_proxy() && disable_socks_proxy() && disable_web_proxy() && disable_secure_web_proxy() 76 | } 77 | -------------------------------------------------------------------------------- /src-tauri/src/network/macos.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use logger::{error, info}; 3 | use std::process::Command; 4 | 5 | pub fn command(cmd_str: &str) -> bool { 6 | let trimmed = cmd_str.trim(); 7 | if trimmed.is_empty() { 8 | error!("Empty command string received"); 9 | return false; 10 | } 11 | 12 | let parts: Vec<&str> = trimmed.split_whitespace().collect(); 13 | if parts.len() < 2 { 14 | error!("Invalid command: [{}]", trimmed); 15 | return false; 16 | } 17 | 18 | let cmd = parts[0]; 19 | let args = &parts[1..]; 20 | 21 | match Command::new(cmd).args(args).status() { 22 | Ok(status) if status.success() => { 23 | info!("Command [{} {}] executed successfully", cmd, args.join(" ")); 24 | true 25 | } 26 | Ok(status) => { 27 | error!("Command failed: [{} {}], exit status: {}", cmd, args.join(" "), status); 28 | false 29 | } 30 | Err(e) => { 31 | error!("Failed to start command: [{} {}], error: {}", cmd, args.join(" "), e); 32 | false 33 | } 34 | } 35 | } 36 | 37 | /*pub fn commands(cmd_str: &str) -> bool { 38 | cmd_str.trim().lines().all(|cmd| command(cmd)) 39 | }*/ 40 | 41 | // 获取当前使用的网络接口名称,如果没有获取到则返回 "Wi-Fi" 42 | fn get_active_network_interface() -> String { 43 | let output = Command::new("networksetup").arg("-listallnetworkservices").output().ok().and_then(|output| { 44 | if output.status.success() { 45 | String::from_utf8(output.stdout).ok() 46 | } else { 47 | None 48 | } 49 | }); 50 | 51 | output 52 | .and_then(|s| { 53 | s.lines() 54 | .skip(1) // 跳过第一行提示信息 55 | .find(|line| !line.starts_with('*')) // 找到第一个未标记为禁用的接口 56 | .map(|line| line.trim().to_string()) 57 | }) 58 | .unwrap_or_else(|| "Wi-Fi".to_string()) 59 | } 60 | 61 | pub fn enable_auto_proxy() -> bool { 62 | let config = config::get_config(); 63 | let interface = get_active_network_interface(); 64 | let url = format!("http://{}:{}/doay/proxy.js", config.web_server_host, config.web_server_port); 65 | command(&format!("networksetup -setautoproxyurl {} {}", interface, url)) && command(&format!("networksetup -setautoproxystate {} on", interface)) 66 | } 67 | 68 | pub fn enable_socks_proxy() -> bool { 69 | let config = config::get_config(); 70 | let interface = get_active_network_interface(); 71 | command(&format!( 72 | "networksetup -setsocksfirewallproxy {} {} {}", 73 | interface, config.ray_host, config.ray_socks_port 74 | )) && command(&format!("networksetup -setsocksfirewallproxystate {} on", interface)) 75 | } 76 | 77 | pub fn enable_web_proxy() -> bool { 78 | let config = config::get_config(); 79 | let interface = get_active_network_interface(); 80 | command(&format!("networksetup -setwebproxy {} {} {}", interface, config.ray_host, config.ray_http_port)) 81 | && command(&format!("networksetup -setwebproxystate {} on", interface)) 82 | } 83 | 84 | pub fn enable_secure_web_proxy() -> bool { 85 | let config = config::get_config(); 86 | let interface = get_active_network_interface(); 87 | command(&format!( 88 | "networksetup -setsecurewebproxy {} {} {}", 89 | interface, config.ray_host, config.ray_http_port 90 | )) && command(&format!("networksetup -setsecurewebproxystate {} on", interface)) 91 | } 92 | 93 | pub fn disable_auto_proxy() -> bool { 94 | let interface = get_active_network_interface(); 95 | command(&format!("networksetup -setautoproxystate {} off", interface)) 96 | } 97 | 98 | pub fn disable_socks_proxy() -> bool { 99 | let interface = get_active_network_interface(); 100 | command(&format!("networksetup -setsocksfirewallproxystate {} off", interface)) 101 | } 102 | 103 | pub fn disable_web_proxy() -> bool { 104 | let interface = get_active_network_interface(); 105 | command(&format!("networksetup -setwebproxystate {} off", interface)) 106 | } 107 | 108 | pub fn disable_secure_web_proxy() -> bool { 109 | let interface = get_active_network_interface(); 110 | command(&format!("networksetup -setsecurewebproxystate {} off", interface)) 111 | } 112 | 113 | pub fn disable_proxies() -> bool { 114 | let interface = get_active_network_interface(); 115 | command(&format!("networksetup -setautoproxystate {} off", interface)) 116 | && command(&format!("networksetup -setsocksfirewallproxystate {} off", interface)) 117 | && command(&format!("networksetup -setwebproxystate {} off", interface)) 118 | && command(&format!("networksetup -setsecurewebproxystate {} off", interface)) 119 | } 120 | -------------------------------------------------------------------------------- /src-tauri/src/network/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use logger::error; 3 | 4 | #[cfg(target_os = "linux")] 5 | pub mod linux; 6 | 7 | #[cfg(target_os = "macos")] 8 | pub mod macos; 9 | 10 | #[cfg(target_os = "windows")] 11 | pub mod windows; 12 | 13 | #[cfg(target_os = "linux")] 14 | pub use linux::*; 15 | 16 | #[cfg(target_os = "macos")] 17 | pub use macos::*; 18 | 19 | #[cfg(target_os = "windows")] 20 | pub use windows::*; 21 | 22 | pub fn setup_proxies() -> bool { 23 | let config = config::get_config(); 24 | let mut success = true; 25 | 26 | if config.auto_setup_pac && !enable_auto_proxy() { 27 | error!("Failed to enable auto proxy (PAC)"); 28 | success = false; 29 | } 30 | if config.auto_setup_socks && !enable_socks_proxy() { 31 | error!("Failed to enable SOCKS proxy"); 32 | success = false; 33 | } 34 | if config.auto_setup_http && !enable_web_proxy() { 35 | error!("Failed to enable HTTP proxy"); 36 | success = false; 37 | } 38 | if config.auto_setup_https && !enable_secure_web_proxy() { 39 | error!("Failed to enable HTTPS proxy"); 40 | success = false; 41 | } 42 | 43 | success 44 | } 45 | 46 | pub fn setup_pac_proxy() -> bool { 47 | let config = config::get_config(); 48 | if config.auto_setup_pac { 49 | if !enable_auto_proxy() { 50 | error!("Failed to enable auto proxy (PAC)"); 51 | return false; 52 | } 53 | true 54 | } else { 55 | true 56 | } 57 | } 58 | 59 | pub fn setup_socks_proxy() -> bool { 60 | let config = config::get_config(); 61 | let mut success = true; 62 | 63 | if config.auto_setup_socks { 64 | if !enable_socks_proxy() { 65 | error!("Failed to enable SOCKS proxy"); 66 | success = false; 67 | } 68 | } 69 | if config.auto_setup_pac { 70 | if !enable_auto_proxy() { 71 | error!("Failed to enable auto proxy (PAC)"); 72 | success = false; 73 | } 74 | } 75 | 76 | success 77 | } 78 | 79 | pub fn setup_http_proxy() -> bool { 80 | let config = config::get_config(); 81 | let mut success = true; 82 | 83 | if config.auto_setup_http { 84 | if !enable_web_proxy() { 85 | error!("Failed to enable HTTP proxy"); 86 | success = false; 87 | } 88 | } 89 | if config.auto_setup_https { 90 | if !enable_secure_web_proxy() { 91 | error!("Failed to enable HTTPS proxy"); 92 | success = false; 93 | } 94 | } 95 | 96 | success 97 | } 98 | -------------------------------------------------------------------------------- /src-tauri/src/network/windows.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use logger::{error, info}; 3 | use std::os::windows::process::CommandExt; 4 | use std::process::Command; 5 | use std::ptr::null_mut; 6 | use winapi::um::wininet::{InternetSetOptionW, INTERNET_OPTION_REFRESH, INTERNET_OPTION_SETTINGS_CHANGED}; 7 | 8 | const SETTINGS: &str = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"; 9 | 10 | fn notify_proxy_change() -> bool { 11 | unsafe { 12 | let success = InternetSetOptionW(null_mut(), INTERNET_OPTION_SETTINGS_CHANGED, null_mut(), 0) != 0 13 | && InternetSetOptionW(null_mut(), INTERNET_OPTION_REFRESH, null_mut(), 0) != 0; 14 | 15 | if success { 16 | info!("Proxy change notified"); 17 | } else { 18 | error!("Failed to notify proxy change"); 19 | } 20 | 21 | success 22 | } 23 | } 24 | 25 | /* 26 | // 备选方案,使用 PowerShell 脚本通知代理设置更改 27 | fn notify_proxy_change() -> bool { 28 | // 使用 PowerShell 脚本通知代理设置更改 29 | let script = r#" 30 | Add-Type -TypeDefinition @" 31 | using System; 32 | using System.Runtime.InteropServices; 33 | public class WinInet { 34 | [DllImport("wininet.dll", SetLastError = true)] 35 | public static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength); 36 | public const int INTERNET_OPTION_SETTINGS_CHANGED = 39; 37 | public const int INTERNET_OPTION_REFRESH = 37; 38 | } 39 | "@ 40 | [WinInet]::InternetSetOption([IntPtr]::Zero, [WinInet]::INTERNET_OPTION_SETTINGS_CHANGED, [IntPtr]::Zero, 0) 41 | [WinInet]::InternetSetOption([IntPtr]::Zero, [WinInet]::INTERNET_OPTION_REFRESH, [IntPtr]::Zero, 0) 42 | "#; 43 | 44 | // 执行 PowerShell 脚本 45 | command("powershell", &["-Command", script]).is_ok() 46 | } 47 | */ 48 | 49 | pub fn command(command: &str, args: &[&str]) -> bool { 50 | match Command::new(command) 51 | .creation_flags(0x08000000) // Windows-specific flag to hide window 52 | .args(args) 53 | .status() 54 | { 55 | Ok(status) if status.success() => { 56 | info!("Command [{} {}] executed successfully", command, args.join(" ")); 57 | true 58 | } 59 | Ok(status) => { 60 | error!("Command failed: [{} {}], exit status: {}", command, args.join(" "), status); 61 | false 62 | } 63 | Err(e) => { 64 | error!("Failed to start command: [{} {}], error: {}", command, args.join(" "), e); 65 | false 66 | } 67 | } 68 | } 69 | 70 | pub fn enable_auto_proxy() -> bool { 71 | let config = config::get_config(); 72 | let url = format!("http://{}:{}/proxy.pac", config.web_server_host, config.web_server_port); 73 | command("reg", &["add", SETTINGS, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", &url, "/f"]) 74 | && command("reg", &["add", SETTINGS, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) 75 | && notify_proxy_change() 76 | } 77 | 78 | fn enable_proxy(host: &str, port: &u32) -> bool { 79 | let proxy_server = format!("{}:{}", host, port); 80 | command("reg", &["add", SETTINGS, "/v", "ProxyServer", "/t", "REG_SZ", "/d", &proxy_server, "/f"]) 81 | && command("reg", &["add", SETTINGS, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]) 82 | && notify_proxy_change() 83 | } 84 | 85 | pub fn enable_socks_proxy() -> bool { 86 | let config = config::get_config(); 87 | enable_proxy(&config.ray_host, &config.ray_socks_port) 88 | } 89 | 90 | pub fn enable_web_proxy() -> bool { 91 | let config = config::get_config(); 92 | enable_proxy(&config.ray_host, &config.ray_http_port) 93 | } 94 | 95 | pub fn enable_secure_web_proxy() -> bool { 96 | let config = config::get_config(); 97 | enable_proxy(&config.ray_host, &config.ray_http_port) 98 | } 99 | 100 | pub fn disable_auto_proxy() -> bool { 101 | command("reg", &["delete", SETTINGS, "/v", "AutoConfigURL", "/f"]) 102 | && command("reg", &["add", SETTINGS, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) 103 | && notify_proxy_change() 104 | } 105 | 106 | pub fn disable_socks_proxy() -> bool { 107 | disable_proxies() 108 | } 109 | 110 | pub fn disable_web_proxy() -> bool { 111 | disable_proxies() 112 | } 113 | 114 | pub fn disable_secure_web_proxy() -> bool { 115 | disable_proxies() 116 | } 117 | 118 | pub fn disable_proxies() -> bool { 119 | command("reg", &["delete", SETTINGS, "/v", "ProxyServer", "/f"]) 120 | && command("reg", &["add", SETTINGS, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) 121 | && notify_proxy_change() 122 | } 123 | 124 | /* 125 | // netsh 命令会修改所有用户的网络配置,不建议使用。更好的方法是修改当前用户的注册表,仅影响当前用户。 126 | pub fn enable_auto_proxy() -> bool { 127 | let config = config::get_config(); 128 | let url = format!("http://{}:{}/doay/proxy.js", config.web_server_host, config.web_server_port); 129 | command(&format!("netsh winhttp set proxy proxy-server=\"{}\"", url)) 130 | } 131 | 132 | pub fn enable_socks_proxy() -> bool { 133 | let config = config::get_config(); 134 | command(&format!("netsh winhttp set proxy proxy-server=\"socks={}:{}\"", config.ray_host, config.ray_socks_port)) 135 | } 136 | 137 | pub fn enable_web_proxy() -> bool { 138 | let config = config::get_config(); 139 | command(&format!("netsh winhttp set proxy proxy-server=\"http={}:{}\"", config.ray_host, config.ray_http_port)) 140 | } 141 | 142 | pub fn enable_secure_web_proxy() -> bool { 143 | let config = config::get_config(); 144 | command(&format!("netsh winhttp set proxy proxy-server=\"https={}:{}\"", config.ray_host, config.ray_http_port)) 145 | } 146 | 147 | pub fn disable_auto_proxy() -> bool { 148 | command("netsh winhttp reset proxy") 149 | } 150 | 151 | pub fn disable_socks_proxy() -> bool { 152 | command("netsh winhttp reset proxy") 153 | } 154 | 155 | pub fn disable_web_proxy() -> bool { 156 | command("netsh winhttp reset proxy") 157 | } 158 | 159 | pub fn disable_secure_web_proxy() -> bool { 160 | command("netsh winhttp reset proxy") 161 | } 162 | 163 | pub fn disable_proxies() -> bool { 164 | command("netsh winhttp reset proxy") 165 | } 166 | */ 167 | -------------------------------------------------------------------------------- /src-tauri/src/scan_ports.rs: -------------------------------------------------------------------------------- 1 | use crate::dirs; 2 | use logger::error; 3 | use serde_json::{json, Value}; 4 | use std::{ 5 | io::{Error, ErrorKind, SeekFrom}, 6 | path::{Path, PathBuf}, 7 | sync::Arc, 8 | time::{Duration, Instant}, 9 | }; 10 | use tokio::{ 11 | fs::{self, File, OpenOptions}, 12 | io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader, BufWriter}, 13 | net::{lookup_host, TcpStream}, 14 | sync::{Mutex, Semaphore}, 15 | task, 16 | }; 17 | 18 | #[derive(Clone)] 19 | struct AsyncThreadLimiter { 20 | semaphore: Arc, 21 | } 22 | 23 | impl AsyncThreadLimiter { 24 | pub fn new(limit: usize) -> Self { 25 | Self { 26 | semaphore: Arc::new(Semaphore::new(limit)), 27 | } 28 | } 29 | 30 | pub async fn acquire(&self) { 31 | self.semaphore.acquire().await.unwrap().forget(); 32 | } 33 | 34 | pub fn release(&self) { 35 | self.semaphore.add_permits(1); 36 | } 37 | } 38 | 39 | async fn init_log_writer(path: &Path) -> Result>>, Error> { 40 | fs::File::create(path).await?; 41 | let file = OpenOptions::new().append(true).open(path).await?; 42 | Ok(Arc::new(Mutex::new(BufWriter::new(file)))) 43 | } 44 | 45 | async fn log_port(log: &Arc>>, port: u16) { 46 | let mut writer = log.lock().await; 47 | if writer.write_all(format!("{}\n", port).as_bytes()).await.is_err() { 48 | error!("Failed to write log"); 49 | } 50 | if writer.flush().await.is_err() { 51 | error!("Failed to flush log"); 52 | } 53 | } 54 | 55 | async fn run_scan_ports(host: &str, start_port: u16, end_port: u16, max_threads: usize, timeout_ms: u64) -> Result { 56 | let test_addr = format!("{}:0", host); 57 | if lookup_host(&test_addr).await.ok().and_then(|mut addrs| addrs.next()).is_none() { 58 | return Err(Error::new(ErrorKind::InvalidInput, format!("Failed to resolve hostname: {}", host))); 59 | } 60 | 61 | let logs_dir = dirs::get_doay_logs_dir().ok_or_else(|| Error::new(ErrorKind::NotFound, "log dir not found"))?; 62 | let open_log = init_log_writer(&logs_dir.join("scan_ports_open.log")).await?; 63 | let timeout_log = init_log_writer(&logs_dir.join("scan_ports_timeout.log")).await?; 64 | let refused_log = init_log_writer(&logs_dir.join("scan_ports_refused.log")).await?; 65 | 66 | let timeout = Duration::from_millis(timeout_ms); 67 | let start_time = Instant::now(); 68 | let counter = Arc::new(Mutex::new((0, 0, 0))); 69 | let limiter = AsyncThreadLimiter::new(max_threads); 70 | 71 | let mut handles = vec![]; 72 | 73 | for port in start_port..=end_port { 74 | let host = host.to_string(); 75 | let limiter = limiter.clone(); 76 | let open_log = open_log.clone(); 77 | let timeout_log = timeout_log.clone(); 78 | let refused_log = refused_log.clone(); 79 | let counter = counter.clone(); 80 | let timeout = timeout.clone(); 81 | 82 | let handle = task::spawn(async move { 83 | limiter.acquire().await; 84 | let addr = format!("{}:{}", host, port); 85 | let sock_addr = lookup_host(addr).await.ok().and_then(|mut a| a.next()); 86 | 87 | if let Some(sock_addr) = sock_addr { 88 | match tokio::time::timeout(timeout, TcpStream::connect(sock_addr)).await { 89 | Ok(Ok(_)) => { 90 | log_port(&open_log, port).await; 91 | counter.lock().await.0 += 1; 92 | } 93 | Ok(Err(_)) => { 94 | log_port(&refused_log, port).await; 95 | counter.lock().await.1 += 1; 96 | } 97 | Err(_) => { 98 | log_port(&timeout_log, port).await; 99 | counter.lock().await.2 += 1; 100 | } 101 | } 102 | } 103 | limiter.release(); 104 | }); 105 | 106 | handles.push(handle); 107 | } 108 | 109 | for handle in handles { 110 | let _ = handle.await; 111 | } 112 | 113 | let elapsed = start_time.elapsed(); 114 | let (open_count, refused_count, timeout_count) = *counter.lock().await; 115 | 116 | Ok(json!({ 117 | "ok": true, 118 | "elapsed_secs": elapsed.as_secs_f64(), 119 | "open_count": open_count, 120 | "refused_count": refused_count, 121 | "timeout_count": timeout_count, 122 | })) 123 | } 124 | 125 | pub async fn start_scan_ports(host: &str, start_port: u16, end_port: u16, max_threads: usize, timeout_ms: u64) -> Value { 126 | match run_scan_ports(host, start_port, end_port, max_threads, timeout_ms).await { 127 | Ok(result) => result, 128 | Err(e) => { 129 | error!("Scan failed: {}", e); 130 | json!({ "ok": false, "error_message": e.to_string() }) 131 | } 132 | } 133 | } 134 | 135 | pub async fn read_open_log() -> String { 136 | match get_log_path("scan_ports_open.log") { 137 | Some(path) => read_full_file(path.to_str().unwrap_or_default()).await, 138 | None => { 139 | error!("Open log path not found"); 140 | String::new() 141 | } 142 | } 143 | } 144 | 145 | pub async fn read_timeout_log() -> String { 146 | match get_log_path("scan_ports_timeout.log") { 147 | Some(path) => read_tail_file(path.to_str().unwrap_or_default(), 100 * 1024).await, 148 | None => { 149 | error!("Timeout log path not found"); 150 | String::new() 151 | } 152 | } 153 | } 154 | 155 | pub async fn read_refused_log() -> String { 156 | match get_log_path("scan_ports_refused.log") { 157 | Some(path) => read_tail_file(path.to_str().unwrap_or_default(), 100 * 1024).await, 158 | None => { 159 | error!("Refused log path not found"); 160 | String::new() 161 | } 162 | } 163 | } 164 | 165 | fn get_log_path(filename: &str) -> Option { 166 | dirs::get_doay_logs_dir().map(|dir| dir.join(filename)) 167 | } 168 | 169 | async fn read_full_file(filepath: &str) -> String { 170 | match File::open(filepath).await { 171 | Ok(file) => { 172 | let mut reader = BufReader::new(file); 173 | let mut content = String::new(); 174 | if reader.read_to_string(&mut content).await.is_ok() { 175 | content 176 | } else { 177 | error!("Failed to read full file '{}'", filepath); 178 | String::new() 179 | } 180 | } 181 | Err(e) => { 182 | error!("Failed to open file '{}': {}", filepath, e); 183 | String::new() 184 | } 185 | } 186 | } 187 | 188 | async fn read_tail_file(filepath: &str, max_bytes: u64) -> String { 189 | match File::open(filepath).await { 190 | Ok(mut file) => { 191 | match file.metadata().await { 192 | Ok(meta) => { 193 | let file_size = meta.len(); 194 | let read_start = if file_size > max_bytes { file_size.saturating_sub(max_bytes) } else { 0 }; 195 | 196 | // 将文件指针移动到读取开始的位置 197 | if let Err(e) = file.seek(SeekFrom::Start(read_start)).await { 198 | error!("Failed to seek file '{}': {}", filepath, e); 199 | return String::new(); 200 | } 201 | 202 | // 读取文件内容 203 | let mut raw_content = String::new(); 204 | if let Err(e) = file.read_to_string(&mut raw_content).await { 205 | error!("Failed to read file '{}': {}", filepath, e); 206 | return String::new(); 207 | } 208 | 209 | // 如果文件内容小于 max_bytes,则直接返回 210 | if file_size <= max_bytes { 211 | return raw_content; 212 | } 213 | 214 | // 跳过可能不完整的第一行 215 | if let Some(first_newline) = raw_content.find('\n') { 216 | raw_content[first_newline + 1..].to_string() 217 | } else { 218 | raw_content 219 | } 220 | } 221 | Err(e) => { 222 | error!("Failed to get metadata for '{}': {}", filepath, e); 223 | String::new() 224 | } 225 | } 226 | } 227 | Err(e) => { 228 | error!("Failed to open file '{}': {}", filepath, e); 229 | String::new() 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src-tauri/src/setting.rs: -------------------------------------------------------------------------------- 1 | use crate::{config, log, network, ray, web}; 2 | use logger::{info, warn}; 3 | use std::net::TcpListener; 4 | 5 | pub fn set_app_log_level(value: &str) -> bool { 6 | config::set_app_log_level(value) && { 7 | log::init(); 8 | true 9 | } 10 | } 11 | 12 | pub fn set_web_server_enable(value: bool) -> bool { 13 | let success = config::set_web_server_enable(value); 14 | if success { 15 | if value { 16 | web::start(); 17 | } else { 18 | web::stop(); 19 | } 20 | } 21 | success 22 | } 23 | 24 | pub fn set_web_server_host(value: &str) -> bool { 25 | let success = config::set_web_server_host(value); 26 | if success { 27 | let config = config::get_config(); 28 | if config.web_server_enable { 29 | web::restart(); 30 | network::setup_pac_proxy(); 31 | } 32 | } 33 | success 34 | } 35 | 36 | pub fn set_web_server_port(value: u32) -> bool { 37 | let success = config::set_web_server_port(value); 38 | if success { 39 | let config = config::get_config(); 40 | if config.web_server_enable { 41 | web::restart(); 42 | network::setup_pac_proxy(); 43 | } 44 | } 45 | success 46 | } 47 | 48 | pub fn set_ray_enable(value: bool) -> bool { 49 | if config::set_ray_enable(value) { 50 | if value { 51 | ray::start() && network::setup_proxies() 52 | } else { 53 | ray::stop() && ray::force_kill() && network::disable_proxies() 54 | } 55 | } else { 56 | false 57 | } 58 | } 59 | 60 | pub fn set_ray_host(value: &str) -> bool { 61 | config::set_ray_host(value) && network::setup_proxies() 62 | } 63 | 64 | pub fn set_ray_socks_port(value: u32) -> bool { 65 | config::set_ray_socks_port(value) && network::setup_socks_proxy() 66 | } 67 | 68 | pub fn set_ray_http_port(value: u32) -> bool { 69 | config::set_ray_http_port(value) && network::setup_http_proxy() 70 | } 71 | 72 | pub fn set_auto_setup_pac(value: bool) -> bool { 73 | if config::set_auto_setup_pac(value) { 74 | if value { 75 | network::enable_auto_proxy() 76 | } else { 77 | network::disable_auto_proxy() 78 | } 79 | } else { 80 | false 81 | } 82 | } 83 | 84 | pub fn set_auto_setup_socks(value: bool) -> bool { 85 | if config::set_auto_setup_socks(value) { 86 | if value { 87 | network::enable_socks_proxy() 88 | } else { 89 | network::disable_socks_proxy() 90 | } 91 | } else { 92 | false 93 | } 94 | } 95 | 96 | pub fn set_auto_setup_http(value: bool) -> bool { 97 | if config::set_auto_setup_http(value) { 98 | if value { 99 | network::enable_web_proxy() 100 | } else { 101 | network::disable_web_proxy() 102 | } 103 | } else { 104 | false 105 | } 106 | } 107 | 108 | pub fn set_auto_setup_https(value: bool) -> bool { 109 | if config::set_auto_setup_https(value) { 110 | if value { 111 | network::enable_secure_web_proxy() 112 | } else { 113 | network::disable_secure_web_proxy() 114 | } 115 | } else { 116 | false 117 | } 118 | } 119 | 120 | pub fn check_port_available(port: u32) -> bool { 121 | let address = format!("127.0.0.1:{}", port); 122 | match TcpListener::bind(&address) { 123 | Ok(_) => { 124 | info!("Port {} is available", port); 125 | true 126 | } 127 | Err(e) => { 128 | warn!("Port {} is unavailable: {}", port, e); 129 | false 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src-tauri/src/sys_info.rs: -------------------------------------------------------------------------------- 1 | use logger::{error, trace}; 2 | use once_cell::sync::Lazy; 3 | use serde_json::{json, Value}; 4 | use std::sync::Mutex; 5 | use std::time::Instant; 6 | use sysinfo::{Components, Disks, Networks, Pid, System, Users}; 7 | 8 | static SYS: Lazy>> = Lazy::new(|| Mutex::new(None)); 9 | 10 | fn get_or_init_system() -> std::sync::MutexGuard<'static, Option> { 11 | let mut sys = SYS.lock().unwrap(); 12 | sys.get_or_insert_with(|| System::new_all()); 13 | sys 14 | } 15 | 16 | pub fn get_sys_info_json() -> Value { 17 | let start = Instant::now(); 18 | let mut sys = get_or_init_system(); 19 | sys.as_mut().map(|sys| sys.refresh_all()); 20 | let sys = sys.as_ref().unwrap(); 21 | trace!("System info refresh all, time elapsed: {:?}", start.elapsed()); 22 | 23 | json!({ 24 | "long_os_version": System::long_os_version(), // 操作系统长版本信息 25 | "kernel_long_version": System::kernel_long_version(), // 操作系统内核版本 26 | "host_name": System::host_name(), // 系统主机名 27 | "uptime": System::uptime(), // 系统运行时间(以秒为单位) 28 | "physical_core_count": System::physical_core_count(), // CPU 物理核心数量 29 | "cpu_arch": System::cpu_arch(), // CPU 架构信息 30 | "cpu_len": sys.cpus().len(), // CPU 核数 31 | "process_len": sys.processes().len(), // 进程数 32 | "global_cpu_usage": sys.global_cpu_usage(), // 系统总 CPU 使用率 33 | "total_memory": sys.total_memory(), 34 | "used_memory": sys.used_memory(), 35 | "total_swap": sys.total_swap(), 36 | "used_swap": sys.used_swap(), 37 | }) 38 | } 39 | 40 | pub fn get_load_average_json() -> Value { 41 | let load_avg = System::load_average(); 42 | json!({ 43 | "one": load_avg.one, 44 | "five": load_avg.five, 45 | "fifteen": load_avg.fifteen, 46 | }) 47 | } 48 | 49 | pub fn get_processes_json(keyword: &str) -> Value { 50 | let mut sys = get_or_init_system(); 51 | sys.as_mut().map(|sys| sys.refresh_all()); 52 | let sys = sys.as_ref().unwrap(); 53 | let users = Users::new_with_refreshed_list(); 54 | 55 | let process_vec = sys 56 | .processes() 57 | .iter() 58 | .filter_map(|(pid, process)| { 59 | let exe = process.exe().map_or("".to_string(), |v| v.to_string_lossy().into_owned()); 60 | 61 | if !keyword.is_empty() && !exe.to_lowercase().contains(&keyword.to_lowercase()) { 62 | return None; 63 | } 64 | 65 | /*let username = process 66 | .user_id() 67 | .and_then(|user_id| { 68 | users 69 | .get_user_by_id(user_id) 70 | .map(|user| user.name().to_string()) 71 | .or_else(|| Some(user_id.to_string())) 72 | }) 73 | .unwrap_or_default();*/ 74 | 75 | let username = process 76 | .user_id() 77 | .and_then(|user_id| users.get_user_by_id(user_id).map(|user| user.name().to_string())) 78 | .unwrap_or_default(); 79 | 80 | Some(json!({ 81 | "pid": pid.as_u32(), 82 | // "parent": process.parent(), 83 | "status": process.status().to_string(), 84 | "memory": process.memory(), 85 | // "virtual_memory": process.virtual_memory(), 86 | "user": username, 87 | "cpu_usage": process.cpu_usage(), 88 | // "accumulated_cpu_time": process.accumulated_cpu_time(), 89 | // "disk_usage": process.disk_usage(), 90 | // "exists": process.exists(), 91 | "start_time": process.start_time(), 92 | "name": process.name().to_string_lossy().to_string(), 93 | "exe": exe, 94 | })) 95 | }) 96 | .collect::>(); 97 | json!(process_vec) 98 | } 99 | 100 | pub fn get_disks_json() -> Value { 101 | let disks = Disks::new_with_refreshed_list() 102 | .iter() 103 | .map(|disk| { 104 | json!({ 105 | "name": disk.name().to_string_lossy().to_string(), 106 | "total_space": disk.total_space(), 107 | "available_space": disk.available_space(), 108 | }) 109 | }) 110 | .collect::>(); 111 | json!(disks) 112 | } 113 | 114 | pub fn get_networks_json() -> Value { 115 | let mut network_vec = Vec::new(); 116 | let networks = Networks::new_with_refreshed_list(); 117 | for (interface_name, data) in &networks { 118 | let interface_type = identify_interface_type(interface_name); 119 | network_vec.push(json!({ 120 | "name": interface_name, 121 | "type": interface_type, 122 | "up": data.total_transmitted(), 123 | "down": data.total_received(), 124 | })); 125 | } 126 | json!(network_vec) 127 | } 128 | 129 | fn identify_interface_type(name: &str) -> &'static str { 130 | match name { 131 | // macOS 132 | "lo0" => "Loopback", 133 | "en0" | "en1" => "Ethernet", 134 | "awdl0" => "Apple Wireless Direct Link", 135 | "utun0" | "utun1" | "utun2" => "Virtual Tunnel", 136 | 137 | // Linux 138 | "lo" => "Loopback", 139 | "eth0" | "eth1" => "Ethernet", 140 | "wlan0" | "wlan1" => "Wireless", 141 | "tun0" | "tap0" => "Virtual Tunnel", 142 | 143 | // Windows 144 | "Loopback Pseudo-Interface" => "Loopback", 145 | "Ethernet" => "Ethernet", 146 | "Wi-Fi" => "Wireless", 147 | "Local Area Connection" => "Ethernet", 148 | 149 | _ => "Unknown", 150 | } 151 | } 152 | 153 | pub fn get_components_json() -> Value { 154 | let components = Components::new_with_refreshed_list() 155 | .iter() 156 | .map(|component| { 157 | json!({ 158 | "label": component.label().to_string(), 159 | "temperature": component.temperature().map_or("".to_string(), |v| v.to_string()), 160 | }) 161 | }) 162 | .collect::>(); 163 | json!(components) 164 | } 165 | 166 | pub fn kill_process_by_pid(pid: u32) -> bool { 167 | let mut sys = get_or_init_system(); 168 | sys.as_mut().map(|sys| sys.refresh_all()); 169 | let sys = sys.as_ref().unwrap(); 170 | 171 | let pid = Pid::from_u32(pid); 172 | if let Some(process) = sys.process(pid) { 173 | if process.kill() { 174 | trace!("Successfully killed process with PID {}", pid); 175 | true 176 | } else { 177 | error!("Failed to kill process with PID {}", pid); 178 | false 179 | } 180 | } else { 181 | error!("Process with PID {} not found", pid); 182 | false 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src-tauri/src/web.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::dirs; 3 | use crate::sys_info; 4 | use actix_files::Files; 5 | use actix_web::middleware::Logger; 6 | use actix_web::{dev, rt, web, App, HttpResponse, HttpServer, Responder}; 7 | use chrono; 8 | use env_logger::Builder; 9 | use log::LevelFilter; 10 | use logger::{debug, error, info}; 11 | use once_cell::sync::Lazy; 12 | use std::fs; 13 | use std::fs::OpenOptions; 14 | use std::io::Write; 15 | use std::sync::Mutex; 16 | 17 | static SERVER_HANDLE: Lazy>> = Lazy::new(|| Mutex::new(None)); 18 | static LOGGER_ONCE: Lazy> = Lazy::new(|| Mutex::new(false)); 19 | 20 | // 日志初始化 21 | fn init_logger() { 22 | let mut init_once = LOGGER_ONCE.lock().unwrap(); 23 | if *init_once { 24 | return; 25 | } 26 | 27 | let log_file = OpenOptions::new() 28 | .write(true) 29 | .create(true) 30 | .append(true) 31 | .open(dirs::get_doay_logs_dir().unwrap().join("web_server.log").to_str().unwrap()) 32 | .unwrap(); 33 | 34 | Builder::from_default_env() 35 | .target(env_logger::Target::Pipe(Box::new(log_file))) 36 | .filter_level(LevelFilter::Off) // 设置日志级别参数: Off Error Warn Info Debug Trace 37 | .filter_module("actix_server::server", LevelFilter::Info) 38 | .filter_module("actix_web::middleware::logger", LevelFilter::Info) 39 | .format(|buf, record| { 40 | buf.write_fmt(format_args!( 41 | "{} [{}] {}: {}\n", 42 | chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), 43 | record.level(), 44 | record.target(), 45 | record.args() 46 | )) 47 | }) 48 | .format_timestamp(None) 49 | .init(); 50 | 51 | *init_once = true; 52 | } 53 | 54 | fn ensure_web_server_dir() -> bool { 55 | let web_server_dir = dirs::get_doay_web_server_dir().unwrap(); 56 | if !web_server_dir.exists() { 57 | if let Err(e) = fs::create_dir_all(&web_server_dir) { 58 | error!("Failed to create web server directory: {}", e); 59 | return false; 60 | } 61 | } 62 | true 63 | } 64 | 65 | pub fn open_web_server_dir() -> bool { 66 | if !ensure_web_server_dir() { 67 | return false; 68 | } 69 | 70 | // 确保 proxy.js 文件存在 71 | let proxy_file = dirs::get_doay_web_server_dir().unwrap().join("proxy.js"); 72 | if !proxy_file.exists() { 73 | if let Err(e) = fs::File::create(&proxy_file) { 74 | error!("Failed to create proxy.js: {}", e); 75 | return false; 76 | } 77 | } 78 | 79 | // 打开 proxy.js 文件 80 | if let Err(e) = tauri_plugin_opener::reveal_item_in_dir(&proxy_file) { 81 | error!("Failed to open proxy.js: {}", e); 82 | return false; 83 | } 84 | 85 | true 86 | } 87 | 88 | pub fn start() -> bool { 89 | if !ensure_web_server_dir() { 90 | return false; 91 | } 92 | 93 | if SERVER_HANDLE.lock().unwrap().is_some() { 94 | info!("Web Server is already running."); 95 | return false; 96 | } 97 | 98 | init_logger(); 99 | std::thread::spawn(|| run_server()); 100 | true 101 | } 102 | 103 | async fn proxy_pac_handle() -> HttpResponse { 104 | let pac_path = dirs::get_doay_web_server_dir().unwrap().join("proxy.js"); 105 | match fs::read_to_string(&pac_path) { 106 | Ok(content) => HttpResponse::Ok().content_type("application/x-ns-proxy-autoconfig").body(content), 107 | Err(_) => HttpResponse::NotFound().body("proxy.js not found"), 108 | } 109 | } 110 | 111 | async fn get_disks_handler() -> impl Responder { 112 | HttpResponse::Ok().json(sys_info::get_disks_json()) 113 | } 114 | 115 | async fn get_networks_handler() -> impl Responder { 116 | HttpResponse::Ok().json(sys_info::get_networks_json()) 117 | } 118 | 119 | async fn get_components_handler() -> impl Responder { 120 | HttpResponse::Ok().json(sys_info::get_components_json()) 121 | } 122 | 123 | fn run_server() { 124 | let config = config::get_config(); 125 | let server_address = format!("{}:{}", config.web_server_host, config.web_server_port); 126 | rt::System::new().block_on(async { 127 | match HttpServer::new(move || { 128 | App::new() 129 | .wrap(Logger::new("%D %a %s \"%r\" %b \"%{Referer}i\" \"%{User-Agent}i\"")) 130 | .service(Files::new("/doay", dirs::get_doay_web_server_dir().unwrap().to_str().unwrap()).show_files_listing()) 131 | .route("/", web::get().to(|| async { "This is Doay Web Server!" })) 132 | .route("/proxy.pac", web::get().to(proxy_pac_handle)) 133 | .route("/disks", web::get().to(get_disks_handler)) 134 | .route("/networks", web::get().to(get_networks_handler)) 135 | .route("/components", web::get().to(get_components_handler)) 136 | }) 137 | .bind(&server_address) 138 | { 139 | Err(e) => { 140 | error!("Web Server failed to bind to {}: {}", server_address, e); 141 | } 142 | Ok(http_server) => { 143 | let server = http_server.run(); 144 | info!("Web Server running on http://{}", server_address); 145 | *SERVER_HANDLE.lock().unwrap() = Some(server.handle()); 146 | if let Err(e) = server.await { 147 | error!("Web Server encountered an error: {}", e); 148 | } 149 | debug!("Web Server has been shut down."); 150 | } 151 | } 152 | }) 153 | } 154 | 155 | pub fn stop() -> bool { 156 | let server_handle = SERVER_HANDLE.lock().unwrap().take(); 157 | if let Some(handle) = server_handle { 158 | rt::System::new().block_on(async { 159 | handle.stop(false).await; 160 | info!("Web Server stopped"); 161 | }) 162 | } 163 | true 164 | } 165 | 166 | pub fn restart() { 167 | stop(); 168 | start(); 169 | } 170 | 171 | pub fn save_proxy_pac(content: &str) -> bool { 172 | let config_path = dirs::get_doay_web_server_dir().unwrap().join("proxy.js").to_str().unwrap().to_string(); 173 | match fs::File::create(config_path) { 174 | Ok(mut file) => { 175 | if let Err(e) = file.write_all(content.as_bytes()) { 176 | error!("Failed to write proxy.js file: {}", e); 177 | return false; 178 | } 179 | info!("proxy.js saved successfully"); 180 | true 181 | } 182 | Err(e) => { 183 | error!("Failed to create proxy.js file: {}", e); 184 | false 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "Doay", 4 | "version": "1.0.9", 5 | "identifier": "doay", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "security": { 14 | "csp": null 15 | } 16 | }, 17 | "bundle": { 18 | "resources": [ 19 | "ray/*" 20 | ], 21 | "active": true, 22 | "targets": "all", 23 | "icon": [ 24 | "icons/64x64.png", 25 | "icons/icon.png", 26 | "icons/icon.icns", 27 | "icons/icon.ico" 28 | ], 29 | "windows": { 30 | "nsis": { 31 | "installMode": "perMachine", 32 | "installerIcon": "icons/icon.ico", 33 | "languages": [ 34 | "simpchinese" 35 | ] 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /*font-synthesis: none;*/ 3 | /*text-rendering: optimizeLegibility;*/ 4 | /*-webkit-font-smoothing: antialiased;*/ 5 | /*-moz-osx-font-smoothing: grayscale;*/ 6 | 7 | --scrollbar-thumb: #c1c1c1; 8 | --scrollbar-thumb-hover: #a8a8a8; 9 | --row-odd-bg: #ffffff; 10 | } 11 | 12 | body, .inset-shadow { 13 | box-shadow: inset 0 10px 10px -10px rgba(0, 0, 0, 0.3); 14 | } 15 | 16 | html.dark { 17 | --scrollbar-thumb: #555; 18 | --scrollbar-thumb-hover: #888; 19 | --row-odd-bg: #1e1e1e; 20 | } 21 | 22 | ::-webkit-scrollbar { 23 | width: 1px; 24 | height: 1px; 25 | } 26 | 27 | /*::-webkit-scrollbar:horizontal { 28 | width: 1px; 29 | height: 1px; 30 | }*/ 31 | 32 | ::-webkit-scrollbar-track { 33 | opacity: 0; 34 | } 35 | 36 | ::-webkit-scrollbar-corner { 37 | opacity: 0; 38 | /*background-color: transparent;*/ 39 | } 40 | 41 | ::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb:horizontal { 42 | background: var(--scrollbar-thumb); 43 | } 44 | 45 | ::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-thumb:horizontal:hover { 46 | background: var(--scrollbar-thumb-hover); 47 | } 48 | 49 | .scr-none::-webkit-scrollbar { 50 | display: none; 51 | } 52 | 53 | .scr-w1::-webkit-scrollbar { 54 | width: 4px; 55 | height: 4px; 56 | } 57 | 58 | .scr-w2::-webkit-scrollbar, .scr-w2 *::-webkit-scrollbar { 59 | width: 8px; 60 | height: 8px; 61 | } 62 | 63 | .code-mirror, .log-view { 64 | user-select: text !important; 65 | -webkit-user-select: text !important; 66 | } 67 | 68 | body, p, ul, li, h1, h2, h3, h4, h5, h6 { 69 | margin: 0; 70 | padding: 0; 71 | } 72 | 73 | body, html { 74 | margin: 0; 75 | padding: 0; 76 | overflow: hidden; 77 | height: 100vh; 78 | will-change: transform; 79 | pointer-events: auto; 80 | } 81 | 82 | .hover-effect { 83 | cursor: pointer; 84 | text-decoration: none; 85 | transition: color 0.3s ease, text-decoration 0.3s ease; 86 | } 87 | 88 | .hover-effect:hover { 89 | color: #007bff; 90 | text-decoration: underline; 91 | } 92 | 93 | .panel-left { 94 | position: fixed; 95 | top: 0; 96 | left: 0; 97 | width: 130px; 98 | height: 100vh; 99 | } 100 | 101 | .panel-right { 102 | position: fixed; 103 | top: 0; 104 | right: 0; 105 | width: calc(100% - 130px); 106 | height: calc(100vh); 107 | padding: 10px; 108 | overflow-y: auto; 109 | } 110 | 111 | /*.drag-grab { 112 | cursor: grab; 113 | transition: all 0.2s ease-in-out; 114 | } 115 | 116 | .drag-grabbing { 117 | cursor: grabbing; 118 | opacity: 0.6; 119 | transform: scale(0.999); 120 | }*/ 121 | 122 | .server-type button { 123 | text-transform: capitalize !important; 124 | } 125 | 126 | .qr-box { 127 | width: 256px; 128 | height: 256px; 129 | margin: 0 auto; 130 | } 131 | 132 | .qr-upload-but { 133 | position: relative; 134 | } 135 | 136 | .qr-upload-but > input[type="file"] { 137 | position: absolute; 138 | top: 0; 139 | left: 0; 140 | width: 100%; 141 | height: 100%; 142 | opacity: 0; 143 | cursor: pointer; 144 | } 145 | 146 | #camera-reader, .camera-box { 147 | display: flex; 148 | justify-content: center; 149 | align-items: center; 150 | width: 480px; 151 | height: 360px; 152 | background: #121212; 153 | color: #fff; 154 | } 155 | 156 | .flex-between { 157 | display: flex; 158 | justify-content: space-between; 159 | align-items: center; 160 | } 161 | 162 | .flex-between.mt1 { 163 | margin-top: 8px; 164 | } 165 | 166 | .flex-between.mt2 { 167 | margin-top: 16px; 168 | } 169 | 170 | .flex-between.p1 { 171 | padding: 8px; 172 | } 173 | 174 | .flex-between.p2 { 175 | padding: 16px; 176 | } 177 | 178 | .flex-between.w100 { 179 | width: 100%; 180 | } 181 | 182 | .flex-center { 183 | display: flex; 184 | justify-content: center; 185 | margin-bottom: 8px; 186 | } 187 | 188 | .flex-column { 189 | display: flex; 190 | flex-direction: column; 191 | } 192 | 193 | .flex-column > :not(style):not(style) { 194 | margin: 0; 195 | } 196 | 197 | .flex-column.gap1 > :not(style) ~ :not(style) { 198 | margin-top: 8px; 199 | } 200 | 201 | .flex-column.gap2 > :not(style) ~ :not(style) { 202 | margin-top: 16px; 203 | } 204 | 205 | .flex-center-gap1 { 206 | display: flex; 207 | align-items: center; 208 | gap: 4px; 209 | } 210 | 211 | .sort-current { 212 | filter: contrast(150%); 213 | } 214 | 215 | .sort-target { 216 | filter: grayscale(100%); 217 | opacity: 0.5; 218 | cursor: move; 219 | } 220 | 221 | .text-ellipsis { 222 | max-width: 320px; 223 | white-space: nowrap; 224 | overflow: hidden; 225 | text-overflow: ellipsis; 226 | } 227 | 228 | .table tbody tr:last-child td, .table tbody tr:last-child th { 229 | border: 0; 230 | } 231 | 232 | /* process */ 233 | .process-but { 234 | display: inline; 235 | cursor: pointer; 236 | text-decoration: none; 237 | transition: filter 0.3s ease; 238 | } 239 | 240 | .process-but:hover { 241 | text-decoration: underline; 242 | filter: brightness(1.2) contrast(1.1); 243 | } 244 | 245 | .process-head { 246 | position: relative; 247 | left: 0; 248 | } 249 | 250 | .process-row { 251 | display: flex; 252 | } 253 | 254 | .process-row > div { 255 | font-size: 14px; 256 | padding: 4px 8px; 257 | white-space: nowrap; 258 | overflow: hidden; 259 | text-overflow: ellipsis; 260 | min-width: max-content; 261 | } 262 | 263 | .process-view .process-row:nth-child(odd) { 264 | background-color: var(--row-odd-bg); 265 | } 266 | 267 | .process-view .process-row.active { 268 | background-color: #007bff; 269 | color: white; 270 | } 271 | 272 | .process-row > div:nth-child(1) { 273 | flex: 0 0 80px; 274 | } 275 | 276 | .process-row > div:nth-child(2) { 277 | flex: 0 0 90px; 278 | } 279 | 280 | .process-row > div:nth-child(3) { 281 | flex: 0 0 100px; 282 | } 283 | 284 | .process-row > div:nth-child(4) { 285 | flex: 0 0 70px; 286 | } 287 | 288 | .process-row > div:nth-child(5) { 289 | flex: 0 0 100px; 290 | } 291 | 292 | .process-row > div:nth-child(6) { 293 | flex: 0 0 150px; 294 | } 295 | 296 | .process-row > div:nth-child(7) { 297 | flex: 0 0 300px; 298 | } 299 | 300 | .process-row > div:last-child { 301 | flex: 1; 302 | } 303 | 304 | .process-open-dir-but { 305 | margin-right: 5px; 306 | transform: scale(0.9); 307 | transition: all 0.3s ease; 308 | } 309 | 310 | .process-open-dir-but:hover { 311 | cursor: pointer; 312 | opacity: 0.8; 313 | transform: scale(1); 314 | color: #fb8c00; 315 | } 316 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom' 3 | 4 | import { 5 | styled, CssBaseline, GlobalStyles, 6 | List, ListItem, ListItemButton, ListItemIcon, ListItemText, 7 | Paper, Tooltip, Stack, Box, Fab 8 | } from '@mui/material' 9 | 10 | import { 11 | Home as HomeIcon, 12 | Storage as StorageIcon, 13 | Inbox as InboxIcon, 14 | Rule as RuleIcon, 15 | Assignment as AssignmentIcon, 16 | Handyman as HandymanIcon, 17 | Settings as SettingsIcon, 18 | Logout as LogoutIcon 19 | } from '@mui/icons-material' 20 | 21 | import { ThemeProvider } from './context/ThemeProvider.tsx' 22 | 23 | import Home from "./view/Home.tsx" 24 | import Server from "./view/Server.tsx" 25 | import ServerCreate from "./view/ServerCreate.tsx" 26 | import ServerExport from "./view/ServerExport.tsx" 27 | import ServerImport from "./view/ServerImport.tsx" 28 | import ServerUpdate from "./view/ServerUpdate.tsx" 29 | import Subscription from "./view/Subscription.tsx" 30 | import Rule from "./view/Rule.tsx" 31 | import Log from "./view/Log.tsx" 32 | import LogDetail from "./view/LogDetail.tsx" 33 | import Tool from "./view/Tool.tsx" 34 | import Setting from "./view/Setting.tsx" 35 | import { useSnackbar } from "./component/useSnackbar.tsx" 36 | 37 | // const ServerImport = lazy(() => import("./view/ServerImport.tsx")) 38 | // const Tool = lazy(() => import("./view/Tool.tsx")) 39 | 40 | import './App.css' 41 | import { appElapsed, isQuietMode, readSubscriptionList, safeInvoke } from "./util/invoke.ts" 42 | import { getSubscription } from "./util/subscription.ts" 43 | import { useDebounce } from "./hook/useDebounce.ts" 44 | import { useVisibility } from "./hook/useVisibility.ts" 45 | import { useWindowFocused } from "./hook/useWindowFocused.ts" 46 | import { useNoBackspaceNav } from "./hook/useNoBackspaceNav.ts" 47 | import { hideWindow, setFocusWindow, showAndFocusWindow } from "./util/tauri.ts" 48 | import { useInitLogLevel } from "./hook/useInitLogLevel.ts" 49 | import { IS_LINUX, sleep } from "./util/util.ts" 50 | 51 | let subscribeLastUpdate = 0 52 | 53 | const App: React.FC = () => { 54 | useNoBackspaceNav() 55 | useInitLogLevel() 56 | 57 | const navItems = [ 58 | {path: '/', text: '首页', icon: }, 59 | {path: '/server', text: '服务器', icon: }, 60 | {path: '/subscription', text: '订阅', icon: }, 61 | {path: '/rule', text: '规则', icon: }, 62 | {path: '/log', text: '日志', icon: }, 63 | {path: '/tool', text: '工具', icon: }, 64 | {path: '/setting', text: '设置', icon: } 65 | ] 66 | 67 | const snackbar = useSnackbar() 68 | const isElapsed = useRef(false) 69 | useEffect(() => { 70 | window.__SNACKBAR__ = snackbar 71 | setTimeout(async () => { 72 | if (isElapsed.current) return 73 | isElapsed.current = true 74 | 75 | let isQuiet = await isQuietMode() 76 | if (!isQuiet) await showAndFocusWindow() 77 | await appElapsed() 78 | 79 | // 如果是 Linux 系统,延迟 200ms 再设置窗口置顶, 提升兼容性,保证标题栏按钮可用 80 | if (IS_LINUX) { 81 | await sleep(200) 82 | await setFocusWindow() 83 | } 84 | }, 0) 85 | }, []) 86 | 87 | const isVisibility = useVisibility() 88 | const isWindowFocused = useWindowFocused() 89 | useEffect(() => { 90 | if (isVisibility) setTimeout(subscribeUpdate, 0) 91 | if (!isVisibility && !isWindowFocused) setTimeout(hideWindow, 0) 92 | }, [isVisibility, isWindowFocused]) 93 | 94 | const subscribeUpdate = useDebounce(async () => { 95 | if (Date.now() - subscribeLastUpdate < 1000 * 60 * 10) return // 更新频率,不要超过 10 分钟 96 | 97 | const subList = await readSubscriptionList() as SubscriptionList 98 | if (subList) { 99 | for (const row of subList) { 100 | if (row.autoUpdate) { 101 | await getSubscription(row) 102 | } 103 | } 104 | } 105 | subscribeLastUpdate = Date.now() 106 | }, 2000) 107 | 108 | // ====================== nav ====================== 109 | const [navState, setNavState] = useState(-1) 110 | const handleNavClick = (index: number) => { 111 | setNavState(index) 112 | } 113 | 114 | const CustomListItemIcon = styled(ListItemIcon)(() => ({minWidth: 36})) 115 | 116 | return ( 117 | 118 | 119 | 120 | {snackbar.SnackbarComponent()} 121 | 122 | 123 | 124 | 125 | safeInvoke('quit')}> 126 | 127 | 128 | 129 | 130 | 131 |
132 | 133 | 134 | {navItems.map((item, index) => ( 135 | 136 | handleNavClick(index)} 141 | > 142 | {item.icon} 143 | 144 | 145 | 146 | ))} 147 | 148 | 149 |
150 |
151 | 152 | }/> 153 | }/> 154 | }/> 155 | }/> 156 | }/> 157 | }/> 158 | }/> 159 | }/> 160 | }/> 161 | }/> 162 | }/> 163 | }/> 164 | 165 |
166 |
167 |
168 | ) 169 | } 170 | 171 | export default App 172 | -------------------------------------------------------------------------------- /src/component/AutoCompleteField.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Autocomplete, TextField } from '@mui/material' 3 | 4 | interface AutoCompleteFieldProps { 5 | label: string; 6 | value: string; 7 | options: string[]; 8 | onChange: (value: string) => void; 9 | id?: string; 10 | } 11 | 12 | export const AutoCompleteField = ({label, value, options, onChange, id}: AutoCompleteFieldProps) => { 13 | const [inputValue, setInputValue] = useState(value) 14 | 15 | useEffect(() => { 16 | setInputValue(value) 17 | }, [value]) 18 | 19 | return ( 20 | setInputValue(v)} 28 | onChange={(_, v) => onChange(v || '')} 29 | onBlur={() => onChange(inputValue)} 30 | options={options} 31 | renderInput={(params) => } 32 | /> 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/component/CodeViewer.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror from '@uiw/react-codemirror' 2 | import { json } from '@codemirror/lang-json' 3 | import { html } from '@codemirror/lang-html' 4 | import { useTheme } from '@mui/material' 5 | 6 | interface JsonCodeViewerProps { 7 | value: Record | string; 8 | height?: string; 9 | isDark?: boolean; 10 | fontSize?: string; 11 | className?: string; 12 | readOnly?: boolean; 13 | } 14 | 15 | interface HtmlCodeViewerProps { 16 | value: string; 17 | height?: string; 18 | isDark?: boolean; 19 | fontSize?: string; 20 | className?: string; 21 | readOnly?: boolean; 22 | } 23 | 24 | export const isDark = () => { 25 | const theme = useTheme() 26 | return theme.palette.mode === 'dark' 27 | } 28 | 29 | export const JsonCodeViewer = ( 30 | {value, height = '500px', fontSize = '14px', className = 'code-mirror scr-w2', readOnly = true}: JsonCodeViewerProps 31 | ) => { 32 | return ( 33 | 42 | ) 43 | } 44 | 45 | export const HtmlCodeViewer = ( 46 | {value, height = '500px', fontSize = '14px', className = 'code-mirror scr-w2', readOnly = true}: HtmlCodeViewerProps 47 | ) => { 48 | return ( 49 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/component/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom' 2 | import { Stack, Paper, Button, Typography } from '@mui/material' 3 | import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew' 4 | 5 | export const PageHeader = ({title, backLink}: { title: string, backLink: string }) => { 6 | const navigate = useNavigate() 7 | return ( 8 | 9 | 17 | {title} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/component/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { 3 | FormControl, 4 | InputLabel, 5 | OutlinedInput, 6 | InputAdornment, 7 | IconButton, 8 | FormHelperText 9 | } from '@mui/material' 10 | import { Visibility, VisibilityOff } from '@mui/icons-material' 11 | 12 | interface PasswordInputProps { 13 | label: string; 14 | value: string; 15 | onChange: (value: string) => void; 16 | error?: boolean; 17 | helperText?: string; 18 | } 19 | 20 | export const PasswordInput = ({label, value, onChange, error, helperText}: PasswordInputProps) => { 21 | const [showPassword, setShowPassword] = useState(false) 22 | 23 | return ( 24 | 25 | {label} 26 | onChange(e.target.value)} 31 | endAdornment={ 32 | 33 | setShowPassword((show) => !show)} 36 | onMouseDown={(e) => e.preventDefault()} 37 | onMouseUp={(e) => e.preventDefault()} 38 | edge="end" 39 | > 40 | {showPassword ? : } 41 | 42 | 43 | } 44 | /> 45 | {helperText && {helperText}} 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/component/SelectField.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, TextField } from '@mui/material' 2 | 3 | interface SelectFieldProps { 4 | label: string; 5 | value: string; 6 | options: string[]; 7 | onChange: (value: string) => void; 8 | id?: string; 9 | } 10 | 11 | export const SelectField = ({label, value, options, onChange, id}: SelectFieldProps) => { 12 | return ( 13 | onChange(e.target.value)} 19 | > 20 | {options.map((v) => ( 21 | {v} 22 | ))} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/component/SpeedGauge.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Typography } from '@mui/material' 2 | import { GaugeContainer, GaugeValueArc, GaugeReferenceArc, useGaugeState } from '@mui/x-charts/Gauge' 3 | 4 | function GaugePointer() { 5 | const {valueAngle, outerRadius, cx, cy} = useGaugeState() 6 | if (valueAngle === null) return null 7 | 8 | const target = { 9 | x: cx + outerRadius * Math.sin(valueAngle), 10 | y: cy - outerRadius * Math.cos(valueAngle), 11 | } 12 | return ( 13 | 14 | 15 | ) 16 | } 17 | 18 | export const SpeedGauge = ({percent, value}: { percent: number, value: string }) => { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | {value} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/component/useAlert.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Alert, Collapse } from '@mui/material' 3 | 4 | export const useAlert = () => { 5 | const [open, setOpen] = useState(false) 6 | const [msg, setMsg] = useState('') 7 | const [severity, setSeverity] = useState<'success' | 'info' | 'warning' | 'error'>('info') 8 | 9 | const showAlert = (msg: string, severity?: 'success' | 'info' | 'warning' | 'error') => { 10 | setMsg(msg) 11 | setSeverity(severity || 'info') 12 | setOpen(true) 13 | } 14 | 15 | const AlertComponent = () => ( 16 | 17 | setOpen(false)}>{msg} 18 | 19 | ) 20 | 21 | return {AlertComponent, showAlert} 22 | } 23 | -------------------------------------------------------------------------------- /src/component/useAlertDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Alert, Dialog, } from '@mui/material' 3 | 4 | export const useAlertDialog = () => { 5 | const [open, setOpen] = useState(false) 6 | const [message, setMessage] = useState('') 7 | const [severity, setSeverity] = useState<'success' | 'info' | 'warning' | 'error'>('error') 8 | 9 | const showAlertDialog = (msg: string, severity?: 'success' | 'info' | 'warning' | 'error', duration?: number) => { 10 | setOpen(true) 11 | setMessage(msg) 12 | setSeverity(severity || 'error') 13 | duration && setTimeout(() => setOpen(false), duration) 14 | } 15 | 16 | const AlertDialogComponent = () => ( 17 | setOpen(false)}> 18 | setOpen(false)}>{message} 19 | 20 | ) 21 | 22 | return {AlertDialogComponent, showAlertDialog} 23 | } 24 | -------------------------------------------------------------------------------- /src/component/useAppBar.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom' 2 | import { AppBar, Typography, Button } from '@mui/material' 3 | import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew' 4 | 5 | export const useAppBar = (backUrl: string, title: string) => { 6 | const navigate = useNavigate() 7 | const AppBarComponent = () => ( 8 | 9 | 14 | {title} 15 | 16 | ) 17 | return {AppBarComponent} 18 | } 19 | -------------------------------------------------------------------------------- /src/component/useCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CircularProgress } from '@mui/material' 2 | import FmdBadIcon from '@mui/icons-material/FmdBad' 3 | 4 | const centerSx = { 5 | p: 3, 6 | display: 'flex', 7 | flexDirection: 'column', 8 | alignItems: 'center', 9 | justifyContent: 'center', 10 | height: 'calc(100vh - 20px)', 11 | mt: 0, 12 | textAlign: 'center' 13 | } 14 | 15 | export const ErrorCard = ({errorMsg, height, elevation, mt}: { 16 | errorMsg: string 17 | height?: string 18 | elevation?: number 19 | mt?: number 20 | }) => { 21 | let sx = {...centerSx, ...(height ? {height} : {}), ...(mt ? {mt} : {})} 22 | return ( 23 | 24 | 25 |
{errorMsg}
26 |
27 | ) 28 | } 29 | 30 | export const LoadingCard = ({height, elevation, mt}: { height?: string, elevation?: number, mt?: number }) => { 31 | let sx = {...centerSx, ...(height ? {height} : {}), ...(mt ? {mt} : {})} 32 | return ( 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/component/useChip.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Collapse, Chip } from '@mui/material' 3 | 4 | export const useChip = () => { 5 | const [open, setOpen] = useState(false) 6 | const [msg, setMsg] = useState('') 7 | const [color, setColor] = useState<'success' | 'info' | 'warning' | 'error'>('success') 8 | const [timer, setTimer] = useState(-1) 9 | 10 | const showChip = (msg: string, color?: 'success' | 'info' | 'warning' | 'error', duration?: number) => { 11 | setOpen(true) 12 | setMsg(msg) 13 | setColor(color || 'success') 14 | const timer = setTimeout(() => setOpen(false), duration || 2000) 15 | setTimer(timer) 16 | } 17 | 18 | useEffect(() => { 19 | return () => clearTimeout(timer) 20 | }, []) 21 | 22 | const ChipComponent = () => ( 23 | 24 | 25 | 26 | ) 27 | 28 | return {ChipComponent, showChip} 29 | } 30 | -------------------------------------------------------------------------------- /src/component/useDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material' 3 | 4 | export function useDialog() { 5 | const [open, setOpen] = useState(false) 6 | const [title, setTitle] = useState('') 7 | const [content, setContent] = useState('') 8 | const [onConfirm, setOnConfirm] = useState<() => void>() 9 | 10 | const dialogConfirm = (title: string, content: string, onConfirm: () => void) => { 11 | setTitle(title) 12 | setContent(content) 13 | setOnConfirm(() => onConfirm) 14 | setOpen(true) 15 | } 16 | 17 | const handleClose = () => { 18 | setOpen(false) 19 | } 20 | 21 | const handleConfirm = () => { 22 | onConfirm && onConfirm() 23 | handleClose() 24 | } 25 | 26 | const DialogComponent = () => ( 27 | 28 | {title} 29 | 30 | {content} 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | 39 | return {DialogComponent, dialogConfirm} 40 | } 41 | -------------------------------------------------------------------------------- /src/component/useServerImport.tsx: -------------------------------------------------------------------------------- 1 | import { getNewServerList } from "../util/server.ts" 2 | import { saveServerList } from "../util/invoke.ts" 3 | 4 | export const useServerImport = async ( 5 | inputValue: string, 6 | showSnackbar: (msg: string, severity?: 'success' | 'info' | 'warning' | 'error') => void, 7 | setError?: ((value: boolean) => void) | null, 8 | onSuccess?: () => void, 9 | ) => { 10 | const s = inputValue.trim() 11 | if (!s) return 12 | 13 | const {newServerList, errNum, existNum, newNum} = await getNewServerList(s) 14 | 15 | const errMsg = `解析链接(URI)错误: ${errNum} 条` 16 | const okMsg = `导入成功: ${newNum} 条` 17 | const existMsg = `已存在: ${existNum} 条` 18 | setError && setError(existNum > 0) 19 | if (newNum > 0) { 20 | const ok = await saveServerList(newServerList) 21 | if (!ok) { 22 | showSnackbar('导入失败', 'error') 23 | } else { 24 | if (errNum > 0) { 25 | showSnackbar(`${errMsg},${okMsg},${existMsg}`, 'error') 26 | } else if (existNum > 0) { 27 | showSnackbar(`${existMsg},${okMsg}`, 'warning') 28 | } else { 29 | showSnackbar(okMsg) 30 | } 31 | onSuccess && onSuccess() 32 | } 33 | } else if (existNum > 0) { 34 | if (errNum > 0) { 35 | showSnackbar(`${existMsg},${errMsg},${okMsg}`, 'error') 36 | } else if (existNum > 0) { 37 | showSnackbar(`${existMsg},${okMsg}`, 'warning') 38 | } 39 | } else if (errNum > 0) { 40 | showSnackbar(errMsg, 'error') 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/component/useSnackbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react' 2 | import { Snackbar, Alert } from '@mui/material' 3 | 4 | type SnackbarPosition = 'bottom-right' | 'top-right' | 'top-center' 5 | 6 | export const useSnackbar = () => { 7 | const [open, setOpen] = useState(false) 8 | const [message, setMessage] = useState('') 9 | const [autoHideDuration, setAutoHideDuration] = useState(3000) 10 | const [severity, setSeverity] = useState<'success' | 'info' | 'warning' | 'error'>('info') 11 | const [position, setPosition] = useState('top-center') 12 | 13 | const lastMessageRef = useRef(null) 14 | const timeoutRef = useRef(null) 15 | 16 | const showSnackbar = ( 17 | msg: string, 18 | severityLevel?: 'success' | 'info' | 'warning' | 'error', 19 | duration?: number, 20 | pos?: SnackbarPosition 21 | ) => { 22 | if (open && msg === lastMessageRef.current) return 23 | 24 | if (timeoutRef.current) { 25 | clearTimeout(timeoutRef.current) 26 | timeoutRef.current = null 27 | } 28 | 29 | timeoutRef.current = setTimeout(() => { 30 | setMessage(msg) 31 | setSeverity(severityLevel || 'info') 32 | setAutoHideDuration(duration ?? 2000) 33 | setPosition(pos || 'top-center') 34 | setOpen(true) 35 | lastMessageRef.current = msg 36 | }, 100) 37 | } 38 | 39 | const SnackbarComponent = () => { 40 | let anchorOrigin: { vertical: 'top' | 'bottom'; horizontal: 'left' | 'center' | 'right' } 41 | 42 | switch (position) { 43 | case 'top-right': 44 | anchorOrigin = {vertical: 'top', horizontal: 'right'} 45 | break 46 | case 'bottom-right': 47 | anchorOrigin = {vertical: 'bottom', horizontal: 'right'} 48 | break 49 | case 'top-center': 50 | default: 51 | anchorOrigin = {vertical: 'top', horizontal: 'center'} 52 | break 53 | } 54 | 55 | return ( 56 | 57 | {message} 58 | 59 | ) 60 | } 61 | 62 | const handleSnackbarClose = (_?: any, reason?: string) => { 63 | if (reason === 'clickaway') return 64 | setOpen(false) 65 | } 66 | 67 | return {SnackbarComponent, showSnackbar, handleSnackbarClose} 68 | } 69 | -------------------------------------------------------------------------------- /src/context/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect, useMemo } from 'react' 2 | import useMediaQuery from '@mui/material/useMediaQuery' 3 | import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles' 4 | import { setThemeWindow } from "../util/tauri.ts" 5 | 6 | type ThemeMode = 'light' | 'dark' | 'system'; 7 | 8 | interface ThemeContextType { 9 | mode: ThemeMode; 10 | toggleMode: (newMode: ThemeMode) => void; 11 | } 12 | 13 | const ThemeContext = createContext({ 14 | mode: 'system', 15 | toggleMode: () => { 16 | }, 17 | }) 18 | 19 | export const useTheme = () => useContext(ThemeContext) 20 | 21 | export const ThemeProvider = ({children}: { children: React.ReactNode }) => { 22 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)') 23 | const [mode, setMode] = useState(() => { 24 | const savedMode = localStorage.getItem('themeMode') as ThemeMode 25 | return ['light', 'dark', 'system'].includes(savedMode) ? savedMode : 'system' 26 | }) 27 | 28 | const setTheme = (newMode: ThemeMode) => { 29 | newMode = ['light', 'dark', 'system'].includes(newMode) ? newMode : 'system' 30 | setMode(newMode) 31 | setThemeWindow(newMode === 'system' ? null : newMode).catch() 32 | } 33 | 34 | useEffect(() => { 35 | const savedMode = localStorage.getItem('themeMode') as ThemeMode | null 36 | if (savedMode) setTheme(savedMode) 37 | }, []) 38 | 39 | useEffect(() => localStorage.setItem('themeMode', mode), [mode]) 40 | 41 | useEffect(() => { 42 | const htmlElement = document.documentElement 43 | const paletteMode = mode === 'system' ? (prefersDarkMode ? 'dark' : 'light') : mode 44 | htmlElement.classList.remove('light', 'dark') 45 | htmlElement.classList.add(paletteMode) 46 | }, [mode, prefersDarkMode]) 47 | 48 | const toggleMode = (newMode: ThemeMode) => { 49 | setTheme(newMode) 50 | } 51 | 52 | const theme = useMemo(() => { 53 | const paletteMode = mode === 'system' ? (prefersDarkMode ? 'dark' : 'light') : mode 54 | return createTheme({ 55 | palette: {mode: paletteMode}, 56 | components: { 57 | MuiCard: { 58 | styleOverrides: { 59 | root: ({theme}) => ({ 60 | border: theme.palette.mode === 'light' ? '1px solid' : 'none', 61 | borderColor: theme.palette.mode === 'light' ? theme.palette.divider : 'transparent' 62 | }) 63 | } 64 | } 65 | } 66 | }) 67 | }, [mode, prefersDarkMode]) 68 | 69 | return ( 70 | 71 | 72 | {children} 73 | 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/hook/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | export const useDebounce = (fn: (...args: any[]) => void, delay: number) => { 4 | const timeoutRef = useRef(0) 5 | return (...args: any[]) => { 6 | if (timeoutRef.current) clearTimeout(timeoutRef.current) 7 | timeoutRef.current = setTimeout(() => fn(...args), delay) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/hook/useFullHeight.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export const useFullHeight = () => { 4 | const [height, setHeight] = useState(window.innerHeight) 5 | 6 | useEffect(() => { 7 | const handleResize = () => { 8 | setHeight(window.innerHeight) 9 | } 10 | 11 | window.addEventListener('resize', handleResize) 12 | return () => window.removeEventListener('resize', handleResize) 13 | }, []) 14 | 15 | return height 16 | } 17 | -------------------------------------------------------------------------------- /src/hook/useInitLogLevel.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { readAppConfig } from "../util/invoke.ts" 3 | import { useDebounce } from "./useDebounce.ts" 4 | import { DEFAULT_APP_CONFIG } from "../util/config.ts" 5 | 6 | export const useInitLogLevel = () => { 7 | const loadConfig = useDebounce(async () => { 8 | const appConf = (await readAppConfig() as AppConfig) || DEFAULT_APP_CONFIG 9 | if (appConf?.app_log_level) { 10 | window.__APP_LOG_LEVEL__ = appConf.app_log_level 11 | } 12 | }, 50) 13 | useEffect(loadConfig, []) 14 | } 15 | -------------------------------------------------------------------------------- /src/hook/useNoBackspaceNav.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | const isEditableElement = (el: EventTarget | null): boolean => { 4 | if (!(el instanceof HTMLElement)) return false 5 | 6 | const tag = el.tagName.toLowerCase() 7 | const editableTags = ['input', 'textarea', 'select'] 8 | return (editableTags.includes(tag) || el.isContentEditable) 9 | } 10 | 11 | // 禁用 Backspace 导致的浏览器后退 12 | export function useNoBackspaceNav() { 13 | useEffect(() => { 14 | const handleBackspace = (e: KeyboardEvent) => { 15 | if (e.key === 'Backspace' && !isEditableElement(e.target)) { 16 | e.preventDefault() 17 | } 18 | } 19 | 20 | window.addEventListener('keydown', handleBackspace) 21 | return () => window.removeEventListener('keydown', handleBackspace) 22 | }, []) 23 | } 24 | -------------------------------------------------------------------------------- /src/hook/useVisibility.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export const useVisibility = () => { 4 | const [isVisibility, setIsVisibility] = useState(true) 5 | useEffect(() => { 6 | const handleVisibilityChange = () => { 7 | setIsVisibility(document.visibilityState === 'visible') 8 | } 9 | document.addEventListener('visibilitychange', handleVisibilityChange) 10 | return () => { 11 | document.removeEventListener('visibilitychange', handleVisibilityChange) 12 | } 13 | }, []) 14 | return isVisibility 15 | } 16 | -------------------------------------------------------------------------------- /src/hook/useWindowFocused.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export const useWindowFocused = () => { 4 | const [isWindowFocused, setIsWindowFocused] = useState(true) 5 | 6 | useEffect(() => { 7 | const handleFocus = () => setIsWindowFocused(true) 8 | const handleBlur = () => setIsWindowFocused(false) 9 | 10 | window.addEventListener('focus', handleFocus) 11 | window.addEventListener('blur', handleBlur) 12 | 13 | return () => { 14 | window.removeEventListener('focus', handleFocus) 15 | window.removeEventListener('blur', handleBlur) 16 | } 17 | }, []) 18 | 19 | return isWindowFocused 20 | } 21 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.tsx' 4 | 5 | const isDev = import.meta.env.MODE === 'development' 6 | if (!isDev) document.addEventListener('contextmenu', e => e.preventDefault()) 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /src/util/concurrency.ts: -------------------------------------------------------------------------------- 1 | function createSemaphore(maxConcurrency: number) { 2 | let currentConcurrency = 0 3 | const queue: (() => void)[] = [] 4 | 5 | const acquire = async (): Promise => { 6 | if (currentConcurrency < maxConcurrency) { 7 | currentConcurrency++ 8 | return 9 | } 10 | 11 | return new Promise((resolve) => { 12 | queue.push(resolve) 13 | }) 14 | } 15 | 16 | const release = (): void => { 17 | if (queue.length > 0) { 18 | const next = queue.shift() 19 | if (next) next() 20 | } else { 21 | currentConcurrency-- 22 | } 23 | } 24 | 25 | return {acquire, release} 26 | } 27 | 28 | export async function runWithConcurrency( 29 | tasks: (() => Promise)[], 30 | concurrency: number 31 | ): Promise { 32 | const {acquire, release} = createSemaphore(concurrency) 33 | const results: T[] = [] 34 | 35 | const runTask = async (task: () => Promise): Promise => { 36 | await acquire() 37 | try { 38 | const result = await task() 39 | results.push(result) 40 | } finally { 41 | release() 42 | } 43 | } 44 | 45 | await Promise.all(tasks.map((task) => runTask(task))) 46 | return results 47 | } 48 | -------------------------------------------------------------------------------- /src/util/crypto.ts: -------------------------------------------------------------------------------- 1 | import { log } from './invoke.ts' 2 | 3 | /** 4 | * 计算字符串的哈希值 5 | * @param input 输入字符串 6 | * @param algorithm 哈希算法,默认为 'SHA-256', 可选 'MD5', 'SHA-1', 'SHA-256', 'SHA-384', 'SHA-512' 等 7 | * @returns 哈希值的十六进制字符串 8 | */ 9 | export async function hashString(input: string, algorithm: string = 'SHA-256'): Promise { 10 | const encoder = new TextEncoder() 11 | const data = encoder.encode(input) 12 | const hashBuffer = await crypto.subtle.digest(algorithm, data) 13 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 14 | return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('') 15 | } 16 | 17 | export async function hashJson(data: any, algorithm: string = 'SHA-256'): Promise { 18 | const s = safeJsonStringify(data) 19 | return await hashString(s, algorithm) 20 | } 21 | 22 | export function encodeBase64(str: string): string { 23 | try { 24 | const encoder = new TextEncoder() 25 | const data = encoder.encode(str) 26 | return btoa(String.fromCharCode(...data)) 27 | } catch (error) { 28 | log.error('Base64 encode error:', error) 29 | return '' 30 | } 31 | } 32 | 33 | export function decodeBase64(base64: string): string { 34 | try { 35 | const binaryString = atob(base64) 36 | const bytes = new Uint8Array([...binaryString].map(char => char.charCodeAt(0))) 37 | const decoder = new TextDecoder() 38 | return decoder.decode(bytes) 39 | } catch (error) { 40 | const shortBase64 = base64.length > 100 ? base64.slice(0, 100) + '...' : base64 41 | log.error(`Failed to decode base64 input "${shortBase64}", error: ${error}`) 42 | return '' 43 | } 44 | } 45 | 46 | export function safeJsonStringify(data: any): string { 47 | try { 48 | return JSON.stringify(data) 49 | } catch (e) { 50 | log.error('JSON.stringify failed:', e) 51 | return '{}' 52 | } 53 | } 54 | 55 | export function safeJsonParse(jsonString: string): any { 56 | try { 57 | return JSON.parse(jsonString) 58 | } catch (e) { 59 | log.error('JSON.parse failed:', e) 60 | return null 61 | } 62 | } 63 | 64 | export function safeDecodeURI(encodedURI: string): string { 65 | try { 66 | return decodeURIComponent(encodedURI) 67 | } catch (e) { 68 | log.error('Failed to decode URI component:', e) 69 | return encodedURI || '' 70 | } 71 | } 72 | 73 | export function deepSafeDecodeURI(data: any): any { 74 | if (typeof data === 'string') { 75 | return safeDecodeURI(data) 76 | } else if (Array.isArray(data)) { 77 | return data.map(item => deepSafeDecodeURI(item)) 78 | } else if (data && typeof data === 'object') { 79 | const result: any = {} 80 | for (const key in data) { 81 | result[key] = deepSafeDecodeURI(data[key]) 82 | } 83 | return result 84 | } 85 | return data 86 | } 87 | -------------------------------------------------------------------------------- /src/util/dns.ts: -------------------------------------------------------------------------------- 1 | export function dnsToConf(dnsConfig: DnsConfig, dnsModeList: DnsModeList): any { 2 | if (!dnsConfig.enable) return {} 3 | const row = dnsModeList[dnsConfig.mode] 4 | if (row) { 5 | return {dns: dnsModeToConf(row)} 6 | } 7 | return {} 8 | } 9 | 10 | export function dnsModeToConf(row: DnsModeRow) { 11 | const dns: any = {} 12 | dns.tag = 'doay-dns' 13 | if (Array.isArray(row.hosts) && row.hosts.length > 0) { 14 | const hosts: any = {} 15 | for (const item of row.hosts) { 16 | hosts[item.domain] = item.host.indexOf('\n') > -1 ? item.host.split('\n') : item.host 17 | } 18 | dns.hosts = hosts 19 | } 20 | 21 | let globalUseIP = false 22 | if (Array.isArray(row.servers) && row.servers.length > 0) { 23 | const servers: any[] = [] 24 | for (const item of row.servers) { 25 | if (item.type === 'address') { 26 | servers.push(item.address) 27 | } else { 28 | const obj: any = {} 29 | obj.address = item.address 30 | if (item.port && item.port !== 53) obj.address = Number(item.port) 31 | if (item.domains) obj.domains = item.domains.split('\n') 32 | if (item.expectIPs) obj.expectIPs = item.expectIPs.split('\n') 33 | if (item.clientIP) obj.clientIP = item.clientIP 34 | if (item.queryStrategy && item.queryStrategy !== 'UseIP') { 35 | obj.queryStrategy = item.queryStrategy 36 | if (['UseIPv4', 'UseIPv6'].indexOf(item.queryStrategy) > -1) globalUseIP = true 37 | } 38 | if (item.timeoutMs > 0 && item.timeoutMs !== 4000) obj.timeoutMs = Number(item.timeoutMs) 39 | if (item.skipFallback) obj.skipFallback = true 40 | if (item.allowUnexpectedIPs) obj.allowUnexpectedIPs = true 41 | servers.push(obj) 42 | } 43 | } 44 | dns.servers = servers 45 | } 46 | 47 | if (globalUseIP) row.queryStrategy = 'UseIP' 48 | 49 | if (row.clientIP) dns.clientIP = row.clientIP 50 | if (row.queryStrategy && row.queryStrategy !== 'UseIP') dns.queryStrategy = row.queryStrategy 51 | if (row.disableCache) dns.disableCache = true 52 | if (row.disableFallback) dns.disableFallback = true 53 | if (row.disableFallbackIfMatch) dns.disableFallbackIfMatch = true 54 | 55 | return dns 56 | } 57 | -------------------------------------------------------------------------------- /src/util/highlightLog.ts: -------------------------------------------------------------------------------- 1 | // 预编译正则表达式 2 | const timestampRegex = /(\d{4}[-\/]\d{2}[-\/]\d{2}[\sT]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/g 3 | const ipWithPortRegex = /\b(?:\d{1,3}\.){3}\d{1,3}(:\d{1,5})?\b/g 4 | const ipv6WithPortRegex = /\b(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4})(:\d{1,5})?\b/g 5 | const domainWithPortRegex = /\b(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}:\d{1,5}|localhost|broadcasthost|(?:[a-zA-Z0-9-]+\.)+(?:com|net|org|cn|us|uk|de|jp|fr|ru|info|biz|at))\b/g 6 | const numberRegex = /(^|\s)(\d+\.\d+|\d+)($|\s)/g 7 | 8 | // 关键词映射 9 | const keywordMap = [ 10 | {regex: /\berror\b/gi, color: 'red'}, 11 | {regex: /\bwarn\b/gi, color: 'orange'}, 12 | {regex: /\bwarning\b/gi, color: 'orange'}, 13 | {regex: /\binfo\b/gi, color: 'green'}, 14 | {regex: /\bdebug\b/gi, color: 'blue'}, 15 | {regex: /\btrace\b/gi, color: 'purple'}, 16 | {regex: /\bdoay\b/gi, color: '#4caf50'}, 17 | {regex: /\btauri\b/gi, color: '#4caf50'}, 18 | {regex: /\brustc\b/gi, color: '#4caf50'}, 19 | {regex: /\btcp:\b/gi, color: '#9c27b0'}, 20 | {regex: /\budp:\b/gi, color: '#e65100'}, 21 | {regex: /\bxray\b/gi, color: '#e65100'}, 22 | {regex: /\bfailed\b/gi, color: 'red'}, 23 | {regex: /\bbroken\b/gi, color: 'red'}, 24 | {regex: /\bdns\b/gi, color: '#568ed9'}, 25 | {regex: /\bproxy\b/g, color: '#fb8c00'}, 26 | {regex: /\bdirect\b/g, color: '#4caf50'}, 27 | {regex: /\breject\b/g, color: '#d50000'}, 28 | {regex: /\binbound\b/g, color: '#69ab73'}, 29 | {regex: /\boutbound\b/g, color: '#cf8e6d'}, 30 | {regex: /\binvalid args\b/g, color: 'red'}, 31 | {regex: /\bmissing required key\b/g, color: 'red'}, 32 | {regex: /\bNo such file or directory\b/gi, color: 'red'}, 33 | {regex: /\baddress already in use\b/gi, color: 'red'} 34 | ] 35 | 36 | const errorCodes = [404, 500, 403, 502] 37 | 38 | /** 39 | * 高亮日志内容 40 | * @param content 日志内容 41 | * @returns 高亮后的 HTML 字符串数组 42 | */ 43 | export default (content: string): string[] => { 44 | return content.split('\n').map(line => { 45 | // 转义 HTML 特殊字符,防止 XSS 攻击 46 | line = line.replace(/&/g, '&') 47 | .replace(//g, '>') 49 | .replace(/"/g, '"') 50 | .replace(/'/g, ''') 51 | 52 | // 关键词匹配 53 | keywordMap.forEach(({regex, color}) => { 54 | line = line.replace(regex, `$&`) 55 | }) 56 | 57 | // 特殊格式匹配 58 | line = line 59 | .replace(timestampRegex, '$&') 60 | .replace(ipWithPortRegex, '$&') 61 | .replace(ipv6WithPortRegex, '$&') 62 | .replace(domainWithPortRegex, '$&') 63 | .replace(numberRegex, match => { 64 | return match.replace(/(\d+\.\d+|\d+)/, number => { 65 | if (number === '200') return `${match}` 66 | if (errorCodes.includes(parseInt(number))) return `${match}` 67 | if (number.includes('.')) return `${number}` 68 | return `${number}` 69 | }) 70 | }) 71 | 72 | return line 73 | }) 74 | }; 75 | -------------------------------------------------------------------------------- /src/util/network.ts: -------------------------------------------------------------------------------- 1 | import { fetchGet } from "./invoke.ts" 2 | import { safeJsonParse } from "./crypto.ts" 3 | 4 | export const sumNetworks = (networks: any[]) => { 5 | let up = 0 6 | let down = 0 7 | let loUp = 0 8 | let loDown = 0 9 | for (const net of networks) { 10 | if (net.type === 'Loopback') { 11 | // 回环地址不计入网络流量统计 12 | loUp += net.up || 0 13 | loDown += net.down || 0 14 | } else { 15 | up += net.up || 0 16 | down += net.down || 0 17 | } 18 | } 19 | return {up, down, loUp, loDown} 20 | } 21 | 22 | /** 23 | * 计算每秒的上传和下载速率 24 | * @param prev 上一次的上传和下载总量 25 | * @param current 当前的上传和下载总量 26 | * @param interval 时间间隔(秒),默认值为 1 27 | * @returns 每秒的上传和下载速率(单位:字节/秒) 28 | */ 29 | export const calculateNetworkSpeed = ( 30 | prev: { up: number; down: number }, 31 | current: { up: number; down: number }, 32 | interval: number = 1 33 | ): { upSpeed: number; downSpeed: number } => { 34 | const upSpeed = (current.up - prev.up) / interval 35 | const downSpeed = (current.down - prev.down) / interval 36 | return {upSpeed: Math.max(upSpeed, 0), downSpeed: Math.max(downSpeed, 0)} 37 | } 38 | 39 | export async function getStatsData(port: number) { 40 | const r = await fetchGet(`http://127.0.0.1:${port}/debug/vars`) 41 | if (!r || !r.ok) return false 42 | const obj = safeJsonParse(r.body) 43 | let result: any = {} 44 | if (obj.memstats) { 45 | result.memStats = extractMemStats(obj.memstats) 46 | } 47 | if (obj.stats) { 48 | result = {...result, ...formatStats(obj.stats)} 49 | } 50 | return result 51 | } 52 | 53 | function extractMemStats(mem: any) { 54 | return { 55 | currentAlloc: mem.Alloc, // 当前程序使用的内存(单位:字节),即正在使用中的内存 56 | sys: mem.Sys, // 系统为程序分配的总内存,包括堆、栈、代码等(单位:字节) 57 | totalAlloc: mem.TotalAlloc, // 程序运行以来累计分配的所有内存总量(单位:字节),包含已释放部分 58 | // heapAlloc: mem.HeapAlloc, // 当前堆上被分配且正在使用的内存(单位:字节) 59 | // heapSys: mem.HeapSys, // 系统为堆分配的总内存(单位:字节),可能部分尚未使用 60 | 61 | gcCount: mem.NumGC, // 自动触发的垃圾回收(GC)次数 62 | pauseTotalMs: Math.round(mem.PauseTotalNs / 1e6), // 所有 GC 暂停耗时的总和(单位:毫秒) 63 | lastGC: Math.round(mem.LastGC / 1e9), // 最近一次 GC 的时间(从 Unix 纪元开始,单位:秒) 64 | // forcedGCCount: mem.NumForcedGC, // 手动触发 GC 的次数(可选项,调试时有用) 65 | // gcCpuPercent: parseFloat((mem.GCCPUFraction * 100).toFixed(2)), // GC 占用的 CPU 百分比(通常较低,可选) 66 | } 67 | } 68 | 69 | const formatStats = (input: any): { inbound: any, outbound: any } => { 70 | const safeGet = (obj: any, path: string) => { 71 | return path.split('.').reduce((acc, part) => { 72 | return acc && acc[part] !== undefined ? acc[part] : 0 73 | }, obj) 74 | } 75 | 76 | return { 77 | inbound: { 78 | totalUp: safeGet(input, 'inbound.http-in.uplink') + safeGet(input, 'inbound.socks-in.uplink'), 79 | totalDown: safeGet(input, 'inbound.http-in.downlink') + safeGet(input, 'inbound.socks-in.downlink'), 80 | httpUp: safeGet(input, 'inbound.http-in.uplink'), 81 | httpDown: safeGet(input, 'inbound.http-in.downlink'), 82 | socksUp: safeGet(input, 'inbound.socks-in.uplink'), 83 | socksDown: safeGet(input, 'inbound.socks-in.downlink'), 84 | }, 85 | outbound: { 86 | totalUp: safeGet(input, 'outbound.proxy.uplink') + safeGet(input, 'outbound.direct.uplink'), 87 | totalDown: safeGet(input, 'outbound.proxy.downlink') + safeGet(input, 'outbound.direct.downlink'), 88 | proxyUp: safeGet(input, 'outbound.proxy.uplink'), 89 | proxyDown: safeGet(input, 'outbound.proxy.downlink'), 90 | directUp: safeGet(input, 'outbound.direct.uplink'), 91 | directDown: safeGet(input, 'outbound.direct.downlink'), 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/util/proxy.ts: -------------------------------------------------------------------------------- 1 | import { readAppConfig, readRuleConfig, readRuleDomain, saveProxyPac, saveAppConfig } from "./invoke.ts" 2 | import { processLines } from "./util.ts" 3 | import { DEFAULT_APP_CONFIG, DEFAULT_RULE_CONFIG, DEFAULT_RULE_DOMAIN } from "./config.ts" 4 | 5 | export async function reloadProxyPAC() { 6 | const ruleConfig = (await readRuleConfig()) || DEFAULT_RULE_CONFIG 7 | const ruleDomain = (await readRuleDomain()) || DEFAULT_RULE_DOMAIN 8 | await updateProxyPAC(ruleConfig, ruleDomain, true) 9 | } 10 | 11 | // 更新 proxy.js 文件 12 | export async function updateProxyPAC(ruleConfig: RuleConfig, ruleDomain: RuleDomain, isReset: boolean = true) { 13 | const config = (await readAppConfig()) || DEFAULT_APP_CONFIG 14 | if (config.auto_setup_pac) { 15 | const proxy = config.ray_host + ":" + config.ray_socks_port 16 | const proxyDomains = ruleDomain.proxy ? JSON.stringify(processLines(ruleDomain.proxy.toLowerCase()), null, '\t') : '[]' 17 | const directDomains = ruleDomain.direct ? JSON.stringify(processLines(ruleDomain.direct.toLowerCase()), null, '\t') : '[]' 18 | const rejectDomains = ruleDomain.reject ? JSON.stringify(processLines(ruleDomain.reject.toLowerCase()), null, '\t') : '[]' 19 | const s = generateProxyPAC(proxy, proxyDomains, directDomains, rejectDomains, ruleConfig.unmatchedStrategy === 'direct') 20 | await saveProxyPac(s) 21 | 22 | // 避免影响 PAC 规则,其他代理设置全部关闭 23 | if (config.auto_setup_socks) await saveAppConfig('set_auto_setup_socks', false) 24 | if (config.auto_setup_http) await saveAppConfig('set_auto_setup_http', false) 25 | if (config.auto_setup_https) await saveAppConfig('set_auto_setup_https', false) 26 | 27 | // 通知操作系统 PAC 文件已经更新,关闭再开启 28 | if (isReset) { 29 | await saveAppConfig('set_auto_setup_pac', false) 30 | setTimeout(() => saveAppConfig('set_auto_setup_pac', true), 200) 31 | } 32 | } 33 | } 34 | 35 | function generateProxyPAC(proxy: string, proxyDomains: string, directDomains: string, rejectDomains: string, isUnmatchedDirect: boolean = true) { 36 | return ` 37 | var proxy = 'SOCKS5 ${proxy}'; 38 | 39 | var proxyDomains = ${proxyDomains}; 40 | 41 | var directDomains = ${directDomains}; 42 | 43 | var rejectDomains = ${rejectDomains}; 44 | 45 | if (!String.prototype.endsWith) { 46 | String.prototype.endsWith = function(s) { 47 | return this.length >= s.length && this.lastIndexOf(s) === this.length - s.length; 48 | }; 49 | } 50 | 51 | function isHostMatch(domains, host) { 52 | return domains.some(v => v === host || host.endsWith('.' + v)); 53 | } 54 | 55 | function FindProxyForURL(url, host) { 56 | if (isHostMatch(proxyDomains, host)) return proxy; 57 | if (isHostMatch(directDomains, host)) return "DIRECT"; 58 | if (isHostMatch(rejectDomains, host)) return "PROXY 127.0.0.1:65535"; 59 | return ${isUnmatchedDirect ? '"DIRECT"' : 'proxy'}; 60 | } 61 | ` 62 | } 63 | -------------------------------------------------------------------------------- /src/util/rule.ts: -------------------------------------------------------------------------------- 1 | import { processLines } from "./util.ts" 2 | 3 | export function ruleToConf(ruleConfig: RuleConfig, ruleDomain: RuleDomain, ruleModeList: RuleModeList): any { 4 | if (ruleConfig.globalProxy) return getGlobalProxyConf() 5 | 6 | let domainStrategy = '' 7 | let rules = [...ruleDomainToConf(ruleDomain)] 8 | 9 | // 采用的哪个模式 10 | if (Array.isArray(ruleModeList) && ruleModeList.length > 0) { 11 | const ruleMode = ruleModeList[ruleConfig.mode] 12 | if (ruleMode && Array.isArray(ruleMode.rules) && ruleMode.rules.length > 0) { 13 | domainStrategy = ruleMode.domainStrategy 14 | rules = [...rules, ...ruleModeToConf(ruleMode.rules)] 15 | } 16 | } 17 | 18 | // 未匹配策略,方便观察匹配上的哪个规则 19 | if (ruleConfig.unmatchedStrategy) { 20 | rules.push({ 21 | type: 'field', 22 | ruleTag: 'doay-unmatched', 23 | outboundTag: ruleConfig.unmatchedStrategy, 24 | // network: 'tcp,udp', 25 | port: '1-65535', 26 | }) 27 | } 28 | 29 | return { 30 | "routing": { 31 | "domainStrategy": domainStrategy || 'AsIs', 32 | "rules": rules 33 | } 34 | } 35 | } 36 | 37 | export function ruleModeToConf(row: RuleRow[]): any[] { 38 | let rules = [] 39 | for (let i = 0; i < row.length; i++) { 40 | const v = row[i] 41 | let rule: any = { 42 | type: 'field', 43 | ruleTag: `${i + 1}-doay-mode-${v.outboundTag}`, 44 | outboundTag: v.outboundTag 45 | } 46 | if (v.ruleType === 'domain') { 47 | rules.push({...rule, domain: processLines(v.domain)}) 48 | } else if (v.ruleType === 'ip') { 49 | rules.push({...rule, ip: processLines(v.ip)}) 50 | } else if (v.ruleType === 'multi') { 51 | if (v.domain) rule.domain = processLines(v.domain) 52 | if (v.ip) rule.ip = processLines(v.ip) 53 | if (v.port) rule.port = processLines(v.port).join(',') 54 | if (v.sourcePort) rule.sourcePort = processLines(v.sourcePort).join(',') 55 | if (v.network) rule.network = v.network 56 | if (v.protocol) rule.protocol = processLines(v.protocol, ',') 57 | rules.push(rule) 58 | } 59 | } 60 | return rules 61 | } 62 | 63 | export function ruleDomainToConf(ruleDomain: RuleDomain): any[] { 64 | let rules = [] 65 | 66 | if (ruleDomain.proxy) { 67 | rules.push({ 68 | type: 'field', 69 | ruleTag: 'doay-domain-proxy', 70 | outboundTag: 'proxy', 71 | domain: processLines(ruleDomain.proxy), 72 | }) 73 | } 74 | 75 | if (ruleDomain.direct) { 76 | rules.push({ 77 | type: 'field', 78 | ruleTag: 'doay-domain-direct', 79 | outboundTag: 'direct', 80 | domain: processLines(ruleDomain.direct), 81 | }) 82 | } 83 | 84 | if (ruleDomain.reject) { 85 | rules.push({ 86 | type: 'field', 87 | ruleTag: 'doay-domain-reject', 88 | outboundTag: 'reject', 89 | domain: processLines(ruleDomain.reject), 90 | }) 91 | } 92 | 93 | return rules 94 | } 95 | 96 | // 考虑用户体验,全局代理,排除代理服务器无法访问的 私有域名 和 私有IP 97 | export function getGlobalProxyConf(): any { 98 | return { 99 | routing: { 100 | domainStrategy: "AsIs", 101 | rules: [{ 102 | type: 'field', 103 | ruleTag: 'doay-global-proxy', 104 | outboundTag: 'direct', 105 | domain: ['geosite:private'], 106 | ip: ['geoip:private'], 107 | }] 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/util/serverOption.ts: -------------------------------------------------------------------------------- 1 | export const vmessNetworkTypeList = [ 2 | 'raw', 3 | 'kcp', 4 | 'ws', 5 | 'http', 6 | 'grpc', 7 | 'httpupgrade', 8 | ] 9 | 10 | export const vmessSecurityList = [ 11 | 'none', 12 | 'auto', 13 | 'zero', 14 | 'aes-128-gcm', 15 | 'chacha20-poly1305', 16 | ] 17 | 18 | export const vlessNetworkTypeList = [ 19 | 'raw', 20 | 'ws', 21 | 'grpc', 22 | 'xhttp', 23 | ] 24 | 25 | export const vlessSecurityList = [ 26 | 'none', 27 | 'tls', 28 | 'reality', 29 | ] 30 | 31 | export const ssMethodList = [ 32 | 'none', 33 | '2022-blake3-aes-128-gcm', 34 | '2022-blake3-aes-256-gcm', 35 | '2022-blake3-chacha20-poly1305', 36 | 'aes-128-gcm', 37 | 'aes-256-gcm', 38 | // 'chacha20-poly1305', 39 | // 'xchacha20-poly1305', 40 | 'chacha20-ietf-poly1305', 41 | 'xchacha20-ietf-poly1305', 42 | ] 43 | 44 | export const trojanNetworkTypeList = [ 45 | 'ws', 46 | 'grpc', 47 | ] 48 | 49 | export const flowList = [ 50 | 'xtls-rprx-vision', 51 | 'xtls-rprx-vision-udp443', 52 | ] 53 | 54 | export const fingerprintList = [ 55 | 'chrome', 56 | 'firefox', 57 | 'safari', 58 | 'edge', 59 | '360', 60 | 'qq', 61 | 'ios', 62 | 'android', 63 | 'random', 64 | 'randomized', 65 | ] 66 | 67 | export const rawHeaderTypeList = [ 68 | 'none', 69 | 'http', 70 | ] 71 | 72 | export const kcpHeaderTypeList = [ 73 | 'none', 74 | 'srtp', 75 | 'utp', 76 | 'wechat-video', 77 | 'dtls', 78 | 'wireguard', 79 | ] 80 | 81 | export const grpcModeList = [ 82 | 'gun', 83 | 'multi', 84 | ] 85 | 86 | export const xhttpModeList = [ 87 | 'auto', 88 | 'packet-up', 89 | 'stream-up', 90 | 'stream-one', 91 | ] 92 | 93 | export const alpnList = [ 94 | 'http/1.1', 95 | 'h2', 96 | 'h2, http/1.1', 97 | 'h3', 98 | 'h3, h2', 99 | 'h3, h2, http/1.1', 100 | ] 101 | -------------------------------------------------------------------------------- /src/util/serverSpeed.ts: -------------------------------------------------------------------------------- 1 | import { checkPortAvailable, fetchTextContent, log, saveSpeedTestConf, startSpeedTestServer, stopSpeedTestServer } from "./invoke.ts" 2 | import { getRandom, sleep } from "./util.ts" 3 | import { getSpeedTestConf } from "./serverConf.ts" 4 | 5 | export async function generateServersPort(serverList: ServerList) { 6 | let port = getRandom(25000, 35000) 7 | let errNum = 0 8 | let servers = [] 9 | for (const server of serverList) { 10 | for (let i = 0; i < 100; i++) { 11 | const ok = await checkPortAvailable(port) 12 | if (ok) { 13 | break 14 | } else { 15 | errNum++ 16 | port++ 17 | } 18 | } 19 | servers.push({server, port}) 20 | port++ 21 | } 22 | if (errNum > 0) log.warn(`${errNum} ports are not available`) 23 | return servers 24 | } 25 | 26 | export async function generateServerPort() { 27 | let port = getRandom(25000, 35000) 28 | let errNum = 0 29 | for (let i = 0; i < 100; i++) { 30 | const ok = await checkPortAvailable(port) 31 | if (ok) { 32 | break 33 | } else { 34 | errNum++ 35 | port++ 36 | } 37 | } 38 | if (errNum > 0) { 39 | log.warn(`${errNum} ports are not available`) 40 | return 0 41 | } 42 | return port 43 | } 44 | 45 | export async function generateAndStartSpeedTestServer(server: ServerRow, appDir: string, rayConfig: RayCommonConfig, port: number) { 46 | const filename = server.host.replace(/[^\w.]/g, '_') + `-${server.id}.json` 47 | const conf = getSpeedTestConf(server, appDir, rayConfig, port) 48 | let ok = await saveSpeedTestConf(filename, conf) 49 | if (!ok) return false 50 | 51 | return await startSpeedTestServer(port, filename) 52 | } 53 | 54 | export async function serverSpeedTest(server: ServerRow, appDir: string, rayConfig: RayCommonConfig, port: number) { 55 | await generateAndStartSpeedTestServer(server, appDir, rayConfig, port) 56 | await sleep(500) 57 | 58 | // 目前测试 http 比 https 快 100-500ms 59 | // https://www.gstatic.com/generate_204 60 | // https://www.google.com/generate_204 61 | // https://cp.cloudflare.com/generate_204 62 | // https://captive.apple.com/hotspot-detect.htm 63 | // http://www.msftconnecttest.com/connecttest.txt 64 | const startTime = performance.now() 65 | const result = await fetchTextContent('http://www.gstatic.com/generate_204', `socks5://127.0.0.1:${port}`) 66 | const elapsed = Math.floor(performance.now() - startTime) 67 | 68 | await stopSpeedTestServer(port) 69 | return {result, elapsed} 70 | } 71 | -------------------------------------------------------------------------------- /src/util/subscription.ts: -------------------------------------------------------------------------------- 1 | import { fetchGet, log, readServerList, saveServerList } from "./invoke.ts" 2 | import { getNewServerList, getScy } from "./server.ts" 3 | import { hashJson } from "./crypto.ts" 4 | import { generateUniqueId } from "./util.ts" 5 | 6 | export async function getSubscription(row: SubscriptionRow) { 7 | const r = await fetchGet(row.url, row.isProxy) 8 | if (r && r.ok) { 9 | if (row.isHtml) { 10 | await parseHtml(r.body, row.name) 11 | } else { 12 | try { 13 | const obj = JSON.parse(r.body) 14 | await parseJson(obj, row.name) 15 | } catch (err) { 16 | log.error(`${row.name}, failed to subscription parseJson:`, err) 17 | } 18 | } 19 | } else { 20 | log.error('Failed to fetch subscription: ' + row.url) 21 | } 22 | } 23 | 24 | async function parseHtml(s: string, name: string) { 25 | const uriRegex = /(?:vmess|vless|ss|trojan):\/\/[^\s"'<>\\]+/g 26 | const matches = s.match(uriRegex) 27 | if (!matches) return 28 | 29 | const uniqueUris = [...new Set(matches)] 30 | const filteredUris = uniqueUris 31 | .map(uri => uri.replace(/&/ig, '&').trim()) 32 | .filter(uri => uri.length > 80) 33 | 34 | log.info(`Subscription "${name}": Found ${filteredUris.length} URIs, type: HTML`) 35 | if (filteredUris.length === 0) return 36 | 37 | const input = filteredUris.join('\n') 38 | const {newServerList, errNum, existNum, newNum} = await getNewServerList(input) 39 | 40 | log.info(`Updated "${name}": ${newNum} new, ${existNum} exist, ${errNum} errors`) 41 | 42 | if (newNum > 0) { 43 | const saved = await saveServerList(newServerList) 44 | if (!saved) { 45 | log.error(`Failed to save updated server list for "${name}"`) 46 | } 47 | } 48 | } 49 | 50 | async function parseJson(obj: any, name: string) { 51 | const servers = obj?.servers 52 | if (servers && Array.isArray(servers)) { 53 | log.info(`Subscription "${name}": Found ${servers.length} servers, type: JSON`) 54 | 55 | const {newServerList, errNum, existNum, newNum} = await getNewServerListBySub(servers) 56 | log.info(`Updated "${name}": ${newNum} new, ${existNum} exist, ${errNum} errors`) 57 | 58 | if (newNum > 0) { 59 | const ok = await saveServerList(newServerList) 60 | if (!ok) { 61 | log.error('Save ServerList Failed!') 62 | } 63 | } 64 | } else { 65 | log.info(`Update subscription not support JSON format: ${name}`) 66 | } 67 | } 68 | 69 | async function getNewServerListBySub(servers: any) { 70 | let errNum = 0 71 | let existNum = 0 72 | let newNum = 0 73 | let newServerList: ServerList = [] 74 | let serverList = await readServerList() || [] 75 | for (let server of servers) { 76 | const row = await subToServerRow(server) 77 | if (!row) { 78 | errNum++ 79 | continue 80 | } 81 | 82 | let isExist = serverList.some(v => v.hash === row.hash) 83 | if (isExist) { 84 | existNum++ 85 | continue 86 | } 87 | 88 | isExist = newServerList.some(v => v.hash === row.hash) 89 | if (isExist) { 90 | existNum++ 91 | continue 92 | } 93 | 94 | newNum++ 95 | newServerList.push(row) 96 | } 97 | 98 | newServerList = [...newServerList, ...serverList] 99 | return {newServerList, errNum, existNum, newNum} 100 | } 101 | 102 | async function subToServerRow(server: any): Promise { 103 | if (server.type === 'vmess') { 104 | return await subToVmessRow(server) 105 | } else if (server.type === 'vless') { 106 | return await subToVlessRow(server) 107 | } else if (server.type === 'ss') { 108 | return await subToSsRow(server) 109 | } else if (server.type === 'trojan') { 110 | return await subToTrojanRow(server) 111 | } else { 112 | log.error("Unsupported protocol, type:", server.type) 113 | return null 114 | } 115 | } 116 | 117 | async function subToVmessRow(server: any): Promise { 118 | let ps = server.name || '' 119 | let data: VmessRow = { 120 | add: server.server || '', 121 | port: Number(server.port) || '', 122 | id: server.uuid || server.id || '', 123 | aid: server.alterId || '0', 124 | 125 | net: server.network || 'raw', 126 | scy: server.cipher || 'auto', 127 | 128 | host: server?.["ws-opts"]?.host || '', 129 | path: server?.["ws-opts"]?.path || '', 130 | 131 | type: server.type || '', 132 | mode: server.mode || '', 133 | 134 | tls: Boolean(server.tls) || false, 135 | alpn: server.alpn || '', 136 | fp: server.fp || 'chrome' 137 | } 138 | 139 | if (data.net === 'tcp') data.net = 'raw' 140 | 141 | return { 142 | id: generateUniqueId(), 143 | ps, 144 | on: 0, 145 | type: 'vmess', 146 | host: `${data.add}:${data.port}`, 147 | scy: getScy(data), 148 | hash: await hashJson(data), 149 | data 150 | } 151 | } 152 | 153 | async function subToVlessRow(server: any): Promise { 154 | let ps = server.name || '' 155 | let data: VlessRow = { 156 | add: server.server || '', 157 | port: Number(server.port) || '', 158 | id: server.uuid || server.id || '', 159 | 160 | net: server.network || 'raw', 161 | scy: server.cipher || 'none', 162 | 163 | host: server.host || '', 164 | path: server.path || '', 165 | 166 | mode: server.mode || '', 167 | extra: server.extra || '', 168 | 169 | alpn: server.alpn || '', 170 | fp: server.fp || '', 171 | 172 | flow: server.flow || '', 173 | 174 | pbk: server.pbk || '', 175 | sid: server.sid || '', 176 | spx: server.spx || '' 177 | } 178 | 179 | if (data.net === 'tcp') data.net = 'raw' 180 | 181 | return { 182 | id: generateUniqueId(), 183 | ps: ps, 184 | on: 0, 185 | type: 'vless', 186 | host: `${data.add}:${data.port}`, 187 | scy: getScy(data), 188 | hash: await hashJson(data), 189 | data 190 | } 191 | } 192 | 193 | async function subToSsRow(server: any): Promise { 194 | let ps = server.name || '' 195 | let data: SsRow = { 196 | add: server.server || '', 197 | port: Number(server.port) || 0, 198 | pwd: server.password || '', 199 | scy: server.cipher || '', 200 | } 201 | 202 | return { 203 | id: generateUniqueId(), 204 | ps, 205 | on: 0, 206 | type: 'ss', 207 | host: `${data.add}:${data.port}`, 208 | scy: data.scy, 209 | hash: await hashJson(data), 210 | data 211 | } 212 | } 213 | 214 | async function subToTrojanRow(server: any): Promise { 215 | let ps = server.name || '' 216 | let data: TrojanRow = { 217 | add: server.server || '', 218 | port: Number(server.port) || 0, 219 | pwd: server.password || '', 220 | 221 | net: server.network || '', 222 | scy: 'tls', 223 | 224 | host: server.host || '', 225 | path: server.path || '', 226 | } 227 | 228 | return { 229 | id: generateUniqueId(), 230 | ps, 231 | on: 0, 232 | type: 'trojan', 233 | host: `${data.add}:${data.port}`, 234 | scy: getScy(data), 235 | hash: await hashJson(data), 236 | data 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/util/tauri.ts: -------------------------------------------------------------------------------- 1 | import { writeText, readText, readImage, writeImage } from '@tauri-apps/plugin-clipboard-manager' 2 | import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart' 3 | import { revealItemInDir, openUrl as openUrlTauri } from '@tauri-apps/plugin-opener' 4 | import { Image } from '@tauri-apps/api/image' 5 | import { save, SaveDialogOptions } from '@tauri-apps/plugin-dialog' 6 | import { getCurrentWindow } from '@tauri-apps/api/window' 7 | import { IS_TAURI, log } from "./invoke.ts" 8 | 9 | export async function hideWindow() { 10 | if (!IS_TAURI) return false 11 | try { 12 | await getCurrentWindow().hide() 13 | return true 14 | } catch (e) { 15 | log.error(`Tauri hide Window error: ${e}`) 16 | return false 17 | } 18 | } 19 | 20 | export async function showWindow() { 21 | if (!IS_TAURI) return false 22 | try { 23 | await getCurrentWindow().show() 24 | return true 25 | } catch (e) { 26 | log.error(`Tauri show Window error: ${e}`) 27 | return false 28 | } 29 | } 30 | 31 | export async function setFocusWindow() { 32 | if (!IS_TAURI) return false 33 | try { 34 | await getCurrentWindow().setFocus() 35 | return true 36 | } catch (e) { 37 | log.error(`Tauri setFocus Window error: ${e}`) 38 | return false 39 | } 40 | } 41 | 42 | export async function setAlwaysOnTopWindow(alwaysOnTop: boolean) { 43 | if (!IS_TAURI) return false 44 | try { 45 | await getCurrentWindow().setAlwaysOnTop(alwaysOnTop) 46 | return true 47 | } catch (e) { 48 | log.error(`Tauri setAlwaysOnTop Window error: ${e}`) 49 | return false 50 | } 51 | } 52 | 53 | export async function showAndFocusWindow() { 54 | if (!IS_TAURI) return false 55 | try { 56 | const window = getCurrentWindow() 57 | await window.show() 58 | await window.setFocus() 59 | // await window.setAlwaysOnTop(true) 60 | return true 61 | } catch (e) { 62 | log.error(`Tauri show Window error: ${e}`) 63 | return false 64 | } 65 | } 66 | 67 | export async function isVisibleWindow() { 68 | if (!IS_TAURI) return false 69 | try { 70 | return await getCurrentWindow().isVisible() 71 | } catch (e) { 72 | log.error(`Tauri isVisibleWindow error: ${e}`) 73 | return false 74 | } 75 | } 76 | 77 | export async function isFocusedWindow() { 78 | if (!IS_TAURI) return false 79 | try { 80 | return await getCurrentWindow().isFocused() 81 | } catch (e) { 82 | log.error(`Tauri isFocusedWindow error: ${e}`) 83 | return false 84 | } 85 | } 86 | 87 | export async function setThemeWindow(theme: 'light' | 'dark' | null) { 88 | if (!IS_TAURI) return false 89 | try { 90 | return await getCurrentWindow().setTheme(theme) 91 | } catch (e) { 92 | log.error(`Tauri setThemeWindow error: ${e}`) 93 | return false 94 | } 95 | } 96 | 97 | export async function showSaveDialog(options?: SaveDialogOptions) { 98 | if (!IS_TAURI) return false 99 | try { 100 | const path = await save(options) 101 | return path || '' 102 | } catch (e) { 103 | log.error(`Tauri save dialog error: ${e}`) 104 | return '' 105 | } 106 | } 107 | 108 | export async function createImage(rgba: number[] | Uint8Array | ArrayBuffer, width: number, height: number) { 109 | if (!IS_TAURI) return false 110 | try { 111 | return await Image.new(rgba, width, height) 112 | } catch (err) { 113 | log.error('Failed to createImage:', err) 114 | return false 115 | } 116 | } 117 | 118 | export async function clipboardWriteText(text: string) { 119 | if (!IS_TAURI) return false 120 | try { 121 | await writeText(text) 122 | return true 123 | } catch (err) { 124 | log.error('Failed to clipboardWriteText:', err) 125 | return false 126 | } 127 | } 128 | 129 | export async function clipboardWriteImage(image: string | Image | Uint8Array | ArrayBuffer | number[]) { 130 | if (!IS_TAURI) return false 131 | try { 132 | await writeImage(image) 133 | return true 134 | } catch (err) { 135 | log.error('Failed to clipboardWriteImage:', err) 136 | return false 137 | } 138 | } 139 | 140 | export async function clipboardReadText() { 141 | return await readText() 142 | } 143 | 144 | export async function clipboardReadImage() { 145 | return await readImage() 146 | } 147 | 148 | export async function isAutoStartEnabled() { 149 | if (!IS_TAURI) return false 150 | try { 151 | return await isEnabled() 152 | } catch (err) { 153 | log.error('Failed to isAutoStartEnabled:', err) 154 | return false 155 | } 156 | } 157 | 158 | export async function saveAutoStart(value: boolean) { 159 | if (!IS_TAURI) return false 160 | try { 161 | value ? await enable() : await disable() 162 | return true 163 | } catch (err) { 164 | log.error('Failed to setAutoStart:', err) 165 | return false 166 | } 167 | } 168 | 169 | export async function openDir(path: string) { 170 | if (!IS_TAURI) return false 171 | try { 172 | await revealItemInDir(path) 173 | return true 174 | } catch (err) { 175 | log.error('Failed to revealItemInDir:', err) 176 | return false 177 | } 178 | } 179 | 180 | export async function openUrl(path: string) { 181 | if (!IS_TAURI) return false 182 | try { 183 | await openUrlTauri(path) 184 | return true 185 | } catch (err) { 186 | log.error('Failed to openUrl:', err) 187 | return false 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/util/validate.ts: -------------------------------------------------------------------------------- 1 | import { isValidUUID, formatPort } from "./util.ts" 2 | 3 | export function validateServerField( 4 | name: string, 5 | value: any, 6 | setAddError: (error: boolean) => void, 7 | setPortError: (error: boolean) => void, 8 | setIdError: (error: boolean) => void, 9 | setIdNotUUID: (error: boolean) => void, 10 | setPwdError: (error: boolean) => void 11 | ): any { 12 | if (name === 'add') { 13 | setAddError(!value) 14 | } else if (name === 'port') { 15 | value = formatPort(value) 16 | setPortError(!value) 17 | } else if (name === 'id') { 18 | let err = !value 19 | let idNotUUID = false 20 | if (!err) { 21 | err = !isValidUUID(value) 22 | if (err) idNotUUID = true 23 | } 24 | setIdError(err) 25 | setIdNotUUID(idNotUUID) 26 | } else if (name === 'pwd') { 27 | setPwdError(!value) 28 | } 29 | return value 30 | } 31 | 32 | export function validateServerRow( 33 | data: VmessRow | VlessRow | SsRow | TrojanRow | null, 34 | ps: string, 35 | setPsError: (error: boolean) => void, 36 | setAddError: (error: boolean) => void, 37 | setPortError: (error: boolean) => void, 38 | setIdError: (error: boolean) => void, 39 | setPwdError: (error: boolean) => void 40 | ): boolean { 41 | if (!data) return false 42 | 43 | let err = false 44 | if (!ps) { 45 | setPsError(true) 46 | err = true 47 | } 48 | if ("add" in data && !data.add) { 49 | setAddError(true) 50 | err = true 51 | } 52 | if ("port" in data && !data.port) { 53 | setPortError(true) 54 | err = true 55 | } 56 | if ("id" in data && !data.id) { 57 | setIdError(true) 58 | err = true 59 | } 60 | if ("pwd" in data && !data.pwd) { 61 | setPwdError(true) 62 | err = true 63 | } 64 | 65 | return !err 66 | } 67 | -------------------------------------------------------------------------------- /src/view/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Paper, Stack, ToggleButtonGroup, ToggleButton } from '@mui/material' 3 | import GridViewIcon from '@mui/icons-material/GridView' 4 | import InsightsIcon from '@mui/icons-material/Insights' 5 | 6 | import HomeBase from "./HomeBase.tsx" 7 | import HomeRay from "./HomeRay.tsx" 8 | 9 | const Home: React.FC = ({setNavState}) => { 10 | useEffect(() => setNavState(0), [setNavState]) 11 | 12 | const [homeType, setHomeType] = useState('base') 13 | 14 | // const pSx = {p: 2, borderRadius: 2, width: '100%', height: `calc(100vh - 20px)`, overflow: 'auto', display: 'flex', justifyContent: 'center', alignItems: 'center'} 15 | const pSx = {p: 2, pt: 1, borderRadius: 2, width: '100%', height: `calc(100vh - 20px)`, overflow: 'auto'} 16 | 17 | return (<> 18 | 19 |
20 | v && setHomeType(v)}> 21 | 基本信息 22 | Xray 信息 23 | 24 |
25 | 26 | 27 | {homeType === 'base' ? ( 28 | 29 | ) : homeType === 'ray' && ( 30 | 31 | )} 32 | 33 |
34 | ) 35 | } 36 | 37 | export default Home 38 | -------------------------------------------------------------------------------- /src/view/Log.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | import { 4 | Card, Box, Button, Link, 5 | Table, TableBody, TableCell, TableContainer, TableHead, TableRow 6 | } from '@mui/material' 7 | import FolderOpenIcon from '@mui/icons-material/FolderOpen' 8 | import DeleteIcon from '@mui/icons-material/Delete' 9 | 10 | import { LoadingCard, ErrorCard } from "../component/useCard.tsx" 11 | import { useDialog } from "../component/useDialog.tsx" 12 | import { clearLogAll, getDoayAppDir, readLogList } from "../util/invoke.ts" 13 | import { sizeToUnit, formatLogName } from "../util/util.ts" 14 | import { openDir } from "../util/tauri.ts" 15 | import { useDebounce } from "../hook/useDebounce.ts" 16 | 17 | const Log: React.FC = ({setNavState}) => { 18 | useEffect(() => setNavState(4), [setNavState]) 19 | const navigate = useNavigate() 20 | 21 | const [logList, setLogList] = useState() 22 | const [errorMsg, setErrorMsg] = useState('') 23 | const loadList = useDebounce(async () => { 24 | let logList = await readLogList() 25 | if (logList) { 26 | setLogList(logList) 27 | } else { 28 | setLogList([]) 29 | setErrorMsg('暂无日志') 30 | } 31 | }, 100) 32 | useEffect(loadList, []) 33 | 34 | const handleClearLogs = () => { 35 | dialogConfirm('确认清空', `确定要清空所有日志吗?`, async () => { 36 | const ok = await clearLogAll() 37 | ok && loadList() 38 | }) 39 | } 40 | 41 | const handleOpenLogDir = async () => { 42 | let dir = await getDoayAppDir() 43 | if (dir) await openDir(`${dir}/logs/`) 44 | } 45 | 46 | const {DialogComponent, dialogConfirm} = useDialog() 47 | return (<> 48 | 49 | {!logList ? ( 50 | 51 | ) : errorMsg ? ( 52 | 53 | ) : (<> 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 日志名称 63 | 日志大小 64 | 最近更新 65 | 66 | 67 | 68 | {logList.map((row) => ( 69 | 70 | 71 | navigate(`/log_detail?filename=${row.filename}`)}> 72 | {formatLogName(row.filename)} 73 | 74 | 75 | {sizeToUnit(row.size)} 76 | {row.last_modified} 77 | 78 | ))} 79 | 80 |
81 |
82 | )} 83 | ) 84 | } 85 | 86 | export default Log 87 | -------------------------------------------------------------------------------- /src/view/LogDetail.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react' 2 | import { useNavigate, useSearchParams } from 'react-router-dom' 3 | import { FixedSizeList as List } from 'react-window' 4 | 5 | import { 6 | Stack, Paper, Button, Typography, 7 | FormControlLabel, Checkbox 8 | } from '@mui/material' 9 | import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew' 10 | 11 | import { useVisibility } from "../hook/useVisibility.ts" 12 | import { readLogFile } from '../util/invoke.ts' 13 | import { formatLogName } from "../util/util.ts" 14 | import highlightLog from '../util/highlightLog' 15 | import { useDebounce } from "../hook/useDebounce.ts" 16 | 17 | const LogDetail: React.FC = ({setNavState}) => { 18 | useEffect(() => setNavState(4), [setNavState]) 19 | 20 | const [searchParams] = useSearchParams() 21 | const filename = searchParams.get('filename') 22 | if (!filename) { 23 | return 未指定日志文件 24 | } 25 | 26 | const navigate = useNavigate() 27 | 28 | const [reverse, setReverse] = useState(true) 29 | const [autoRefresh, setAutoRefresh] = useState(true) 30 | const [startPosition, setStartPosition] = useState(-1) 31 | const [logList, setLogList] = useState(['日志加载中...']) 32 | 33 | const defaultPrevLogContent = {fileSize: 0, start: 0, end: 0, len: 0} 34 | const [prevLogContent, setPrevLogContent] = useState(defaultPrevLogContent) 35 | 36 | // 获取日志内容 37 | const startPositionCache = useRef([]) // 缓存 startPosition 读取过的位置,防止重复合并数据 38 | const htmlCache = useRef([]) // 缓存 html,按行存放 39 | const fetchLogContent = (reverse: boolean, startPosition: number) => { 40 | (async () => { 41 | setStartPosition(startPosition) 42 | const logData = await readLogFile(filename, reverse, startPosition) as LogContent 43 | if (!logData) return 44 | 45 | setPrevLogContent({fileSize: logData.size, start: logData.start, end: logData.end, len: logData.content.length}) 46 | 47 | // console.log('fileSize:', logData.size, 'start:', logData.start, 'end:', logData.end, 'len:', logData.content.length) 48 | // console.log('reverse:', reverse, 'autoRefresh:', autoRefresh, 'startPosition:', startPosition) 49 | 50 | const htmlLogContent = highlightLog(logData.content) 51 | if (!autoRefresh) { 52 | // 防止重复合并数据 53 | if (startPositionCache.current.includes(logData.start)) return 54 | startPositionCache.current.push(logData.start) 55 | 56 | if (reverse) { 57 | htmlCache.current = [...htmlLogContent, ...htmlCache.current] 58 | } else { 59 | htmlCache.current = [...htmlCache.current, ...htmlLogContent] 60 | } 61 | setLogList(htmlCache.current) 62 | } else { 63 | setLogList(htmlLogContent) // 自动刷新时,直接替换全部内容,简化程序逻辑 64 | } 65 | })() 66 | } 67 | 68 | // 监听 logList 变化,滚动到什么位置 69 | useEffect(() => { 70 | if (autoRefresh) scrollTo('bottom') 71 | else if (startPosition === -1) scrollTo(reverse ? 'bottom' : 'top') 72 | }, [logList]) 73 | 74 | // 初始加载 75 | useEffect(() => { 76 | if (!filename) return 77 | fetchLogContent(reverse, -1) 78 | }, []) 79 | 80 | // 自动刷新日志内容 81 | const intervalRef = useRef(0) 82 | const isVisibility = useVisibility() 83 | useEffect(() => { 84 | // 只有在查看日志文件末尾时,才开启自动刷新日志 85 | // 只在窗口可视时才自动刷新日志,以减小资源消耗 86 | if (reverse && isVisibility && autoRefresh && filename) { 87 | intervalRef.current = setInterval(() => { 88 | fetchLogContent(true, -1) 89 | }, 3000) 90 | } 91 | 92 | return () => clearInterval(intervalRef.current) 93 | }, [isVisibility, autoRefresh, filename]) 94 | 95 | const isScrollingRef = useRef(false) 96 | const scrollTo = (position: 'top' | 'bottom') => { 97 | if (isScrollingRef.current || !listRef.current) return 98 | isScrollingRef.current = true 99 | 100 | requestAnimationFrame(() => { 101 | if (position === 'top') { 102 | listRef.current!.scrollTo(0) 103 | } else { 104 | // listRef.current!.scrollTo(0, Infinity) 105 | listRef.current!.scrollToItem(logList.length - 1, 'end') 106 | } 107 | isScrollingRef.current = false 108 | }) 109 | } 110 | 111 | // 切换 reverse 方向,重新加载日志 112 | const handleReverseChange = (reverse: boolean) => { 113 | setReverse(reverse) 114 | 115 | // 重置 116 | startPositionCache.current = [] 117 | htmlCache.current = [] 118 | setPrevLogContent(defaultPrevLogContent) 119 | 120 | // 重新加载日志 121 | fetchLogContent(reverse, -1) 122 | } 123 | 124 | // 切换自动刷新状态 125 | const handleAutoRefreshChange = (autoRefresh: boolean) => { 126 | setAutoRefresh(autoRefresh) 127 | autoRefresh && setReverse(true) 128 | } 129 | 130 | // 防抖处理,减少滚动时频繁请求日志内容 131 | const debouncedScroll = useDebounce((isAtTop: boolean, isAtBottom: boolean) => { 132 | if (!isAtBottom) { 133 | clearInterval(intervalRef.current) // 停止自动刷新日志 134 | setAutoRefresh(false) 135 | } 136 | if (autoRefresh) return 137 | 138 | if (reverse) { 139 | // 向上滚动加载更多 140 | if (isAtTop) { 141 | if (prevLogContent.fileSize > 0 && prevLogContent.start === 0) { 142 | window.__SNACKBAR__.showSnackbar('已经到最顶部了', 'info', 2000, 'top-right') 143 | } else if (prevLogContent.start > 0) { 144 | fetchLogContent(reverse, prevLogContent.start - 1) 145 | } 146 | } 147 | } else { 148 | // 向下滚动加载更多 149 | if (isAtBottom) { 150 | if (prevLogContent.fileSize > 0 && prevLogContent.end === prevLogContent.fileSize) { 151 | window.__SNACKBAR__.showSnackbar('已经到最底部了', 'info', 2000, 'bottom-right') 152 | } else if (prevLogContent.end > 0) { 153 | fetchLogContent(reverse, prevLogContent.end + 1) 154 | } 155 | } 156 | } 157 | }, 300) 158 | 159 | const listRef = useRef(null) 160 | const handleListScroll = ({scrollOffset}: { scrollOffset: number }) => { 161 | if (!listRef.current) return 162 | 163 | const isAtTop = scrollOffset === 0 164 | const isAtBottom = scrollOffset + window.innerHeight - 75 >= listRef.current.props.itemSize * logList.length 165 | debouncedScroll(isAtTop, isAtBottom) 166 | } 167 | 168 | const [listHeight, setListHeight] = useState(window.innerHeight - 75) 169 | useEffect(() => { 170 | const handleResize = () => { 171 | setListHeight(window.innerHeight - 75) 172 | } 173 | window.addEventListener('resize', handleResize) 174 | return () => window.removeEventListener('resize', handleResize) 175 | }, []) 176 | 177 | const Row = ({index, style}: { index: number; style: React.CSSProperties }) => ( 178 |
179 |     )
180 | 
181 |     return (<>
182 |         
183 |             
184 |                 
188 |                 {formatLogName(filename, true)}
189 |             
190 |             
191 |                 {!autoRefresh && (
192 |                      handleReverseChange(e.target.checked)}/>}
194 |                         label="查看末尾"/>
195 |                 )}
196 |                  handleAutoRefreshChange(e.target.checked)}/>}
198 |                     label="自动刷新"/>
199 |             
200 |         
201 |         
202 |             
204 |                 {Row}
205 |             
206 |         
207 |     )
208 | }
209 | 
210 | export default LogDetail
211 | 


--------------------------------------------------------------------------------
/src/view/ServerImport.tsx:
--------------------------------------------------------------------------------
  1 | import React, { useEffect, useState, useRef } from 'react'
  2 | import { useNavigate } from 'react-router-dom'
  3 | import { Html5Qrcode } from 'html5-qrcode'
  4 | import { Box, Card, TextField, Button, Stack, Dialog, DialogContent, DialogActions } from '@mui/material'
  5 | import { useDebounce } from '../hook/useDebounce.ts'
  6 | import { PageHeader } from "../component/PageHeader.tsx"
  7 | import { useServerImport } from "../component/useServerImport.tsx"
  8 | import { clipboardReadImage } from "../util/tauri.ts"
  9 | 
 10 | const ServerImport: React.FC = ({setNavState}) => {
 11 |     useEffect(() => setNavState(1), [setNavState])
 12 |     const navigate = useNavigate()
 13 | 
 14 |     // =============== text import ===============
 15 |     const [text, setText] = useState('')
 16 |     const [error, setError] = useState(false)
 17 |     const handleTextChange = (value: string) => {
 18 |         setError(false)
 19 |         setText(value)
 20 |     }
 21 |     const handleSubmit = useDebounce(async () => {
 22 |         await useServerImport(text, window.__SNACKBAR__.showSnackbar, setError, () => {
 23 |             setTimeout(() => navigate('/server'), 1000)
 24 |         })
 25 |     }, 300)
 26 | 
 27 |     // =============== file import ===============
 28 |     const fileInputRef = useRef(null)
 29 |     const handleFileChange = async (event: React.ChangeEvent) => {
 30 |         setError(false)
 31 |         const files = event.target.files
 32 |         if (!files || files.length < 1) return
 33 | 
 34 |         let ok = 0
 35 |         let err = 0
 36 |         let s = ''
 37 |         for (const file of files) {
 38 |             if (!file.type.startsWith('image/')) return
 39 |             try {
 40 |                 const html5Qr = new Html5Qrcode("hidden-reader")
 41 |                 const decodedText = await html5Qr.scanFile(file, true)
 42 |                 s += decodedText + '\n'
 43 |                 ok++
 44 |             } catch {
 45 |                 err++
 46 |             }
 47 |         }
 48 | 
 49 |         setText(s)
 50 |         if (err > 0) window.__SNACKBAR__.showSnackbar(`识别失败 ${err} 张, 成功 ${ok} 张`, 'warning')
 51 |         event.target.value = ''
 52 |     }
 53 | 
 54 |     // =============== clipboard import ===============
 55 |     const handleReadClipboard = async () => {
 56 |         try {
 57 |             const image = await clipboardReadImage()
 58 |             const imgRgba = await image.rgba()
 59 |             const imgSize = await image.size()
 60 |             const file = await createImageFromRGBA(imgRgba, imgSize.width, imgSize.height)
 61 |             if (!file) {
 62 |                 window.__SNACKBAR__.showSnackbar('转 canvas 出错', 'error')
 63 |                 return
 64 |             }
 65 | 
 66 |             try {
 67 |                 const html5Qr = new Html5Qrcode("hidden-reader")
 68 |                 const r = await html5Qr.scanFile(file, true)
 69 |                 setText(r)
 70 |             } catch {
 71 |                 window.__SNACKBAR__.showSnackbar('没有识别到内容', 'error')
 72 |             }
 73 |         } catch {
 74 |             window.__SNACKBAR__.showSnackbar('没有从剪切板读取到内容', 'error')
 75 |         }
 76 |     }
 77 | 
 78 |     const createImageFromRGBA = (rgbaData: Uint8Array, width: number, height: number): Promise => {
 79 |         return new Promise((resolve) => {
 80 |             const canvas = document.createElement('canvas')
 81 |             const ctx = canvas.getContext('2d')
 82 |             if (!ctx) return resolve(null)
 83 | 
 84 |             canvas.width = width
 85 |             canvas.height = height
 86 |             const imageData = new ImageData(new Uint8ClampedArray(rgbaData), width, height)
 87 |             ctx.putImageData(imageData, 0, 0)
 88 | 
 89 |             canvas.toBlob((blob) => {
 90 |                 if (!blob) return resolve(null)
 91 | 
 92 |                 const file = new File([blob], 'clipboard.png', {type: 'image/png'})
 93 |                 resolve(file)
 94 |             }, 'image/png')
 95 |         })
 96 |     }
 97 | 
 98 |     // =============== camera import ===============
 99 |     const [open, setOpen] = useState(false)
100 |     const [cameraState, setCameraState] = useState(-1)
101 |     const scannerRef = useRef(null)
102 |     const handleStopCamera = async () => {
103 |         setOpen(false)
104 |         if (scannerRef.current) {
105 |             try {
106 |                 await scannerRef.current.stop()
107 |                 scannerRef.current.clear()
108 |             } catch (e) {
109 |             }
110 |         }
111 |     }
112 | 
113 |     const handleStartCamera = async () => {
114 |         setOpen(true)
115 |         setCameraState(-1)
116 |         try {
117 |             const devices = await Html5Qrcode.getCameras()
118 |             if (!devices || devices.length < 1) {
119 |                 setCameraState(0)
120 |                 return
121 |             }
122 | 
123 |             setCameraState(1)
124 |             setTimeout(() => startCamera(devices[0].id), 200)
125 |         } catch (e) {
126 |             setCameraState(0)
127 |             return
128 |         }
129 |     }
130 | 
131 |     const startCamera = async (cameraId: string) => {
132 |         try {
133 |             const qrScanner = new Html5Qrcode('camera-reader')
134 |             scannerRef.current = qrScanner
135 |             await qrScanner.start(
136 |                 cameraId,
137 |                 {fps: 10, qrbox: 250},
138 |                 (decodedText) => {
139 |                     setText(decodedText)
140 |                     handleStopCamera()
141 |                 },
142 |                 (_) => {
143 |                 }
144 |             )
145 |         } catch {
146 |             setCameraState(-2)
147 |         }
148 |     }
149 | 
150 |     return (<>
151 |         
152 | 153 | 154 | {cameraState === -1 ? ( 155 |
正在检测摄像头
156 | ) : cameraState === 0 ? ( 157 |
未检测到摄像头,请检查设备或权限
158 | ) : cameraState === 1 ? ( 159 |
160 | ) : cameraState === -2 && ( 161 |
启用摄像头失败
162 | )} 163 | 164 | 165 | 166 | 167 |
168 | 169 | 170 | 171 | 172 | 176 | 177 | 178 | 179 | handleTextChange(e.target.value)}/> 182 | 183 | 184 | 185 | 186 | 187 | ) 188 | } 189 | 190 | export default ServerImport 191 | -------------------------------------------------------------------------------- /src/view/ServerUpdate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useNavigate, useSearchParams } from 'react-router-dom' 3 | import { ToggleButtonGroup, ToggleButton, Card, TextField, Button, Grid } from '@mui/material' 4 | 5 | import { PageHeader } from "../component/PageHeader.tsx" 6 | import { validateServerField, validateServerRow } from "../util/validate.ts" 7 | import { hashJson } from "../util/crypto.ts" 8 | import { readServerList, saveServerList } from "../util/invoke.ts" 9 | import { getScy } from "../util/server.ts" 10 | import { LoadingCard, ErrorCard } from "../component/useCard.tsx" 11 | import { VmessForm } from './server/VmessForm.tsx' 12 | import { VlessForm } from './server/VlessForm.tsx' 13 | import { SsForm } from './server/SsForm.tsx' 14 | import { TrojanForm } from './server/TrojanForm.tsx' 15 | import { useDebounce } from "../hook/useDebounce.ts" 16 | 17 | const ServerUpdate: React.FC = ({setNavState}) => { 18 | useEffect(() => setNavState(1), [setNavState]) 19 | 20 | const navigate = useNavigate() 21 | const [searchParams] = useSearchParams() 22 | const key = Number(searchParams.get('key')) 23 | 24 | const [serverList, setServerList] = useState() 25 | const [serverRow, setServerRow] = useState() 26 | const [serverType, setServerType] = useState('') 27 | const [ps, setPs] = useState('') 28 | 29 | const [vmessForm, setVmessForm] = useState() 30 | const [vlessForm, setVlessForm] = useState() 31 | const [ssForm, setSsForm] = useState() 32 | const [trojanForm, setTrojanForm] = useState() 33 | const [errorMsg, setErrorMsg] = useState('') 34 | 35 | const loadList = useDebounce(async () => { 36 | let serverList = await readServerList() 37 | if (serverList) { 38 | setServerList(serverList) 39 | let row = serverList[key] 40 | if (row) { 41 | setServerRow(row) 42 | setServerType(row.type) 43 | setPs(row.ps) 44 | 45 | if (row.type === 'vmess') { 46 | setVmessForm(row.data as VmessRow) 47 | } else if (row.type === 'vless') { 48 | setVlessForm(row.data as VlessRow) 49 | } else if (row.type === 'ss') { 50 | setSsForm(row.data as SsRow) 51 | } else if (row.type === 'trojan') { 52 | setTrojanForm(row.data as TrojanRow) 53 | } 54 | } else { 55 | setErrorMsg('服务器不存在') 56 | } 57 | } else { 58 | setServerList([]) 59 | setErrorMsg('暂无服务器') 60 | } 61 | }, 100) 62 | useEffect(loadList, []) 63 | 64 | const [psError, setPsError] = useState(false) 65 | const [addError, setAddError] = useState(false) 66 | const [portError, setPortError] = useState(false) 67 | const [idError, setIdError] = useState(false) 68 | const [idNotUUID, setIdNotUUID] = useState(false) 69 | const [pwdError, setPwdError] = useState(false) 70 | 71 | const handleChange = (e: React.ChangeEvent) => { 72 | setFormData(e.target.name, e.target.value) 73 | } 74 | 75 | const setFormData = (name: string, value: any) => { 76 | name = name.trim() 77 | if (typeof value === 'string') value = value.trim() 78 | // console.log('setFormData', name, value) 79 | 80 | value = validateServerField(name, value, setAddError, setPortError, setIdError, setIdNotUUID, setPwdError) 81 | if (serverType === 'vmess') { 82 | vmessForm && setVmessForm({...vmessForm, [name]: value}) 83 | } else if (serverType === 'vless') { 84 | vlessForm && setVlessForm({...vlessForm, [name]: value}) 85 | } else if (serverType === 'ss') { 86 | ssForm && setSsForm({...ssForm, [name]: value}) 87 | } else if (serverType === 'trojan') { 88 | trojanForm && setTrojanForm({...trojanForm, [name]: value}) 89 | } 90 | } 91 | 92 | const handleSubmit = async () => { 93 | let data: VmessRow | VlessRow | SsRow | TrojanRow | null = null 94 | if (serverType === 'vmess') { 95 | if (vmessForm) data = vmessForm 96 | } else if (serverType === 'vless') { 97 | if (vlessForm) data = vlessForm 98 | } else if (serverType === 'ss') { 99 | if (ssForm) data = ssForm 100 | } else if (serverType === 'trojan') { 101 | if (trojanForm) data = trojanForm 102 | } 103 | if (!data) return 104 | 105 | const isValid = validateServerRow(data, ps, setPsError, setAddError, setPortError, setIdError, setPwdError) 106 | if (!isValid) return 107 | 108 | data.port = Number(data.port) 109 | 110 | let netServerList = serverList ? [...serverList] : [] 111 | if (netServerList[key] && serverRow) { 112 | const newServer: ServerRow = { 113 | id: serverRow.id, 114 | ps: ps, 115 | on: serverRow.on, 116 | type: serverType, 117 | host: `${data.add}:${data.port}`, 118 | scy: getScy(data), 119 | hash: await hashJson(data), 120 | data 121 | } 122 | 123 | // 排重 124 | const existKey = netServerList.findIndex((server, i) => server.hash === newServer.hash && i !== key) 125 | if (existKey !== -1) { 126 | window.__SNACKBAR__.showSnackbar('修改的服务器内容已存在', 'error') 127 | return 128 | } 129 | 130 | netServerList.splice(key, 1) 131 | netServerList.unshift(newServer) 132 | const ok = await saveServerList(netServerList) 133 | if (!ok) { 134 | window.__SNACKBAR__.showSnackbar('修改失败', 'error') 135 | } else { 136 | setTimeout(() => navigate(`/server`), 100) 137 | } 138 | } 139 | } 140 | 141 | return !serverList ? ( 142 | 143 | ) : errorMsg ? ( 144 | 145 | ) : (<> 146 | 147 | 148 | 149 | 150 | 151 | Vmess 152 | Vless 153 | Shadowsocks 154 | Trojan 155 | 156 | 157 | 158 | { 163 | let v = e.target.value.trim() 164 | setPsError(!v) 165 | setPs(v) 166 | }}/> 167 | 168 | 169 | {serverType === 'vmess' && vmessForm ? ( 170 | 176 | ) : serverType === 'vless' && vlessForm ? ( 177 | 183 | ) : serverType === 'ss' && ssForm ? ( 184 | 190 | ) : serverType === 'trojan' && trojanForm && ( 191 | 197 | )} 198 | 199 | 200 | 201 | 202 | 203 | 204 | ) 205 | } 206 | 207 | export default ServerUpdate 208 | -------------------------------------------------------------------------------- /src/view/Setting.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, FC } from 'react' 2 | import { Paper, Box, Tabs, Tab } from '@mui/material' 3 | 4 | import SettingBase from "./SettingBase.tsx" 5 | import SettingProxy from "./SettingProxy.tsx" 6 | import SettingRay from "./SettingRay.tsx" 7 | import SettingWeb from "./SettingWeb.tsx" 8 | 9 | const Setting: FC = ({setNavState}) => { 10 | useEffect(() => setNavState(6), [setNavState]) 11 | 12 | const [activeTab, setActiveTab] = useState(0) 13 | 14 | return ( 15 | 16 | 17 | setActiveTab(newValue)} aria-label="设置导航"> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {activeTab === 0 ? ( 28 | 29 | ) : activeTab === 1 ? ( 30 | 31 | ) : activeTab === 2 ? ( 32 | 33 | ) : activeTab === 3 && ( 34 | 35 | )} 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | export default Setting 43 | -------------------------------------------------------------------------------- /src/view/SettingBase.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { 3 | Card, Divider, Typography, Switch, Button, ButtonGroup, 4 | FormControl, Select, MenuItem, SelectChangeEvent 5 | } from '@mui/material' 6 | 7 | import { useTheme } from '../context/ThemeProvider.tsx' 8 | import { readAppConfig, saveAppConfig } from '../util/invoke.ts' 9 | import { DEFAULT_APP_CONFIG } from "../util/config.ts" 10 | import { isAutoStartEnabled, saveAutoStart } from "../util/tauri.ts" 11 | import { useDebounce } from "../hook/useDebounce.ts" 12 | 13 | export default () => { 14 | const {mode, toggleMode} = useTheme() 15 | const handleTheme = (newMode: 'light' | 'dark' | 'system') => { 16 | toggleMode(newMode as 'light' | 'dark' | 'system') 17 | } 18 | 19 | const [autoStart, setAutoStart] = useState(false) 20 | const [config, setConfig] = useState(DEFAULT_APP_CONFIG) 21 | const loadConfig = useDebounce(async () => { 22 | const newConfig = await readAppConfig() 23 | if (newConfig) setConfig({...DEFAULT_APP_CONFIG, ...newConfig}) 24 | 25 | setAutoStart(await isAutoStartEnabled()) 26 | }, 100) 27 | useEffect(loadConfig, []) 28 | 29 | const handleAutoStart = async (value: boolean) => { 30 | setAutoStart(value) 31 | await saveAutoStart(value) 32 | } 33 | 34 | const handleAppLogLevel = async (event: SelectChangeEvent) => { 35 | const value = event.target.value as AppConfig['app_log_level'] 36 | setConfig(prevConfig => ({...prevConfig, app_log_level: value})) 37 | window.__APP_LOG_LEVEL__ = value 38 | await saveAppConfig('set_app_log_level', value) 39 | } 40 | 41 | return ( 42 | 43 |
44 | 外观 45 | 46 | 47 | 48 | 49 | 50 |
51 | 52 |
53 | 开机启动 54 | handleAutoStart(e.target.checked)}/> 55 |
56 | 57 |
58 | 日志级别 59 | 60 | 68 | 69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/view/SettingProxy.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Card, Divider, ListItem, ListItemButton, Typography, Switch, Tooltip } from '@mui/material' 3 | import HelpIcon from '@mui/icons-material/Help' 4 | 5 | import { readAppConfig, saveAppConfig } from '../util/invoke.ts' 6 | import { DEFAULT_APP_CONFIG } from "../util/config.ts" 7 | import { reloadProxyPAC } from "../util/proxy.ts" 8 | import { useDebounce } from "../hook/useDebounce.ts" 9 | import { IS_WINDOWS } from "../util/util.ts" 10 | 11 | export default () => { 12 | const [config, setConfig] = useState(DEFAULT_APP_CONFIG) 13 | const loadConfig = useDebounce(async () => { 14 | const newConfig = await readAppConfig() 15 | if (newConfig) setConfig({...DEFAULT_APP_CONFIG, ...newConfig}) 16 | }, 100) 17 | useEffect(loadConfig, []) 18 | 19 | const handleAutoSetupPac = async (value: boolean) => { 20 | setConfig(prevConfig => ({...prevConfig, auto_setup_pac: value})) 21 | await saveAppConfig('set_auto_setup_pac', value) 22 | 23 | // 开启 PAC 自动配置时,关闭其他配置,避免影响 PAC 规则 24 | if (value) { 25 | setConfig(prevConfig => ({ 26 | ...prevConfig, 27 | auto_setup_socks: false, 28 | auto_setup_http: false, 29 | auto_setup_https: false, 30 | })) 31 | 32 | setTimeout(reloadProxyPAC, 300) 33 | } 34 | } 35 | 36 | const handleAutoSetupSocks = async (value: boolean) => { 37 | setConfig(prevConfig => ({...prevConfig, auto_setup_socks: value})) 38 | 39 | // Windows 系统下,只允许开启一个代理设置 40 | if (IS_WINDOWS && value) { 41 | setConfig(prevConfig => ({ 42 | ...prevConfig, 43 | auto_setup_pac: false, 44 | auto_setup_http: false, 45 | })) 46 | 47 | if (config.auto_setup_pac) await saveAppConfig('set_auto_setup_pac', false) 48 | if (config.auto_setup_http) await saveAppConfig('set_auto_setup_http', false) 49 | } 50 | 51 | await saveAppConfig('set_auto_setup_socks', value) 52 | } 53 | 54 | const handleAutoSetupHttp = async (value: boolean) => { 55 | setConfig(prevConfig => ({...prevConfig, auto_setup_http: value})) 56 | 57 | // Windows 系统下,只允许开启一个代理设置 58 | if (IS_WINDOWS && value) { 59 | setConfig(prevConfig => ({ 60 | ...prevConfig, 61 | auto_setup_pac: false, 62 | auto_setup_socks: false, 63 | })) 64 | 65 | if (config.auto_setup_pac) await saveAppConfig('set_auto_setup_pac', false) 66 | if (config.auto_setup_socks) await saveAppConfig('set_auto_setup_socks', false) 67 | } 68 | 69 | await saveAppConfig('set_auto_setup_http', value) 70 | } 71 | 72 | const handleAutoSetupHttps = async (value: boolean) => { 73 | setConfig(prevConfig => ({...prevConfig, auto_setup_https: value})) 74 | await saveAppConfig('set_auto_setup_https', value) 75 | } 76 | 77 | return ( 78 | 79 | 80 |
81 | 系统代理 82 | 83 | 84 | 85 |
86 |
87 | 88 | 89 | 90 |
91 | PAC 自动配置代理 92 | handleAutoSetupPac(e.target.checked)}/> 93 |
94 |
95 |
96 | 97 | 98 |
99 | SOCKS 代理 100 | handleAutoSetupSocks(e.target.checked)}/> 101 |
102 |
103 |
104 | 105 | 106 |
107 | HTTP 代理 108 | handleAutoSetupHttp(e.target.checked)}/> 109 |
110 |
111 |
112 | {!IS_WINDOWS && ( 113 | 114 | 115 |
116 | HTTPS 代理 117 | handleAutoSetupHttps(e.target.checked)}/> 118 |
119 |
120 |
121 | )} 122 |
123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /src/view/SettingWeb.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { 3 | Card, Button, Divider, Stack, Switch, TextField, Typography, 4 | } from '@mui/material' 5 | import FolderOpenIcon from '@mui/icons-material/FolderOpen' 6 | import OpenInNewIcon from '@mui/icons-material/OpenInNew' 7 | 8 | import { validateIp, validatePort } from '../util/util.ts' 9 | import { checkPortAvailable, readAppConfig, saveAppConfig, openWebServerDir } from '../util/invoke.ts' 10 | import { DEFAULT_APP_CONFIG } from "../util/config.ts" 11 | import { useDebounce } from "../hook/useDebounce.ts" 12 | 13 | export default () => { 14 | const [config, setConfig] = useState(DEFAULT_APP_CONFIG) 15 | const loadConfig = useDebounce(async () => { 16 | const newConfig = await readAppConfig() 17 | if (newConfig) setConfig({...DEFAULT_APP_CONFIG, ...newConfig}) 18 | }, 100) 19 | useEffect(loadConfig, []) 20 | 21 | const [webIpError, setWebIpError] = useState(false) 22 | const [webPortError, setWebPortError] = useState(false) 23 | const [webPortErrorText, setWebPortErrorText] = useState('') 24 | 25 | const handleWebServerEnable = async (value: boolean) => { 26 | setConfig(prevConfig => ({...prevConfig, web_server_enable: value})) 27 | await saveAppConfig('set_web_server_enable', value) 28 | } 29 | 30 | const setWebServerHostDebounce = useDebounce(async (value: string) => { 31 | const c = await readAppConfig() 32 | if (c?.web_server_host !== value) { 33 | setConfig(prevConfig => ({...prevConfig, web_server_host: value})) 34 | await saveAppConfig('set_web_server_host', value) 35 | } 36 | }, 1000) 37 | const handleWebIp = (event: React.ChangeEvent) => { 38 | const value = event.target.value.trim() 39 | setConfig(prevConfig => ({...prevConfig, web_server_host: value})) 40 | const ok = validateIp(value) 41 | setWebIpError(!ok) 42 | if (ok) setWebServerHostDebounce(value) 43 | } 44 | 45 | const setWebServerPortDebounce = useDebounce(async (value: number) => { 46 | const c = await readAppConfig() 47 | if (c?.web_server_port !== value) { 48 | const ok = await checkPortAvailable(value) 49 | setWebPortError(!ok) 50 | !ok && setWebPortErrorText('本机端口不可用') 51 | if (ok) { 52 | setWebPortErrorText('') 53 | setConfig(prevConfig => ({...prevConfig, web_server_port: value})) 54 | await saveAppConfig('set_web_server_port', value) 55 | } 56 | } 57 | }, 1500) 58 | const handleWebPort = (event: React.ChangeEvent) => { 59 | const value = Number(event.target.value) || 0 60 | setConfig(prevConfig => ({...prevConfig, web_server_port: value || ""})) 61 | setWebPortErrorText('') 62 | const ok = validatePort(value) 63 | setWebPortError(!ok) 64 | !ok && setWebPortErrorText('请输入有效的端口号 (1-65535)') 65 | if (ok) setWebServerPortDebounce(value) 66 | } 67 | 68 | return ( 69 | 70 |
71 | Web 服务 72 | handleWebServerEnable(e.target.checked)}/> 73 |
74 | 75 | 76 | 84 | 92 | 93 | 94 | 95 | 97 | 98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/view/Tool.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Box, Card, Paper, ToggleButtonGroup, ToggleButton } from '@mui/material' 3 | import TerminalIcon from '@mui/icons-material/Terminal' 4 | import WysiwygIcon from '@mui/icons-material/Wysiwyg' 5 | import HttpIcon from '@mui/icons-material/Http' 6 | import SpeedIcon from '@mui/icons-material/Speed' 7 | import RadarIcon from '@mui/icons-material/Radar' 8 | 9 | import SysInfo from "./SysInfo.tsx" 10 | import TerminalCmd from "./TerminalCmd.tsx" 11 | import HttpTest from "./HttpTest.tsx" 12 | import SpeedTest from "./SpeedTest.tsx" 13 | import ScanPorts from "./ScanPorts.tsx" 14 | 15 | const Tool: React.FC = ({setNavState}) => { 16 | useEffect(() => setNavState(5), [setNavState]) 17 | 18 | const [action, setAction] = useState('system') 19 | 20 | return ( 21 | 22 |
23 | v && setAction(v)}> 24 | 系统信息 25 | 终端命令 26 | 请求测试 27 | 网速测试 28 | 端口扫描 29 | 30 |
31 | 32 | 33 | 34 | {action === 'system' && ()} 35 | {action === 'term' && ()} 36 | {action === 'http' && ()} 37 | {action === 'speed' && ()} 38 | {action === 'scan' && ()} 39 | 40 | 41 |
42 | ) 43 | } 44 | 45 | export default Tool 46 | -------------------------------------------------------------------------------- /src/view/server/SsForm.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material' 2 | import { TextField } from '@mui/material' 3 | import { AutoCompleteField } from '../../component/AutoCompleteField.tsx' 4 | import { PasswordInput } from '../../component/PasswordInput.tsx' 5 | import { ssMethodList } from "../../util/serverOption.ts" 6 | 7 | interface SsFormProps { 8 | form: SsRow 9 | errors: { 10 | addError: boolean 11 | portError: boolean 12 | pwdError: boolean 13 | } 14 | handleChange: (e: React.ChangeEvent) => void 15 | setFormData: (name: string, value: any) => void 16 | } 17 | 18 | export const SsForm = ({form, errors, handleChange, setFormData}: SsFormProps) => { 19 | return (<> 20 | 21 | 25 | 26 | 27 | 31 | 32 | 33 | { 37 | setFormData('pwd', value) 38 | }}/> 39 | 40 | 41 | setFormData('scy', value)}/> 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/view/server/TrojanForm.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material' 2 | import { TextField } from '@mui/material' 3 | import { SelectField } from '../../component/SelectField.tsx' 4 | import { trojanNetworkTypeList } from "../../util/serverOption.ts" 5 | 6 | interface TrojanFormProps { 7 | form: TrojanRow 8 | errors: { 9 | addError: boolean 10 | portError: boolean 11 | pwdError: boolean 12 | } 13 | handleChange: (e: React.ChangeEvent) => void 14 | setFormData: (name: string, value: any) => void 15 | } 16 | 17 | export const TrojanForm = ({form, errors, handleChange, setFormData}: TrojanFormProps) => { 18 | return (<> 19 | 20 | 24 | 25 | 26 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | setFormData('net', value)}/> 42 | 43 | 44 | 45 | 46 | 47 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/view/server/VlessForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Grid } from '@mui/material' 3 | import { TextField, Stack, Button } from '@mui/material' 4 | import { SelectField } from '../../component/SelectField.tsx' 5 | import { AutoCompleteField } from '../../component/AutoCompleteField.tsx' 6 | import { 7 | vlessNetworkTypeList, vlessSecurityList, 8 | grpcModeList, xhttpModeList, alpnList, fingerprintList, flowList 9 | } from "../../util/serverOption.ts" 10 | import { generateUUID } from "../../util/util.ts" 11 | 12 | interface VlessFormProps { 13 | form: VlessRow 14 | errors: { 15 | addError: boolean 16 | portError: boolean 17 | idError: boolean 18 | idNotUUID: boolean 19 | } 20 | handleChange: (e: React.ChangeEvent) => void 21 | setFormData: (name: string, value: any) => void 22 | } 23 | 24 | export const VlessForm = ({form, errors, handleChange, setFormData}: VlessFormProps) => { 25 | return (<> 26 | 27 | 31 | 32 | 33 | 37 | 38 | 39 | 40 | setFormData('id', e.target.value)} 48 | /> 49 | 55 | 56 | 57 | 58 | 59 | setFormData('net', value)}/> 62 | 63 | 64 | setFormData('scy', value)}/> 67 | 68 | 69 | {(form.net !== 'raw' || form.scy === 'reality') && (<> 70 | 71 | 72 | 73 | )} 74 | 75 | {(form.net === 'ws' || form.net === 'xhttp') && ( 76 | 77 | 78 | 79 | )} 80 | {(form.net === 'grpc' || form.scy === 'reality') && ( 81 | 82 | 83 | 84 | )} 85 | 86 | {form.net === 'grpc' && (<> 87 | 88 | setFormData('mode', value)}/> 91 | 92 | )} 93 | 94 | {form.net === 'xhttp' && (<> 95 | 96 | setFormData('mode', value)}/> 99 | 100 | 101 | 102 | 103 | )} 104 | 105 | {form.scy !== 'none' && (<> 106 | 107 | setFormData('alpn', value)}/> 110 | 111 | 112 | setFormData('fp', value)}/> 115 | 116 | 117 | setFormData('flow', value)}/> 120 | 121 | )} 122 | 123 | {form.scy === 'reality' && (<> 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | )} 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /src/view/server/VmessForm.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Stack, Typography, Switch, TextField, Button } from '@mui/material' 2 | import { SelectField } from '../../component/SelectField.tsx' 3 | import { AutoCompleteField } from '../../component/AutoCompleteField.tsx' 4 | import { 5 | vmessNetworkTypeList, vmessSecurityList, 6 | kcpHeaderTypeList, rawHeaderTypeList, grpcModeList, alpnList, fingerprintList 7 | } from "../../util/serverOption.ts" 8 | import { generateUUID } from "../../util/util.ts" 9 | 10 | interface VmessFormProps { 11 | form: VmessRow 12 | errors: { 13 | addError: boolean 14 | portError: boolean 15 | idError: boolean 16 | idNotUUID: boolean 17 | } 18 | handleChange: (e: React.ChangeEvent) => void 19 | setFormData: (name: string, value: any) => void 20 | } 21 | 22 | export const VmessForm = ({form, errors, handleChange, setFormData}: VmessFormProps) => { 23 | return (<> 24 | 25 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | setFormData('id', e.target.value)} 46 | /> 47 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | setFormData('net', value)}/> 63 | 64 | 65 | setFormData('scy', value)}/> 68 | 69 | 70 | 71 | {form.net !== 'raw' && (<> 72 | 73 | 74 | 75 | 76 | 79 | 80 | )} 81 | 82 | {['raw', 'kcp'].includes(form.net) && ( 83 | 84 | setFormData('type', value)}/> 88 | 89 | )} 90 | 91 | {form.net === 'grpc' && (<> 92 | 93 | setFormData('mode', value)}/> 96 | 97 | )} 98 | 99 | 100 | 101 | 102 | TLS 安全协议 103 | setFormData('tls', e.target.checked)}/> 104 | 105 | 106 | {form.tls && (<> 107 | 108 | setFormData('alpn', value)}/> 110 | 111 | 112 | setFormData('fp', value)}/> 115 | 116 | )} 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // @ts-expect-error process is a nodejs global 5 | const host = process.env.TAURI_DEV_HOST 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(async () => ({ 9 | plugins: [react()], 10 | 11 | optimizeDeps: { 12 | include: ['@uiw/react-codemirror'], 13 | }, 14 | 15 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 16 | // 17 | // 1. prevent vite from obscuring rust errors 18 | clearScreen: false, 19 | // 2. tauri expects a fixed port, fail if that port is not available 20 | server: { 21 | port: 1420, 22 | strictPort: true, 23 | host: host || false, 24 | hmr: host 25 | ? { 26 | protocol: "ws", 27 | host, 28 | port: 1421, 29 | } 30 | : undefined, 31 | watch: { 32 | // 3. tell vite to ignore watching `src-tauri` 33 | ignored: ["**/src-tauri/**"], 34 | }, 35 | }, 36 | build: { 37 | emptyOutDir: true, 38 | rollupOptions: { 39 | output: { 40 | manualChunks: { 41 | // vendor: ['react', 'react-dom'], 42 | h5qr: ['html5-qrcode'], 43 | mui: ['@mui/material'], 44 | icons: ['@mui/icons-material'], 45 | charts: ['@mui/x-charts'], 46 | codemirror: ['@uiw/react-codemirror'], 47 | 'code-lang': ['@codemirror/lang-html', '@codemirror/lang-json'], 48 | } 49 | } 50 | } 51 | }, 52 | })) 53 | --------------------------------------------------------------------------------