├── scripts └── analyze-bundle.js ├── .env ├── .gitignore ├── resource ├── icon │ ├── tray.png │ ├── index256.png │ ├── index512.png │ ├── tray_backup.png │ ├── activity │ │ ├── ghost.png │ │ └── confusion.png │ ├── installer │ │ └── installer.ico │ └── logo.svg └── static │ └── lvory_slogan.png ├── public ├── icons │ ├── tray-icon.png │ └── tray-icon-active.png └── index.html ├── src ├── components │ ├── Dashboard.jsx │ ├── Activity │ │ ├── ConnectionHeader.jsx │ │ ├── LogItem.jsx │ │ ├── ConnectionLogItem.jsx │ │ └── LogHeader.jsx │ ├── Settings │ │ ├── Settings.jsx │ │ └── SettingsSidebar.jsx │ ├── Modal.jsx │ ├── MessageBox.jsx │ ├── Dashboard │ │ ├── CoreManagement.jsx │ │ ├── hooks │ │ │ └── useProfileUpdate.jsx │ │ └── SpeedTest.jsx │ ├── SystemStatus.jsx │ └── Sidebar.jsx ├── main │ ├── ipc-handlers.js │ ├── ipc │ │ ├── constants.js │ │ ├── utils.js │ │ ├── index.js │ │ └── handlers │ │ │ └── window.js │ ├── ipc-handlers │ │ ├── node-history-handlers.js │ │ ├── log-cleanup-handlers.js │ │ ├── subscription-handlers.js │ │ ├── traffic-stats-handlers.js │ │ └── index.js │ ├── ipc-manager.js │ └── data-managers │ │ ├── base-database-manager.js │ │ ├── node-connection-monitor.js │ │ └── log-cleanup-manager.js ├── index.js ├── index.html ├── i18n │ └── index.js ├── assets │ └── css │ │ ├── global.css │ │ ├── systemindicator.css │ │ ├── customerCard.css │ │ ├── messagebox.css │ │ ├── profile-table.css │ │ ├── app.css │ │ ├── activity-icons.css │ │ ├── profile-actions.css │ │ ├── servicenodes.css │ │ └── stats-overview.css ├── utils │ ├── version.js │ ├── event-bus.js │ ├── messageBox.js │ ├── store.js │ ├── ipcOptimizer.js │ ├── formatters.js │ ├── sing-box │ │ ├── config-parser.js │ │ └── state-manager.js │ ├── config-processor.js │ └── paths.js ├── services │ ├── network │ │ └── TracerouteService.js │ └── ip │ │ └── IPService.js ├── context │ └── AppContext.jsx └── hooks │ └── usePrivacySettings.js ├── docs ├── screenshot │ ├── activity.png │ ├── dashboard.png │ ├── linux-deb.png │ ├── settings.png │ ├── activity_conn.png │ └── profile_action.png ├── screenshot.md ├── README.md ├── faq.md ├── appimage-mode.md ├── program │ └── node_score.md └── ipc-optimization.md ├── .vscode └── settings.json ├── flatpak ├── com.lvory.app.desktop ├── setup-permissions.bat ├── generate-sources.sh ├── lvory-wrapper.sh ├── README.md ├── com.lvory.app.metainfo.xml └── com.lvory.app.yml ├── .electronbuilderignore ├── README.md └── webpack.config.js /scripts/analyze-bundle.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | BROWSER=none -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | dist/ 4 | sing-box.json 5 | build/ 6 | cache.db -------------------------------------------------------------------------------- /resource/icon/tray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/tray.png -------------------------------------------------------------------------------- /public/icons/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/public/icons/tray-icon.png -------------------------------------------------------------------------------- /resource/icon/index256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/index256.png -------------------------------------------------------------------------------- /resource/icon/index512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/index512.png -------------------------------------------------------------------------------- /src/components/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import Dashboard from './Dashboard/index'; 2 | export default Dashboard; -------------------------------------------------------------------------------- /docs/screenshot/activity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/activity.png -------------------------------------------------------------------------------- /docs/screenshot/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/dashboard.png -------------------------------------------------------------------------------- /docs/screenshot/linux-deb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/linux-deb.png -------------------------------------------------------------------------------- /docs/screenshot/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/settings.png -------------------------------------------------------------------------------- /resource/icon/tray_backup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/tray_backup.png -------------------------------------------------------------------------------- /docs/screenshot/activity_conn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/activity_conn.png -------------------------------------------------------------------------------- /docs/screenshot/profile_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/docs/screenshot/profile_action.png -------------------------------------------------------------------------------- /public/icons/tray-icon-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/public/icons/tray-icon-active.png -------------------------------------------------------------------------------- /resource/icon/activity/ghost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/activity/ghost.png -------------------------------------------------------------------------------- /resource/static/lvory_slogan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/static/lvory_slogan.png -------------------------------------------------------------------------------- /resource/icon/activity/confusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/activity/confusion.png -------------------------------------------------------------------------------- /resource/icon/installer/installer.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaoxhao/lvory/HEAD/resource/icon/installer/installer.ico -------------------------------------------------------------------------------- /src/main/ipc-handlers.js: -------------------------------------------------------------------------------- 1 | // 导入新的模块化处理程序入口 2 | const { setupHandlers } = require('./ipc-handlers/index'); 3 | 4 | module.exports = { 5 | setupHandlers 6 | }; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true, 3 | "sonarlint.connectedMode.project": { 4 | "connectionId": "sxueck", 5 | "projectKey": "sxueck_lvory" 6 | } 7 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import './assets/css/global.css'; 5 | 6 | const root = createRoot(document.getElementById('root')); 7 | root.render(); -------------------------------------------------------------------------------- /docs/screenshot.md: -------------------------------------------------------------------------------- 1 | # 页面预览 2 | 3 | ![Dashboard](screenshot/dashboard.png) 4 | ![Activity](screenshot/activity.png) 5 | ![Profile_Actions](screenshot/profile_action.png) 6 | ![settings](screenshot/settings.png) 7 | ![ActivityConn](screenshot/activity_conn.png) -------------------------------------------------------------------------------- /src/main/ipc/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC常量定义 3 | * 这个文件定义了所有IPC通道名称,便于统一管理 4 | */ 5 | 6 | // 窗口管理相关 7 | const WINDOW = { 8 | CONTROL: 'window.control', // 控制窗口动作:最小化,最大化,关闭 9 | ACTION: 'window.action' // 窗口操作:显示,退出 10 | }; 11 | 12 | module.exports = { 13 | WINDOW 14 | }; -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Lvory 文档 2 | 3 | ## 文档目录 4 | 5 | ### 用户指南 6 | 7 | - [页面预览](screenshot.md) - 应用界面截图 8 | - [常见问题](faq.md) - 用户常见问题解答 9 | 10 | ### 技术文档 11 | 12 | - [IPC 接口优化说明](ipc-optimization.md) - IPC 接口合并优化详情 13 | - [配置引擎设计](program/profiles_engine.md) - 配置映射引擎原型设计 14 | - [节点评分算法](program/node_score.md) - 代理节点延迟评估与打分算法 15 | - [Lvory 同步协议](program/lvory-sync-protocol.md) - 同步协议设计文档 16 | 17 | ### 开发相关 18 | 19 | 所有技术文档均为开发流程参考,用户无需关心这部分内容。如果您是开发者,可以参考这些文档了解 Lvory 的内部设计和实现。 -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # 常见问题 (FAQ) 2 | 3 | ## 关于 TUN 模式的疑问 4 | 5 | TUN(Tunnel)代理模式是一种先进的网络流量转发机制,它通过创建虚拟网络接口来接管设备的所有网络流量,并将其重定向至代理服务器进行处理。与传统的系统代理不同,TUN 模式工作在更低的传输层,能够实现更全面、更底层的流量控制,尤其适用于需要全局代理的场景,由于其复杂性,Lvory 不会进行实际的 TUN 管控,而是将其交由了 Singbox 内核进行处理 6 | 7 | 我观察到,当前大部分订阅都会默认启用 TUN 模式,这不是一种很好的行为,TUN 由于需要捕获和处理设备上的所有网络流量,其会对系统性能产生一定影响,尤其是在网络流量较大时,可能会增加 CPU 和内存的消耗,同时会影响系统路由表,当用户不具备一定的网络知识的时候会造成相当困扰 8 | 9 | 为此,Lvory 在订阅配置下载后,会抹去其中关于 TUN 模式的开启选项,转而用户可以从 "设置" - "基本设置" - "TUN 模式支持" 手动进行开关并进行管理,同时打开该选项后,内核会自动以管理员权限模式运行并处理相关依赖 -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LVORY 7 | 20 | 21 | 22 |
23 | 24 | -------------------------------------------------------------------------------- /src/components/Activity/ConnectionHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ConnectionHeader = () => { 4 | return ( 5 |
6 |
TIME
7 |
DIR
8 |
ADDRESS
9 |
PROXY
10 |
PROTOCOL
11 |
12 | ); 13 | }; 14 | 15 | export default ConnectionHeader; -------------------------------------------------------------------------------- /flatpak/com.lvory.app.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Version=1.0 4 | Name=lvory 5 | Name[zh_CN]=lvory 6 | GenericName=Sing-box Client 7 | GenericName[zh_CN]=Sing-box 客户端 8 | Comment=Minimalist Cross-Platform Client for Singbox 9 | Comment[zh_CN]=简约跨平台 Sing-box 客户端 10 | Icon=com.lvory.app 11 | Exec=lvory %U 12 | Terminal=false 13 | StartupNotify=true 14 | Categories=Network;Utility; 15 | Keywords=proxy;vpn;singbox;network;tunnel; 16 | Keywords[zh_CN]=代理;VPN;网络;隧道; 17 | MimeType=application/json; 18 | StartupWMClass=lvory 19 | X-GNOME-UsesNotifications=true 20 | -------------------------------------------------------------------------------- /.electronbuilderignore: -------------------------------------------------------------------------------- 1 | # 开发文件 2 | src/ 3 | public/ 4 | docs/ 5 | test/ 6 | *.md 7 | README* 8 | CHANGELOG* 9 | LICENSE* 10 | 11 | # 配置文件 12 | webpack.config.js 13 | babel.config.js 14 | .babelrc* 15 | .eslintrc* 16 | .prettierrc* 17 | .gitignore 18 | .gitattributes 19 | 20 | # 依赖和缓存 21 | node_modules/.cache/ 22 | .npm/ 23 | .yarn/ 24 | yarn.lock 25 | package-lock.json 26 | pnpm-lock.yaml 27 | 28 | # IDE和编辑器 29 | .vscode/ 30 | .idea/ 31 | *.swp 32 | *.swo 33 | *~ 34 | 35 | # 系统文件 36 | .DS_Store 37 | Thumbs.db 38 | 39 | # 构建产物(保留dist) 40 | build/ 41 | coverage/ 42 | .nyc_output/ 43 | 44 | # 日志文件 45 | *.log 46 | logs/ -------------------------------------------------------------------------------- /src/components/Settings/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import SettingsSidebar from './SettingsSidebar'; 3 | import SettingsContent from './SettingsContent'; 4 | 5 | const Settings = () => { 6 | const [selectedSection, setSelectedSection] = useState('basic'); 7 | 8 | return ( 9 |
10 | 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default Settings; -------------------------------------------------------------------------------- /flatpak/setup-permissions.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | REM Windows 批处理文件 - 设置 Flatpak 脚本权限 3 | REM 在 Linux 环境中运行时,这些脚本将自动获得执行权限 4 | 5 | echo 设置 Flatpak 脚本权限... 6 | echo. 7 | echo 注意: 此批处理文件仅用于 Windows 环境下的文件管理 8 | echo 在 Linux 环境中,请运行以下命令设置执行权限: 9 | echo. 10 | echo chmod +x flatpak/*.sh 11 | echo. 12 | echo 或者单独设置每个脚本的权限: 13 | echo chmod +x flatpak/build.sh 14 | echo chmod +x flatpak/install.sh 15 | echo chmod +x flatpak/uninstall.sh 16 | echo chmod +x flatpak/test.sh 17 | echo chmod +x flatpak/generate-sources.sh 18 | echo chmod +x flatpak/download-singbox.sh 19 | echo chmod +x flatpak/lvory-wrapper.sh 20 | echo. 21 | echo 脚本文件列表: 22 | dir /b flatpak\*.sh 23 | echo. 24 | echo 完成! 25 | pause 26 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | 5 | import enUS from './translations/en-US'; 6 | import zhCN from './translations/zh-CN'; 7 | 8 | const resources = { 9 | en_US: { 10 | translation: enUS 11 | }, 12 | zh_CN: { 13 | translation: zhCN 14 | } 15 | }; 16 | 17 | i18n 18 | .use(LanguageDetector) 19 | .use(initReactI18next) 20 | .init({ 21 | resources, 22 | fallbackLng: 'en_US', 23 | interpolation: { 24 | escapeValue: false 25 | }, 26 | detection: { 27 | order: ['localStorage', 'navigator'], 28 | lookupLocalStorage: 'language' 29 | } 30 | }); 31 | 32 | export default i18n; -------------------------------------------------------------------------------- /docs/appimage-mode.md: -------------------------------------------------------------------------------- 1 | # AppImage 模式支持 2 | 3 | 4 | ## 概述 5 | 6 | ## 功能特性 7 | 8 | ### 1. 自动检测 AppImage 环境 9 | 10 | 应用程序会自动检测是否运行在 AppImage 环境中,检测方法包括: 11 | 12 | - **APPIMAGE 环境变量**:AppImage 运行时会设置此变量指向 AppImage 文件路径 13 | - **APPDIR 环境变量**:AppImage 挂载目录路径 14 | - **进程路径特征**:检查可执行文件路径是否包含 AppImage 特征(如 `/.mount_` 或 `/tmp/.mount_`) 15 | 16 | ### 2. 符合标准的文件存储 17 | 18 | 在 AppImage 模式下,所有应用程序数据都存储到符合 XDG 基础目录规范的位置: 19 | 20 | - **配置目录**:`$XDG_CONFIG_HOME/lvory` 或 `~/.config/lvory` 21 | - **内核文件**:`$XDG_CONFIG_HOME/lvory/bin` 或 `~/.config/lvory/bin` 22 | - **配置文件**:`$XDG_CONFIG_HOME/lvory/configs` 或 `~/.config/lvory/configs` 23 | - **日志文件**:`$XDG_CONFIG_HOME/lvory/logs` 或 `~/.config/lvory/logs` 24 | 25 | ### 3. 运行模式显示 26 | 27 | 在应用程序的"设置 > 关于"页面中,会显示当前的运行模式信息,包括: 28 | 29 | - 运行模式类型(标准模式、便携模式、AppImage) 30 | - 平台信息 31 | - 模式状态标识 -------------------------------------------------------------------------------- /src/components/Modal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/css/modal.css'; 3 | 4 | const Modal = ({ isOpen, onClose, title, children, className }) => { 5 | if (!isOpen) return null; 6 | 7 | // 根据className判断是否为成功状态 8 | const isSuccess = className && className.includes('success-state'); 9 | 10 | return ( 11 |
12 |
13 |
14 |

{title}

15 | 22 |
23 |
24 | {children} 25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default Modal; -------------------------------------------------------------------------------- /src/main/ipc/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC工具函数 3 | */ 4 | const { BrowserWindow } = require('electron'); 5 | const logger = require('../../utils/logger'); 6 | 7 | /** 8 | * 获取主窗口实例 9 | * @returns {BrowserWindow|null} 主窗口实例或null 10 | */ 11 | function getMainWindow() { 12 | const windows = BrowserWindow.getAllWindows(); 13 | if (windows.length === 0) { 14 | logger.warn('无法获取主窗口: 没有打开的窗口'); 15 | return null; 16 | } 17 | return windows[0]; 18 | } 19 | 20 | /** 21 | * 统一的错误处理 22 | * @param {Error} error 错误对象 23 | * @param {String} operation 操作名称 24 | * @returns {Object} 格式化的错误对象 25 | */ 26 | function handleError(error, operation) { 27 | logger.error(`${operation}失败:`, error); 28 | return { 29 | success: false, 30 | error: error.message || '未知错误' 31 | }; 32 | } 33 | 34 | /** 35 | * 创建成功响应 36 | * @param {*} data 响应数据 37 | * @returns {Object} 格式化的成功响应 38 | */ 39 | function createSuccess(data = null) { 40 | return { 41 | success: true, 42 | data 43 | }; 44 | } 45 | 46 | module.exports = { 47 | getMainWindow, 48 | handleError, 49 | createSuccess 50 | }; -------------------------------------------------------------------------------- /src/assets/css/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; 6 | font-weight: 450; 7 | user-select: none; 8 | -webkit-user-select: none; 9 | } 10 | 11 | body { 12 | background-color: #fff; 13 | color: #333; 14 | font-size: 14px; 15 | line-height: 1.5; 16 | font-weight: 300; 17 | } 18 | 19 | 20 | ul { 21 | list-style: none; 22 | } 23 | 24 | button { 25 | cursor: pointer; 26 | background: none; 27 | border: none; 28 | outline: none; 29 | } 30 | 31 | input, textarea, [contenteditable="true"] { 32 | border: none; 33 | outline: none; 34 | } 35 | 36 | a { 37 | text-decoration: none; 38 | color: inherit; 39 | } 40 | 41 | ::-webkit-scrollbar { 42 | width: 6px; 43 | height: 6px; 44 | } 45 | 46 | ::-webkit-scrollbar-track { 47 | background: #f1f1f1; 48 | } 49 | 50 | ::-webkit-scrollbar-thumb { 51 | background: #c1c1c1; 52 | border-radius: 3px; 53 | } 54 | 55 | ::-webkit-scrollbar-thumb:hover { 56 | background: #a8a8a8; 57 | } -------------------------------------------------------------------------------- /src/components/MessageBox.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import '../assets/css/messagebox.css'; 3 | 4 | const MessageBox = ({ isOpen, onClose, message }) => { 5 | // TODO: 这部分需要使用 Recet Protal 来进行重构 6 | useEffect(() => { 7 | if (isOpen) { 8 | const mainContent = document.querySelector('.main-content'); 9 | if (mainContent) { 10 | // 为 main-content 添加一个类来标识有模态弹窗 11 | mainContent.classList.add('has-modal'); 12 | } 13 | } 14 | 15 | return () => { 16 | const mainContent = document.querySelector('.main-content'); 17 | if (mainContent) { 18 | mainContent.classList.remove('has-modal'); 19 | } 20 | }; 21 | }, [isOpen]); 22 | 23 | if (!isOpen) return null; 24 | 25 | return ( 26 |
27 |
28 |
29 |

{message}

30 |
31 |
32 | 33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export default MessageBox; -------------------------------------------------------------------------------- /src/main/ipc-handlers/node-history-handlers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 节点历史数据相关IPC处理程序 3 | */ 4 | const { ipcMain } = require('electron'); 5 | const logger = require('../../utils/logger'); 6 | const nodeHistoryManager = require('../data-managers/node-history-manager'); 7 | 8 | /** 9 | * 设置节点历史数据相关IPC处理程序 10 | */ 11 | function setup() { 12 | // 获取指定节点的历史数据 13 | ipcMain.handle('get-node-history', async (event, nodeTag) => { 14 | return nodeHistoryManager.getNodeHistory(nodeTag); 15 | }); 16 | 17 | // 检查节点历史数据功能是否启用 18 | ipcMain.handle('is-node-history-enabled', () => { 19 | return { success: true, enabled: nodeHistoryManager.isHistoryEnabled() }; 20 | }); 21 | 22 | // 加载所有节点历史数据 23 | ipcMain.handle('load-all-node-history', () => { 24 | return nodeHistoryManager.loadAllHistoryData(); 25 | }); 26 | 27 | // 获取指定节点的累计流量数据 28 | ipcMain.handle('get-node-total-traffic', async (event, nodeTag) => { 29 | return nodeHistoryManager.getTotalTraffic(nodeTag); 30 | }); 31 | 32 | // 获取所有节点的累计流量数据 33 | ipcMain.handle('get-all-nodes-total-traffic', () => { 34 | return nodeHistoryManager.getAllTotalTraffic(); 35 | }); 36 | 37 | // 重置节点累计流量数据 38 | ipcMain.handle('reset-node-total-traffic', async (event, nodeTag) => { 39 | return nodeHistoryManager.resetTotalTraffic(nodeTag); 40 | }); 41 | } 42 | 43 | module.exports = { 44 | setup 45 | }; -------------------------------------------------------------------------------- /src/main/ipc-handlers/log-cleanup-handlers.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const logger = require('../../utils/logger'); 3 | const logCleanupManager = require('../data-managers/log-cleanup-manager'); 4 | 5 | function setup() { 6 | ipcMain.handle('log-cleanup:perform', async () => { 7 | try { 8 | return logCleanupManager.performCleanup(); 9 | } catch (error) { 10 | logger.error('执行日志清理失败:', error); 11 | return { success: false, error: error.message }; 12 | } 13 | }); 14 | 15 | ipcMain.handle('log-cleanup:set-retention-days', async (event, days) => { 16 | try { 17 | return logCleanupManager.setRetentionDays(days); 18 | } catch (error) { 19 | logger.error('设置日志保留天数失败:', error); 20 | return { success: false, error: error.message }; 21 | } 22 | }); 23 | 24 | ipcMain.handle('log-cleanup:get-retention-days', async () => { 25 | try { 26 | return logCleanupManager.getRetentionDays(); 27 | } catch (error) { 28 | logger.error('获取日志保留天数失败:', error); 29 | return { success: false, error: error.message }; 30 | } 31 | }); 32 | 33 | ipcMain.handle('log-cleanup:get-stats', async () => { 34 | try { 35 | return logCleanupManager.getLogFileStats(); 36 | } catch (error) { 37 | logger.error('获取日志文件统计失败:', error); 38 | return { success: false, error: error.message }; 39 | } 40 | }); 41 | 42 | logger.info('日志清理 IPC 处理程序已注册'); 43 | } 44 | 45 | module.exports = { 46 | setup 47 | }; 48 | 49 | -------------------------------------------------------------------------------- /src/main/ipc/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC系统入口 3 | * 统一管理Electron的IPC通信 4 | */ 5 | const logger = require('../../utils/logger'); 6 | 7 | // 处理程序模块 8 | const handlers = { 9 | window: require('./handlers/window') 10 | }; 11 | 12 | // 已经注册的处理程序 13 | let isRegistered = false; 14 | 15 | /** 16 | * 设置所有IPC处理程序 17 | */ 18 | function setup() { 19 | if (isRegistered) { 20 | logger.warn('IPC处理程序已注册,跳过'); 21 | return; 22 | } 23 | 24 | try { 25 | // 注册所有处理程序 26 | for (const [name, handler] of Object.entries(handlers)) { 27 | if (handler && typeof handler.setup === 'function') { 28 | handler.setup(); 29 | logger.info(`注册IPC处理程序: ${name}`); 30 | } else { 31 | logger.warn(`无效的IPC处理程序模块: ${name}`); 32 | } 33 | } 34 | 35 | isRegistered = true; 36 | logger.info('所有IPC处理程序注册成功'); 37 | } catch (error) { 38 | logger.error('注册IPC处理程序失败:', error); 39 | } 40 | } 41 | 42 | /** 43 | * 清理所有IPC处理程序 44 | */ 45 | function cleanup() { 46 | if (!isRegistered) { 47 | return; 48 | } 49 | 50 | try { 51 | // 清理所有处理程序 52 | for (const [name, handler] of Object.entries(handlers)) { 53 | if (handler && typeof handler.cleanup === 'function') { 54 | handler.cleanup(); 55 | logger.info(`清理IPC处理程序: ${name}`); 56 | } 57 | } 58 | 59 | isRegistered = false; 60 | logger.info('所有IPC处理程序已清理'); 61 | } catch (error) { 62 | logger.error('清理IPC处理程序失败:', error); 63 | } 64 | } 65 | 66 | module.exports = { 67 | setup, 68 | cleanup, 69 | constants: require('./constants') 70 | }; -------------------------------------------------------------------------------- /flatpak/generate-sources.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Flatpak Node.js 依赖源生成脚本 3 | 4 | set -e 5 | 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 8 | 9 | echo "正在生成 Flatpak Node.js 依赖源文件..." 10 | 11 | # 检查是否存在 flatpak-node-generator 12 | if ! command -v flatpak-node-generator &> /dev/null; then 13 | echo "错误: 未找到 flatpak-node-generator" 14 | echo "请安装 flatpak-node-generator:" 15 | echo " pip3 install flatpak-node-generator" 16 | echo " 或者从 https://github.com/flatpak/flatpak-builder-tools 获取" 17 | exit 1 18 | fi 19 | 20 | # 检查 package-lock.json 是否存在 21 | if [ ! -f "$PROJECT_ROOT/package-lock.json" ]; then 22 | echo "错误: 未找到 package-lock.json 文件" 23 | echo "请先在项目根目录运行 'npm install' 生成 package-lock.json" 24 | exit 1 25 | fi 26 | 27 | # 进入项目根目录 28 | cd "$PROJECT_ROOT" 29 | 30 | # 生成 Flatpak 源文件 31 | echo "正在分析 package-lock.json..." 32 | flatpak-node-generator npm package-lock.json 33 | 34 | # 移动生成的文件到 flatpak 目录 35 | if [ -f "generated-sources.json" ]; then 36 | mv generated-sources.json "$SCRIPT_DIR/" 37 | echo "✓ 已生成 generated-sources.json" 38 | else 39 | echo "错误: 生成 generated-sources.json 失败" 40 | exit 1 41 | fi 42 | 43 | # 验证生成的文件 44 | if [ -f "$SCRIPT_DIR/generated-sources.json" ]; then 45 | SOURCE_COUNT=$(jq length "$SCRIPT_DIR/generated-sources.json") 46 | echo "✓ 成功生成 $SOURCE_COUNT 个 NPM 包源" 47 | echo "✓ 文件保存在: $SCRIPT_DIR/generated-sources.json" 48 | else 49 | echo "错误: 生成的源文件验证失败" 50 | exit 1 51 | fi 52 | 53 | echo "" 54 | echo "Node.js 依赖源生成完成!" 55 | echo "现在可以使用以下命令构建 Flatpak 包:" 56 | echo " cd $SCRIPT_DIR" 57 | echo " flatpak-builder build com.lvory.app.yml --force-clean --install-deps-from=flathub" 58 | -------------------------------------------------------------------------------- /src/main/ipc-manager.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const settingsManager = require('./settings-manager'); 3 | const singbox = require('../utils/sing-box'); 4 | const logger = require('../utils/logger'); 5 | 6 | class IPCManager { 7 | constructor() { 8 | this.mainWindow = null; 9 | } 10 | 11 | setMainWindow(window) { 12 | this.mainWindow = window; 13 | } 14 | 15 | init() { 16 | // 窗口控制相关IPC 17 | this.handleWindowControls(); 18 | } 19 | 20 | handleWindowControls() { 21 | ipcMain.on('window-minimize', () => { 22 | if (this.mainWindow?.isDestroyed?.() === false) { 23 | this.mainWindow.minimize(); 24 | } 25 | }); 26 | 27 | ipcMain.on('window-close', async () => { 28 | if (this.mainWindow?.isDestroyed?.() === false) { 29 | // 检查仅前台运行设置 30 | const settings = settingsManager.getSettings(); 31 | if (settings.foregroundOnly) { 32 | try { 33 | logger.info('仅前台运行模式,从IPC管理器退出程序'); 34 | await singbox.disableSystemProxy(); 35 | await singbox.stopCore(); 36 | global.isQuitting = true; 37 | require('electron').app.quit(); 38 | } catch (error) { 39 | logger.error('退出前清理失败:', error); 40 | global.isQuitting = true; 41 | require('electron').app.quit(); 42 | } 43 | } else { 44 | this.mainWindow.hide(); 45 | } 46 | } 47 | }); 48 | 49 | ipcMain.on('window-maximize', () => { 50 | if (this.mainWindow?.isDestroyed?.() === false) { 51 | if (this.mainWindow.isMaximized()) { 52 | this.mainWindow.unmaximize(); 53 | } else { 54 | this.mainWindow.maximize(); 55 | } 56 | } 57 | }); 58 | } 59 | } 60 | 61 | const ipcManager = new IPCManager(); 62 | module.exports = ipcManager; -------------------------------------------------------------------------------- /src/main/ipc-handlers/subscription-handlers.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const subscriptionManager = require('../data-managers/subscription-manager'); 3 | const logger = require('../../utils/logger'); 4 | 5 | function setup() { 6 | ipcMain.handle('subscription:add', async (event, { fileName, metadata }) => { 7 | try { 8 | return subscriptionManager.addSubscription(fileName, metadata); 9 | } catch (error) { 10 | logger.error('添加订阅失败:', error); 11 | return { success: false, error: error.message }; 12 | } 13 | }); 14 | 15 | ipcMain.handle('subscription:get', async (event, fileName) => { 16 | try { 17 | return subscriptionManager.getSubscription(fileName); 18 | } catch (error) { 19 | logger.error('获取订阅失败:', error); 20 | return { success: false, error: error.message }; 21 | } 22 | }); 23 | 24 | ipcMain.handle('subscription:get-all', async () => { 25 | try { 26 | return subscriptionManager.getAllSubscriptions(); 27 | } catch (error) { 28 | logger.error('获取所有订阅失败:', error); 29 | return { success: false, error: error.message }; 30 | } 31 | }); 32 | 33 | ipcMain.handle('subscription:update', async (event, { fileName, updates }) => { 34 | try { 35 | return subscriptionManager.updateSubscription(fileName, updates); 36 | } catch (error) { 37 | logger.error('更新订阅失败:', error); 38 | return { success: false, error: error.message }; 39 | } 40 | }); 41 | 42 | ipcMain.handle('subscription:delete', async (event, fileName) => { 43 | try { 44 | return subscriptionManager.deleteSubscription(fileName); 45 | } catch (error) { 46 | logger.error('删除订阅失败:', error); 47 | return { success: false, error: error.message }; 48 | } 49 | }); 50 | 51 | logger.info('订阅管理IPC处理程序已设置'); 52 | } 53 | 54 | module.exports = { 55 | setup 56 | }; 57 | 58 | -------------------------------------------------------------------------------- /src/assets/css/systemindicator.css: -------------------------------------------------------------------------------- 1 | /* 系统状态卡片样式 */ 2 | .system-status-card { 3 | margin-top: auto; 4 | padding: 12px; 5 | background-color: #f9fafb; 6 | border-radius: 8px; 7 | box-shadow: 0 1px 3px rgba(0,0,0,0.05); 8 | border: 1px solid #f0f2f5; 9 | } 10 | 11 | .system-status-card h3 { 12 | margin: 0 0 5px 0; 13 | font-size: 13px; 14 | font-weight: 600; 15 | color: #24292e; 16 | } 17 | 18 | .status-item { 19 | margin-bottom: 5px; 20 | } 21 | 22 | .status-item:last-child { 23 | margin-bottom: 0; 24 | } 25 | 26 | .status-label { 27 | font-size: 11px; 28 | color: #666; 29 | margin-bottom: 2px; 30 | } 31 | 32 | .status-value { 33 | font-size: 12px; 34 | color: #24292e; 35 | font-weight: 500; 36 | } 37 | 38 | .status-value-row { 39 | display: flex; 40 | align-items: center; 41 | gap: 6px; 42 | flex-wrap: wrap; 43 | } 44 | 45 | .download-core-btn { 46 | padding: 2px 6px; 47 | background-color: #50b2d0; 48 | color: white; 49 | border: none; 50 | border-radius: 4px; 51 | font-size: 10px; 52 | cursor: pointer; 53 | transition: background-color 0.2s; 54 | } 55 | 56 | .download-core-btn:hover { 57 | background-color: #3d9cb9; 58 | } 59 | 60 | .download-progress { 61 | width: 100%; 62 | height: 14px; 63 | background-color: #e9ecef; 64 | border-radius: 4px; 65 | position: relative; 66 | overflow: hidden; 67 | font-size: 9px; 68 | } 69 | 70 | .progress-text { 71 | position: absolute; 72 | width: 100%; 73 | text-align: center; 74 | color: #495057; 75 | z-index: 1; 76 | line-height: 14px; 77 | } 78 | 79 | .progress-bar { 80 | position: absolute; 81 | top: 0; 82 | left: 0; 83 | height: 100%; 84 | background-color: #50b2d0; 85 | transition: width 0.3s ease; 86 | } 87 | 88 | .download-error { 89 | color: #dc3545; 90 | font-size: 10px; 91 | margin-top: 2px; 92 | } 93 | 94 | .download-success { 95 | color: #28a745; 96 | font-size: 10px; 97 | margin-top: 2px; 98 | } -------------------------------------------------------------------------------- /src/utils/version.js: -------------------------------------------------------------------------------- 1 | // 版本信息常量定义 2 | const VERSION_INFO = { 3 | APP_VERSION: '0.1.7', // 默认版本号 4 | APP_NAME: 'lvory', 5 | APP_DESCRIPTION: '基于Sing-Box内核的通用桌面GUI客户端', 6 | LICENSE: 'Apache-2.0', 7 | WEBSITE: 'https://github.com/sxueck/lvory', 8 | BUILD_DATE: '20240101', // 默认构建日期,将被CI替换 9 | }; 10 | 11 | // 尝试从Electron环境获取版本信息 12 | const initVersionInfo = async () => { 13 | if (window.electron) { 14 | try { 15 | const version = await window.electron.invoke('get-app-version'); 16 | if (version) { 17 | VERSION_INFO.APP_VERSION = version; 18 | } 19 | 20 | // 获取构建日期 21 | const buildDate = await window.electron.invoke('get-build-date'); 22 | if (buildDate) { 23 | VERSION_INFO.BUILD_DATE = buildDate; 24 | } 25 | } catch (error) { 26 | console.error('无法获取应用版本信息:', error); 27 | } 28 | } 29 | }; 30 | 31 | // 初始化版本信息 32 | initVersionInfo(); 33 | 34 | // 获取应用版本信息 35 | const getAppVersion = () => VERSION_INFO.APP_VERSION; 36 | 37 | // 获取构建日期 38 | const getBuildDate = () => VERSION_INFO.BUILD_DATE; 39 | 40 | // 获取应用名称 41 | const getAppName = () => VERSION_INFO.APP_NAME; 42 | 43 | // 获取完整版本信息对象 44 | const getVersionInfo = () => ({...VERSION_INFO}); 45 | 46 | // 获取格式化的关于信息 47 | const getAboutInfo = async () => { 48 | let coreVersion = 'Not Installed'; 49 | 50 | // 尝试更新应用版本信息 51 | await initVersionInfo(); 52 | 53 | // 尝试获取内核版本 54 | if (window.electron && window.electron.singbox && window.electron.singbox.getVersion) { 55 | try { 56 | const result = await window.electron.singbox.getVersion(); 57 | if (result.success) { 58 | coreVersion = result.version || 'Unknown'; 59 | } 60 | } catch (error) { 61 | console.error('获取内核版本失败:', error); 62 | } 63 | } 64 | 65 | return { 66 | ...VERSION_INFO, 67 | CORE_VERSION: coreVersion, 68 | }; 69 | }; 70 | 71 | export { 72 | getAppVersion, 73 | getBuildDate, 74 | getAppName, 75 | getVersionInfo, 76 | getAboutInfo, 77 | }; -------------------------------------------------------------------------------- /src/components/Dashboard/CoreManagement.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const useCoreManagement = () => { 4 | const [isDownloadingCore, setIsDownloadingCore] = useState(false); 5 | const [coreDownloadProgress, setCoreDownloadProgress] = useState(0); 6 | const [coreDownloadError, setCoreDownloadError] = useState(''); 7 | const [coreDownloadSuccess, setCoreDownloadSuccess] = useState(false); 8 | 9 | const handleCoreDownload = () => { 10 | if (window.electron && window.electron.singbox && window.electron.singbox.downloadCore) { 11 | setIsDownloadingCore(true); 12 | setCoreDownloadError(''); 13 | setCoreDownloadSuccess(false); 14 | 15 | window.electron.singbox.downloadCore() 16 | .then(result => { 17 | setIsDownloadingCore(false); 18 | if (result.success) { 19 | setCoreDownloadSuccess(true); 20 | setTimeout(() => { 21 | setCoreDownloadSuccess(false); 22 | }, 3000); 23 | return result; 24 | } else { 25 | setCoreDownloadError(result.error || '下载失败'); 26 | throw new Error(result.error || '下载失败'); 27 | } 28 | }) 29 | .catch(err => { 30 | setIsDownloadingCore(false); 31 | setCoreDownloadError(err.message || '下载过程中发生错误'); 32 | throw err; 33 | }); 34 | } else { 35 | setCoreDownloadError('下载功能不可用'); 36 | throw new Error('下载功能不可用'); 37 | } 38 | }; 39 | 40 | const setupCoreDownloadListener = (callback) => { 41 | if (window.electron && window.electron.download && window.electron.download.onCoreProgress) { 42 | return window.electron.download.onCoreProgress(progress => { 43 | setCoreDownloadProgress(progress.progress); 44 | if (callback) callback(progress); 45 | }); 46 | } 47 | return null; 48 | }; 49 | 50 | return { 51 | isDownloadingCore, 52 | coreDownloadProgress, 53 | coreDownloadError, 54 | coreDownloadSuccess, 55 | handleCoreDownload, 56 | setupCoreDownloadListener 57 | }; 58 | }; 59 | 60 | export default useCoreManagement; -------------------------------------------------------------------------------- /src/services/network/TracerouteService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Traceroute 网络追踪服务 3 | * 通过 IPC 调用主进程的 traceroute 功能 4 | */ 5 | 6 | class TracerouteService { 7 | /** 8 | * 验证IP地址或域名格式 9 | * @param {string} target 目标地址 10 | * @returns {boolean} 是否有效 11 | */ 12 | static isValidTarget(target) { 13 | if (!target || typeof target !== 'string' || target.trim().length === 0) { 14 | return false; 15 | } 16 | 17 | const trimmedTarget = target.trim(); 18 | const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 19 | const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/; 20 | 21 | return ipv4Regex.test(trimmedTarget) || domainRegex.test(trimmedTarget); 22 | } 23 | 24 | /** 25 | * 执行 traceroute 追踪 26 | * @param {string} target 目标主机或IP地址 27 | * @returns {Promise} 返回路由跳点信息数组 28 | */ 29 | static async trace(target) { 30 | try { 31 | if (window.electron && window.electron.traceroute) { 32 | const result = await window.electron.traceroute.execute(target); 33 | 34 | if (result.success) { 35 | return result.hops || []; 36 | } else { 37 | throw new Error(result.error || 'Traceroute execution failed'); 38 | } 39 | } else { 40 | throw new Error('Traceroute service not available'); 41 | } 42 | } catch (error) { 43 | console.error('Traceroute execution failed:', error); 44 | throw error; 45 | } 46 | } 47 | 48 | /** 49 | * 验证目标地址 50 | * @param {string} target 目标地址 51 | * @returns {Promise} 是否有效 52 | */ 53 | static async validateTarget(target) { 54 | try { 55 | if (window.electron && window.electron.traceroute) { 56 | return await window.electron.traceroute.validate(target); 57 | } else { 58 | return this.isValidTarget(target); 59 | } 60 | } catch (error) { 61 | console.error('Target validation failed:', error); 62 | return this.isValidTarget(target); 63 | } 64 | } 65 | } 66 | 67 | module.exports = TracerouteService; -------------------------------------------------------------------------------- /src/context/AppContext.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useContext, useEffect } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | const AppContext = createContext(); 5 | 6 | export const useAppContext = () => useContext(AppContext); 7 | 8 | export const AppProvider = ({ children }) => { 9 | const { i18n } = useTranslation(); 10 | const [state, setState] = useState({ 11 | privateMode: false, 12 | theme: 'light', 13 | language: 'zh_CN', 14 | }); 15 | 16 | // 加载持久化的设置 17 | useEffect(() => { 18 | const loadSettings = async () => { 19 | if (window.electron && window.electron.settings && window.electron.settings.get) { 20 | try { 21 | const result = await window.electron.settings.get(); 22 | if (result.success) { 23 | setState(prev => ({ 24 | ...prev, 25 | ...result.settings, 26 | showAnimations: result.settings.showAnimations !== undefined 27 | ? result.settings.showAnimations 28 | : true, 29 | language: result.settings.language || 'zh_CN' 30 | })); 31 | 32 | // 设置i18n语言 33 | if(result.settings.language) { 34 | i18n.changeLanguage(result.settings.language); 35 | } 36 | } 37 | } catch (error) { 38 | console.error('加载设置失败:', error); 39 | } 40 | } 41 | }; 42 | 43 | loadSettings(); 44 | }, [i18n]); 45 | 46 | const updateSettings = async (newSettings) => { 47 | setState(prev => ({ 48 | ...prev, 49 | ...newSettings 50 | })); 51 | 52 | // 如果更新了语言设置,则切换i18n语言 53 | if (newSettings.language && newSettings.language !== state.language) { 54 | i18n.changeLanguage(newSettings.language); 55 | } 56 | 57 | // 持久化设置 58 | if (window.electron && window.electron.settings && window.electron.settings.save) { 59 | try { 60 | await window.electron.settings.save({ 61 | ...state, 62 | ...newSettings 63 | }); 64 | } catch (error) { 65 | console.error('保存设置失败:', error); 66 | } 67 | } 68 | }; 69 | 70 | return ( 71 | 75 | {children} 76 | 77 | ); 78 | }; 79 | 80 | export default AppContext; -------------------------------------------------------------------------------- /src/components/Activity/LogItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { formatTimestamp } from '../../utils/formatters'; 3 | 4 | const logColors = { 5 | INFO: '#4CAF50', 6 | WARN: '#FF9800', 7 | ERROR: '#F44336', 8 | DEBUG: '#2196F3', 9 | }; 10 | 11 | const logIcons = { 12 | SYSTEM: '', 13 | SINGBOX: '', 14 | NETWORK: '', 15 | CONNECTION: '', 16 | STATUS: '', 17 | CONFIG: '', 18 | }; 19 | 20 | 21 | 22 | const safeString = (value) => { 23 | if (value === undefined || value === null) return ''; 24 | return String(value); 25 | }; 26 | 27 | const LogItem = ({ log, index, coreStatus }) => { 28 | const [isVisible, setIsVisible] = useState(true); 29 | 30 | // 确保 log 存在,否则使用空对象作为 fallback,以避免在 Hooks 之前进行条件返回 31 | const currentLog = log || {}; 32 | const level = safeString(currentLog.level || 'INFO').toLowerCase(); 33 | const type = safeString(currentLog.type || 'SYSTEM'); 34 | const message = safeString(currentLog.message || ''); 35 | 36 | // 基于内核状态决定日志显示 37 | useEffect(() => { 38 | // 如果内核已停止且这是连接相关的日志,则淡化显示 39 | if (coreStatus && !coreStatus.isRunning && (type === 'NETWORK' || type === 'CONNECTION')) { 40 | setIsVisible(false); 41 | } else { 42 | setIsVisible(true); 43 | } 44 | }, [coreStatus, type]); 45 | 46 | // 获取日志优先级 47 | const getLogPriority = () => { 48 | if (type === 'SINGBOX') return 'high'; 49 | if (type === 'SYSTEM') return 'medium'; 50 | if (type === 'NETWORK' || type === 'CONNECTION') { 51 | return coreStatus?.isRunning ? 'medium' : 'low'; 52 | } 53 | return 'medium'; 54 | }; 55 | 56 | const priority = getLogPriority(); 57 | const itemClass = `log-item log-${level} log-priority-${priority} ${!isVisible ? 'log-dimmed' : ''}`; 58 | 59 | if (!log) { 60 | return null; 61 | } 62 | 63 | return ( 64 |
65 |
{formatTimestamp(currentLog.timestamp, true)}
66 |
67 | {currentLog.level || 'INFO'} 68 |
69 |
70 | {logIcons[type] || '🔹'} {type} 71 |
72 |
{message}
73 | {!isVisible && ( 74 |
75 | ⏸️ 76 |
77 | )} 78 |
79 | ); 80 | }; 81 | 82 | export default LogItem; -------------------------------------------------------------------------------- /src/components/SystemStatus.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import '../assets/css/systemindicator.css'; 3 | 4 | const SystemStatus = () => { 5 | 6 | const [systemStats, setSystemStats] = useState({ 7 | coreVersion: 'N/A', 8 | }); 9 | const [profileData, setProfileData] = useState([]); 10 | 11 | // 初始化数据订阅:监听配置文件变更事件并获取sing-box核心版本信息 12 | useEffect(() => { 13 | // 处理配置文件更新事件:当主进程推送新配置时更新组件状态 14 | const handleProfileData = (event, data) => { 15 | setProfileData(data || []); 16 | }; 17 | 18 | // 获取sing-box版本 19 | const fetchCoreVersion = async () => { 20 | if (window.electron && window.electron.singbox && window.electron.singbox.getVersion) { 21 | try { 22 | const result = await window.electron.singbox.getVersion(); 23 | if (result.success) { 24 | setSystemStats(prev => ({ 25 | ...prev, 26 | coreVersion: result.version || 'unknown' 27 | })); 28 | } 29 | } catch (error) { 30 | console.error('获取版本失败:', error); 31 | } 32 | } 33 | }; 34 | 35 | let removeProfileListener = null; 36 | 37 | if (window.electron) { 38 | removeProfileListener = window.electron.profiles.onData(handleProfileData); 39 | 40 | // 手动请求配置文件数据 41 | window.electron.profiles.getData().then((data) => { 42 | if (data && data.success && Array.isArray(data.profiles)) { 43 | setProfileData(data.profiles); 44 | } else if (Array.isArray(data)) { 45 | // 兼容可能的直接返回数组的情况 46 | setProfileData(data); 47 | } else { 48 | console.error('获取到的配置文件数据格式不正确:', data); 49 | setProfileData([]); 50 | } 51 | }).catch(err => { 52 | console.error('获取配置文件数据失败:', err); 53 | setProfileData([]); 54 | }); 55 | } 56 | 57 | fetchCoreVersion(); 58 | 59 | // 组件卸载时移除事件监听 60 | return () => { 61 | if (removeProfileListener) { 62 | removeProfileListener(); 63 | } 64 | }; 65 | }, []); 66 | 67 | 68 | 69 | return ( 70 |
71 |

Information

72 | 73 |
74 |
TotalNodes
75 |
{profileData.length} 个
76 |
77 | 78 |
79 |
Core Version
80 |
{systemStats.coreVersion}
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default SystemStatus; 87 | -------------------------------------------------------------------------------- /src/hooks/usePrivacySettings.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useMemo } from 'react'; 2 | 3 | const STORAGE_KEY = 'lvory_privacy_settings'; 4 | 5 | const defaultSettings = { 6 | hideNodeNames: false, 7 | hideNodeIPs: false, 8 | hideNodeTypes: false, 9 | hidePersonalIP: 'none', // 'none', 'partial', 'full' 10 | }; 11 | 12 | // 从 localStorage 加载设置 13 | const loadSettings = () => { 14 | try { 15 | const saved = localStorage.getItem(STORAGE_KEY); 16 | return saved ? { ...defaultSettings, ...JSON.parse(saved) } : defaultSettings; 17 | } catch (error) { 18 | console.warn('Failed to load privacy settings from localStorage:', error); 19 | return defaultSettings; 20 | } 21 | }; 22 | 23 | // 保存设置到 localStorage 24 | const saveSettings = (settings) => { 25 | try { 26 | localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); 27 | } catch (error) { 28 | console.warn('Failed to save privacy settings to localStorage:', error); 29 | } 30 | }; 31 | 32 | export const usePrivacySettings = () => { 33 | const [privacySettings, setPrivacySettingsState] = useState(loadSettings); 34 | 35 | // 更新设置的回调函数 36 | const setPrivacySettings = useCallback((newSettings) => { 37 | setPrivacySettingsState(newSettings); 38 | saveSettings(newSettings); 39 | }, []); 40 | 41 | // 缓存的隐藏状态计算函数 42 | const createHideStates = useCallback((privateMode = false) => { 43 | return { 44 | hideNodeNames: privateMode || privacySettings.hideNodeNames, 45 | hideNodeIPs: privateMode || privacySettings.hideNodeIPs, 46 | hideNodeTypes: privateMode || privacySettings.hideNodeTypes, 47 | hidePersonalIP: privacySettings.hidePersonalIP 48 | }; 49 | }, [privacySettings]); 50 | 51 | // IP格式化函数 52 | const formatIpForDisplay = useMemo(() => { 53 | return (ipString) => { 54 | if (!ipString) return ipString; 55 | 56 | if (privacySettings.hidePersonalIP === 'full') { 57 | return '隐藏'; 58 | } else if (privacySettings.hidePersonalIP === 'partial') { 59 | // 部分隐藏IP地址 60 | const ipMatch = ipString.match(/(\d+\.\d+\.\d+\.)\d+/); 61 | if (ipMatch) { 62 | return ipString.replace(/(\d+\.\d+\.\d+\.)\d+/, '$1***'); 63 | } 64 | // 如果不是标准IP格式,隐藏后半部分 65 | const parts = ipString.split(' '); 66 | if (parts.length > 1) { 67 | return parts[0] + ' ***'; 68 | } 69 | return ipString.length > 10 ? ipString.substring(0, 10) + '***' : ipString; 70 | } 71 | 72 | return ipString; 73 | }; 74 | }, [privacySettings.hidePersonalIP]); 75 | 76 | return { 77 | privacySettings, 78 | setPrivacySettings, 79 | createHideStates, 80 | formatIpForDisplay 81 | }; 82 | }; 83 | 84 | export default usePrivacySettings; 85 | -------------------------------------------------------------------------------- /src/components/Settings/SettingsSidebar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | const SettingsSidebar = ({ selectedSection, onSectionChange }) => { 5 | const { t } = useTranslation(); 6 | 7 | const sections = { 8 | GENERAL: [ 9 | { id: 'basic', label: t('settings.basicSettings') }, 10 | { id: 'nodes', label: t('settings.nodesSettings') }, 11 | { id: 'advanced', label: t('settings.advancedSettings') }, 12 | { id: 'core', label: t('settings.coreManagement') }, 13 | ], 14 | OTHERS: [ 15 | { id: 'about', label: t('settings.about') }, 16 | ], 17 | }; 18 | 19 | return ( 20 |
27 | {Object.entries(sections).map(([category, items]) => ( 28 |
29 |
41 | {category} 42 |
43 | {items.map((item) => ( 44 |
onSectionChange(item.id)} 47 | style={{ 48 | padding: '8px 16px', 49 | cursor: 'pointer', 50 | backgroundColor: selectedSection === item.id ? '#e2e8f0' : 'transparent', 51 | color: selectedSection === item.id ? '#334155' : '#64748b', 52 | borderRadius: '6px', 53 | display: 'flex', 54 | alignItems: 'center', 55 | gap: '8px', 56 | transition: 'all 0.2s', 57 | fontWeight: selectedSection === item.id ? '500' : '400', 58 | position: 'relative', 59 | }} 60 | onMouseEnter={(e) => { 61 | if (selectedSection !== item.id) { 62 | e.currentTarget.style.backgroundColor = '#f1f5f9'; 63 | } 64 | }} 65 | onMouseLeave={(e) => { 66 | if (selectedSection !== item.id) { 67 | e.currentTarget.style.backgroundColor = 'transparent'; 68 | } 69 | }} 70 | > 71 | {item.label} 72 |
73 | ))} 74 |
75 | ))} 76 |
77 | ); 78 | }; 79 | 80 | export default SettingsSidebar; -------------------------------------------------------------------------------- /src/utils/event-bus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 全局事件总线 3 | * 用于解决模块间的循环依赖问题 4 | */ 5 | const { EventEmitter } = require('events'); 6 | 7 | class EventBus extends EventEmitter { 8 | constructor() { 9 | super(); 10 | this.setMaxListeners(100); // 增加监听器上限 11 | this.services = new Map(); // 服务容器 12 | this.initialized = false; 13 | } 14 | 15 | /** 16 | * 注册服务 17 | * @param {String} name 服务名称 18 | * @param {Function|Object} service 服务实例或工厂函数 19 | */ 20 | registerService(name, service) { 21 | this.services.set(name, service); 22 | } 23 | 24 | /** 25 | * 获取服务 26 | * @param {String} name 服务名称 27 | * @returns {Object} 服务实例 28 | */ 29 | getService(name) { 30 | const service = this.services.get(name); 31 | if (typeof service === 'function') { 32 | // 如果是工厂函数,执行并缓存结果 33 | const instance = service(); 34 | this.services.set(name, instance); 35 | return instance; 36 | } 37 | return service; 38 | } 39 | 40 | /** 41 | * 检查服务是否存在 42 | * @param {String} name 服务名称 43 | * @returns {Boolean} 44 | */ 45 | hasService(name) { 46 | return this.services.has(name); 47 | } 48 | 49 | /** 50 | * 安全地获取服务(如果不存在则返回null) 51 | * @param {String} name 服务名称 52 | * @returns {Object|null} 53 | */ 54 | safeGetService(name) { 55 | try { 56 | return this.getService(name); 57 | } catch (error) { 58 | console.warn(`获取服务 ${name} 失败:`, error.message); 59 | return null; 60 | } 61 | } 62 | 63 | /** 64 | * 发送状态变化事件 65 | * @param {String} type 事件类型 66 | * @param {Object} data 事件数据 67 | */ 68 | emitStateChange(type, data = {}) { 69 | this.emit('state-change', { type, data, timestamp: Date.now() }); 70 | this.emit(`state-change:${type}`, data); 71 | } 72 | 73 | /** 74 | * 监听状态变化 75 | * @param {String} type 事件类型,可选 76 | * @param {Function} callback 回调函数 77 | * @returns {Function} 取消监听的函数 78 | */ 79 | onStateChange(type, callback) { 80 | if (typeof type === 'function') { 81 | // 如果第一个参数是函数,则监听所有状态变化 82 | callback = type; 83 | this.on('state-change', callback); 84 | return () => this.removeListener('state-change', callback); 85 | } else { 86 | // 监听特定类型的状态变化 87 | const eventName = `state-change:${type}`; 88 | this.on(eventName, callback); 89 | return () => this.removeListener(eventName, callback); 90 | } 91 | } 92 | 93 | /** 94 | * 初始化事件总线 95 | */ 96 | initialize() { 97 | if (this.initialized) return; 98 | 99 | this.initialized = true; 100 | console.log('EventBus: 已初始化'); 101 | } 102 | 103 | /** 104 | * 清理事件总线 105 | */ 106 | destroy() { 107 | this.removeAllListeners(); 108 | this.services.clear(); 109 | this.initialized = false; 110 | console.log('EventBus: 已清理'); 111 | } 112 | } 113 | 114 | // 导出单例 115 | const eventBus = new EventBus(); 116 | module.exports = eventBus; -------------------------------------------------------------------------------- /src/main/ipc-handlers/traffic-stats-handlers.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const trafficStatsManager = require('../data-managers/traffic-stats-manager'); 3 | const logger = require('../../utils/logger'); 4 | 5 | function setup() { 6 | ipcMain.handle('traffic-stats:set-period', async (event, period) => { 7 | try { 8 | return trafficStatsManager.setStatsPeriod(period); 9 | } catch (error) { 10 | logger.error('设置流量统计周期失败:', error); 11 | return { success: false, error: error.message }; 12 | } 13 | }); 14 | 15 | ipcMain.handle('traffic-stats:get-period', async () => { 16 | try { 17 | return trafficStatsManager.getStatsPeriod(); 18 | } catch (error) { 19 | logger.error('获取流量统计周期失败:', error); 20 | return { success: false, error: error.message }; 21 | } 22 | }); 23 | 24 | ipcMain.handle('traffic-stats:update', async (event, { upload, download }) => { 25 | try { 26 | return trafficStatsManager.updateTraffic(upload, download); 27 | } catch (error) { 28 | logger.error('更新流量统计失败:', error); 29 | return { success: false, error: error.message }; 30 | } 31 | }); 32 | 33 | ipcMain.handle('traffic-stats:get-current', async () => { 34 | try { 35 | return trafficStatsManager.getCurrentPeriodStats(); 36 | } catch (error) { 37 | logger.error('获取当前周期流量统计失败:', error); 38 | return { success: false, error: error.message }; 39 | } 40 | }); 41 | 42 | ipcMain.handle('traffic-stats:get-history', async (event, limit) => { 43 | try { 44 | return trafficStatsManager.getHistoryStats(limit); 45 | } catch (error) { 46 | logger.error('获取历史流量统计失败:', error); 47 | return { success: false, error: error.message }; 48 | } 49 | }); 50 | 51 | ipcMain.handle('traffic-stats:reset-current', async () => { 52 | try { 53 | return trafficStatsManager.resetCurrentPeriod(); 54 | } catch (error) { 55 | logger.error('重置当前周期流量统计失败:', error); 56 | return { success: false, error: error.message }; 57 | } 58 | }); 59 | 60 | ipcMain.handle('traffic-stats:cleanup', async (event, retentionDays) => { 61 | try { 62 | return trafficStatsManager.cleanupOldData(retentionDays); 63 | } catch (error) { 64 | logger.error('清理过期流量统计数据失败:', error); 65 | return { success: false, error: error.message }; 66 | } 67 | }); 68 | 69 | ipcMain.handle('traffic-stats:set-retention-days', async (event, days) => { 70 | try { 71 | return trafficStatsManager.setRetentionDays(days); 72 | } catch (error) { 73 | logger.error('设置数据保留天数失败:', error); 74 | return { success: false, error: error.message }; 75 | } 76 | }); 77 | 78 | ipcMain.handle('traffic-stats:get-retention-days', async () => { 79 | try { 80 | return trafficStatsManager.getRetentionDays(); 81 | } catch (error) { 82 | logger.error('获取数据保留天数失败:', error); 83 | return { success: false, error: error.message }; 84 | } 85 | }); 86 | 87 | logger.info('流量统计 IPC 处理程序已注册'); 88 | } 89 | 90 | module.exports = { 91 | setup 92 | }; 93 | 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Lvory 4 | 5 | *跨平台 Singbox 客户端* 6 | 7 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sxueck_lvory&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=sxueck_lvory) 8 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sxueck_lvory&metric=bugs)](https://sonarcloud.io/summary/new_code?id=sxueck_lvory) 9 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=sxueck_lvory&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=sxueck_lvory) 10 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/sxueck/lvory/total?style=flat-square) 11 | 12 | [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) 13 | [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)]() 14 | 15 | [截图预览](docs/screenshot.md) • [文档](docs/) • [常见问题](docs/faq.md) 16 | 17 |
18 | 19 | ## 功能特性 20 | 21 | Lvory 是一个基于 Electron 开发的高度灵活的跨平台 SingBox 客户端,**面向具有自建节点能力的技术向用户**。 22 | 23 | ### 核心功能 24 | 25 | | 功能 | 描述 | 26 | |---------|-------------| 27 | | **自动内核管理** | 自动下载、安装和更新 SingBox 内核 | 28 | | **代理管理** | 一键启用/禁用系统代理,自动端口检测 | 29 | | **配置管理** | 多配置支持,自动解析节点并显示 | 30 | | **自动更新** | 定时自动更新配置文件 | 31 | | **活动日志** | 实时 SingBox 运行日志和系统活动 | 32 | | **高度灵活** | 基于文件操作,UI 辅助,最大化灵活性 | 33 | 34 | 35 | ## 预览 36 | 37 |
38 | 39 | ![仪表板](docs/screenshot/dashboard.png) 40 | 41 | *主仪表板界面* 42 | 43 |
44 | 45 | 更多截图请查看 [更多截图](docs/screenshot.md) 46 | 47 | 或者也可以通过 [#42](https://github.com/sxueck/lvory/issues/42) 查看程序实际运行的效果 48 | 49 | ## 快速开始 50 | 51 | ### 系统要求 52 | 53 | - **操作系统**: Windows 10+、macOS 10.15+ 或 Linux 54 | - **SingBox**: 由 Lvory 自动管理 55 | 56 | ### 安装 57 | 58 | 从 [GitHub Releases](https://github.com/sxueck/lvory/releases) 页面下载最新版本。 59 | 60 | 选择适合您操作系统的安装包,日常构建版本通常是基于每次 main 分支的提交都会进行一次编译,其会包含实时最新开发特性,但也包含例如无法启动或者包含严重 Bug 等风险,而 RC 版本则代表经过简单测试未发现问题后编译版本,Stable 则是正式可完全日常使用的版本 61 | 62 | ### 快速使用 63 | 64 | 1. **启动 Lvory** - 启动应用程序 65 | 2. **添加配置** - 导入您的 SingBox 配置文件 66 | 3. **启用代理** - 一键切换系统代理 67 | 4. **监控活动** - 查看实时日志和连接状态 68 | 69 | ### Lvory 协议支持 70 | 71 | Lvory 支持专门的同步协议,用于智能管理多源代理配置: 72 | 73 | - **多源节点池**:从多个订阅源自动获取和更新节点信息 74 | - **智能匹配**:通过节点名称等标识符自动匹配和同步节点 75 | - **声明式配置**:使用简单的配置文件管理复杂的代理架构 76 | - **自动同步**:定时检查配置源更新,保持配置最新状态 77 | 78 | 具体的声明和使用方式请参考:[Lvory 同步协议文档](docs/program/lvory-sync-protocol.md) 79 | 80 | ### 内核管理 81 | 82 | Lvory 会自动下载和管理 SingBox 内核,通常情况下用户无需关心这部分,但是同时也支持让用户自行下载或者更换内核,具体可以参考该 [issue](https://github.com/sxueck/lvory/issues/21) 83 | 84 | ### 架构文档 85 | 86 | - **[配置引擎设计 - Alpha](docs/program/profiles_engine.md)** 87 | 配置映射引擎原型和实现细节 88 | 89 | - **[节点评分算法 - Alpha](docs/program/node_score.md)** 90 | 代理节点评分算法和工作流程文档 91 | 92 | ### 开发环境搭建 93 | 94 | 具体的构建编译可以参考 Github Actions 声明 95 | 96 | ## 免责声明 97 | 98 | > **重要提示**: 使用前请仔细阅读。 99 | 100 | 1. **教学目的**: 该项目及其文档仅用于技术研究、讨论和学习目的,不构成商业或法律建议。 101 | 102 | 2. **无保证**: 作者和维护者对使用该项目可能造成的任何直接、间接、偶然或后果性损害、数据丢失或系统故障不承担责任。 103 | 104 | 3. **法律合规**: 该项目不得用于任何非法、未经授权或违反法规的活动。此类误用产生的责任完全由用户承担。 105 | 106 | 4. **用户责任**: 用户在使用该项目时有责任确保遵守所有适用的法律、法规和行业标准。 107 | 108 | ## 许可证 109 | 110 | 该项目基于 Apache License 2.0 许可证 - 详情请查看 [LICENSE](LICENSE) 文件。 111 | -------------------------------------------------------------------------------- /src/assets/css/customerCard.css: -------------------------------------------------------------------------------- 1 | .customer-card { 2 | background-color: #fff; 3 | border-radius: 6px; 4 | padding: 10px; 5 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 6 | width: 90%; 7 | margin: 0 auto; 8 | } 9 | 10 | .gray-border-card { 11 | border: 1.5px solid #ccc; 12 | } 13 | 14 | .card-header { 15 | display: flex; 16 | justify-content: space-between; 17 | align-items: center; 18 | margin-bottom: 6px; 19 | } 20 | 21 | .customer-name { 22 | font-size: 12px; 23 | font-weight: 400; 24 | } 25 | 26 | .more-options { 27 | color: #999; 28 | font-size: 14px; 29 | padding: 0 2px; 30 | cursor: pointer; 31 | } 32 | 33 | .customer-description { 34 | color: #666; 35 | margin-bottom: 8px; 36 | line-height: 1.2; 37 | font-size: 11px; 38 | } 39 | 40 | .card-footer { 41 | display: flex; 42 | justify-content: space-between; 43 | align-items: center; 44 | font-size: 10px; 45 | color: #666; 46 | } 47 | 48 | .date-info { 49 | display: flex; 50 | align-items: center; 51 | gap: 3px; 52 | } 53 | 54 | .interaction-stats { 55 | display: flex; 56 | gap: 6px; 57 | } 58 | 59 | .stat { 60 | display: flex; 61 | align-items: center; 62 | gap: 3px; 63 | } 64 | 65 | .icon { 66 | width: 16px; 67 | height: 16px; 68 | display: inline-block; 69 | margin-right: 5px; 70 | } 71 | 72 | .calendar-icon { 73 | background-color: #e0e0e0; 74 | mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z'/%3E%3C/svg%3E") no-repeat center / contain; 75 | -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z'/%3E%3C/svg%3E") no-repeat center / contain; 76 | } 77 | 78 | .contact-icon { 79 | background-color: #e0e0e0; 80 | mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E") no-repeat center / contain; 81 | -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E") no-repeat center / contain; 82 | } 83 | 84 | .message-icon { 85 | display: inline-block; 86 | width: 14px; 87 | height: 14px; 88 | background-color: #e0e0e0; 89 | mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z'/%3E%3C/svg%3E") no-repeat center / contain; 90 | -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath d='M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z'/%3E%3C/svg%3E") no-repeat center / contain; 91 | } -------------------------------------------------------------------------------- /src/services/ip/IPService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IP地理位置服务 3 | * 基于 https://ip.sb/api/ 提供的API 4 | */ 5 | 6 | class IPService { 7 | /** 8 | * 获取当前出口IP地址信息 9 | * @returns {Promise} 包含IP和地理位置信息的对象 10 | */ 11 | static async getIPInfo() { 12 | try { 13 | const response = await fetch('https://api.ip.sb/geoip', { 14 | headers: { 15 | 'User-Agent': 'Mozilla/5.0', 16 | 'Accept': 'application/json' 17 | } 18 | }); 19 | 20 | if (!response.ok) { 21 | throw new Error(`IP查询失败: ${response.status}`); 22 | } 23 | 24 | const data = await response.json(); 25 | 26 | // 确保返回的数据包含经纬度信息 27 | return { 28 | ip: data.ip || '未知', 29 | country: data.country || '未知', 30 | city: data.city || '', 31 | region: data.region || '', 32 | latitude: data.latitude || 0, 33 | longitude: data.longitude || 0, 34 | asn: data.asn || '', 35 | organization: data.organization || '' 36 | }; 37 | } catch (error) { 38 | console.error('获取IP地理位置信息失败:', error); 39 | return { 40 | ip: '未知', 41 | country: '未知', 42 | city: '', 43 | region: '', 44 | latitude: 0, 45 | longitude: 0, 46 | asn: '', 47 | organization: '' 48 | }; 49 | } 50 | } 51 | 52 | /** 53 | * 获取IP地址的格式化地理位置信息 54 | * @returns {Promise} 格式化的地理位置信息 55 | */ 56 | static async getLocationString() { 57 | try { 58 | const ipInfo = await this.getIPInfo(); 59 | 60 | // 组合地理位置信息 61 | const locationParts = []; 62 | 63 | if (ipInfo.ip) { 64 | locationParts.push(ipInfo.ip); 65 | } 66 | 67 | const locationInfo = []; 68 | if (ipInfo.country) { 69 | locationInfo.push(ipInfo.country); 70 | } 71 | if (ipInfo.region && ipInfo.region !== ipInfo.country) { 72 | locationInfo.push(ipInfo.region); 73 | } 74 | if (ipInfo.city) { 75 | locationInfo.push(ipInfo.city); 76 | } 77 | 78 | if (locationInfo.length > 0) { 79 | locationParts.push(`(${locationInfo.join(', ')})`); 80 | } 81 | 82 | return locationParts.join(' '); 83 | } catch (error) { 84 | console.error('获取地理位置字符串失败:', error); 85 | return '未知位置'; 86 | } 87 | } 88 | 89 | /** 90 | * 获取IP的ASN组织信息 91 | * @returns {Promise} 格式化的ASN和组织信息 92 | */ 93 | static async getAsnString() { 94 | try { 95 | const ipInfo = await this.getIPInfo(); 96 | 97 | // 组合ASN和组织信息 98 | const asnParts = []; 99 | 100 | if (ipInfo.ip) { 101 | asnParts.push(ipInfo.ip); 102 | } 103 | 104 | const asnInfo = []; 105 | if (ipInfo.asn) { 106 | asnInfo.push(`ASN: ${ipInfo.asn}`); 107 | } 108 | if (ipInfo.organization) { 109 | asnInfo.push(ipInfo.organization); 110 | } 111 | 112 | if (asnInfo.length > 0) { 113 | asnParts.push(`(${asnInfo.join(' | ')})`); 114 | } 115 | 116 | return asnParts.join(' '); 117 | } catch (error) { 118 | console.error('获取ASN信息失败:', error); 119 | return '未知ASN信息'; 120 | } 121 | } 122 | } 123 | 124 | export default IPService; -------------------------------------------------------------------------------- /src/components/Dashboard/hooks/useProfileUpdate.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | const useProfileUpdate = (setProfileData) => { 4 | const [updateInterval, setUpdateInterval] = useState('0'); // '0'表示不自动更新 5 | const updateTimerRef = useRef(null); 6 | 7 | // 设置配置文件更新定时器 8 | const setupUpdateTimer = (url, fileName) => { 9 | if (updateTimerRef.current) { 10 | clearInterval(updateTimerRef.current); 11 | } 12 | 13 | if (updateInterval === '0') { 14 | return; 15 | } 16 | 17 | // 计算间隔毫秒数 18 | let intervalMs; 19 | if (updateInterval.endsWith('h')) { 20 | // 小时格式,如 "12h" 21 | intervalMs = parseInt(updateInterval.replace('h', '')) * 60 * 60 * 1000; 22 | } else if (updateInterval.endsWith('d')) { 23 | // 天数格式,如 "7d" 24 | intervalMs = parseInt(updateInterval.replace('d', '')) * 24 * 60 * 60 * 1000; 25 | } else { 26 | // 兼容旧格式(纯数字,按小时计算) 27 | intervalMs = parseInt(updateInterval) * 60 * 60 * 1000; 28 | } 29 | 30 | updateTimerRef.current = setInterval(() => { 31 | console.log(`自动更新配置文件: ${fileName} - ${new Date().toLocaleString()}`); 32 | downloadProfileSilently(url, fileName); 33 | }, intervalMs); 34 | 35 | const displayText = updateInterval.endsWith('h') ? 36 | updateInterval.replace('h', '小时') : 37 | updateInterval.endsWith('d') ? 38 | updateInterval.replace('d', '天') : 39 | `${updateInterval}小时`; 40 | 41 | console.log(`已设置自动更新定时器,间隔 ${displayText}`); 42 | }; 43 | 44 | const downloadProfileSilently = (url, customFileName) => { 45 | if (!url || !customFileName) return; 46 | 47 | if (window.electron) { 48 | window.electron.profiles.update(customFileName) 49 | .then(result => { 50 | console.log('自动更新结果:', result); 51 | if (result.success) { 52 | // 重新获取配置文件数据 53 | window.electron.profiles.getData().then((data) => { 54 | if (data && data.success && Array.isArray(data.profiles)) { 55 | setProfileData(data.profiles); 56 | } 57 | }).catch(err => { 58 | console.error('Failed to get profile data:', err); 59 | }); 60 | } else { 61 | console.error('自动更新失败:', result.error || result.message); 62 | } 63 | }) 64 | .catch(error => { 65 | console.error('自动更新失败:', error); 66 | }); 67 | } 68 | }; 69 | 70 | // 处理下载成功后的回调 71 | const handleDownloadSuccess = (url, fileName, interval, protocolType) => { 72 | setUpdateInterval(interval); 73 | // 设置定时更新(如果有的话) 74 | if (interval !== '0') { 75 | setupUpdateTimer(url, fileName); 76 | } 77 | 78 | // 如果提供了协议类型,通知 Profiles 组件刷新数据 79 | if (protocolType && window.electron && window.electron.profiles && window.electron.profiles.setUserProtocolChoice) { 80 | window.electron.profiles.setUserProtocolChoice(fileName, protocolType); 81 | } 82 | }; 83 | 84 | // 清除定时器 85 | useEffect(() => { 86 | return () => { 87 | if (updateTimerRef.current) { 88 | clearInterval(updateTimerRef.current); 89 | } 90 | }; 91 | }, []); 92 | 93 | return { 94 | updateInterval, 95 | setUpdateInterval, 96 | setupUpdateTimer, 97 | handleDownloadSuccess 98 | }; 99 | }; 100 | 101 | export default useProfileUpdate; -------------------------------------------------------------------------------- /src/components/Activity/ConnectionLogItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { formatTimestamp } from '../../utils/formatters'; 3 | 4 | const logColors = { 5 | info: '#4CAF50', 6 | warning: '#FF9800', 7 | error: '#F44336', 8 | debug: '#2196F3', 9 | INFO: '#4CAF50', 10 | WARN: '#FF9800', 11 | ERROR: '#F44336', 12 | DEBUG: '#2196F3', 13 | }; 14 | 15 | const directionIcons = { 16 | inbound: '⬇️', 17 | outbound: '⬆️' 18 | }; 19 | 20 | 21 | 22 | // 解析连接日志格式 23 | const parseConnectionLog = (payload) => { 24 | if (!payload) return null; 25 | 26 | // 解析格式: [sessionId delay] direction/networkType[nodeGroup]: connection info 27 | const logMatch = payload.match(/\[(\d+)\s+([^\]]+)\]\s+(inbound|outbound)\/(\w+)\[([^\]]+)\]:\s+(.+)/); 28 | 29 | if (!logMatch) { 30 | return { 31 | sessionId: '', 32 | delay: '', 33 | direction: '', 34 | networkType: '', 35 | nodeGroup: '', 36 | connectionInfo: payload, 37 | domain: '', 38 | originalPayload: payload 39 | }; 40 | } 41 | 42 | const [, sessionId, delay, direction, networkType, nodeGroup, connectionInfo] = logMatch; 43 | 44 | // 提取地址信息 45 | let address = ''; 46 | if (connectionInfo.includes('connection to')) { 47 | const addressMatch = connectionInfo.match(/connection to\s+([^\s]+)/); 48 | if (addressMatch) { 49 | address = addressMatch[1]; 50 | } 51 | } else if (connectionInfo.includes('connection from')) { 52 | const addressMatch = connectionInfo.match(/connection from\s+([^\s]+)/); 53 | if (addressMatch) { 54 | address = addressMatch[1]; 55 | } 56 | } 57 | 58 | return { 59 | sessionId, 60 | delay, 61 | direction, 62 | networkType, 63 | nodeGroup, 64 | connectionInfo, 65 | address, 66 | originalPayload: payload 67 | }; 68 | }; 69 | 70 | const ConnectionLogItem = ({ log, index }) => { 71 | if (!log) return null; 72 | 73 | // Ensure log.type is a string before calling toLowerCase() 74 | const logType = log.type || 'info'; 75 | const type = typeof logType === 'string' ? logType.toLowerCase() : String(logType).toLowerCase(); 76 | const payload = log.payload || log.originalPayload || ''; 77 | 78 | // 解析连接日志 79 | const parsed = parseConnectionLog(payload); 80 | if (!parsed) return null; 81 | 82 | const { sessionId, delay, direction, networkType, nodeGroup, address } = parsed; 83 | 84 | // 过滤掉地址为空的连接日志 85 | if (!address || address.trim() === '') { 86 | return null; 87 | } 88 | 89 | const getDirectionDisplay = (dir) => { 90 | if (dir === 'inbound') return ; 91 | if (dir === 'outbound') return ; 92 | return ·; 93 | }; 94 | 95 | return ( 96 |
97 |
98 | {formatTimestamp(log.timestamp)} 99 |
100 |
101 | {getDirectionDisplay(direction)} 102 |
103 |
104 | {address} 105 |
106 |
107 | {nodeGroup} 108 |
109 |
110 | {networkType} 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default ConnectionLogItem; -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 20 | LVORY 21 | 63 | 64 | 65 |
66 | 67 |
68 |

应用正在加载中...

69 |
如果长时间未加载完成,请尝试重启应用
70 |
71 |
72 |
73 | 74 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/assets/css/messagebox.css: -------------------------------------------------------------------------------- 1 | .messagebox-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | backdrop-filter: blur(20px) brightness(1.1); 8 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 9 | background-color: rgba(255, 255, 255, 0.3); 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | z-index: 1000; 14 | animation: messageboxOverlayFadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); 15 | } 16 | 17 | .messagebox-overlay.main-content-modal { 18 | position: absolute; 19 | top: 0; 20 | left: 0; 21 | right: 0; 22 | bottom: 0; 23 | backdrop-filter: blur(20px) brightness(1.1); 24 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 25 | background-color: rgba(255, 255, 255, 0.3); 26 | border-radius: 0; 27 | z-index: 100; 28 | pointer-events: all; 29 | animation: mainContentModalFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); 30 | } 31 | 32 | /* 确保消息框容器的指针事件正常 */ 33 | #message-box-container { 34 | pointer-events: none; 35 | } 36 | 37 | #message-box-container .messagebox-overlay { 38 | pointer-events: all; 39 | } 40 | 41 | .messagebox-container { 42 | background-color: white; 43 | border-radius: 8px; 44 | box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); 45 | max-width: 90%; 46 | min-width: 260px; 47 | overflow: hidden; 48 | animation: message-pop-in 0.2s cubic-bezier(0.4, 0, 0.2, 1); 49 | will-change: transform, opacity; 50 | transform: translateZ(0); 51 | } 52 | 53 | /* main-content 区域内的模态弹窗容器样式 */ 54 | .messagebox-overlay.main-content-modal .messagebox-container { 55 | background-color: rgba(255, 255, 255, 0.95); 56 | backdrop-filter: blur(10px) brightness(1.02); 57 | -webkit-backdrop-filter: blur(10px) brightness(1.02); 58 | border: 1px solid rgba(255, 255, 255, 0.3); 59 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.05); 60 | border-radius: 12px; 61 | max-width: 85%; 62 | min-width: 260px; 63 | } 64 | 65 | @keyframes message-pop-in { 66 | 0% { 67 | transform: scale(0.8) translateZ(0); 68 | opacity: 0; 69 | } 70 | 100% { 71 | transform: scale(1) translateZ(0); 72 | opacity: 1; 73 | } 74 | } 75 | 76 | @keyframes messageboxOverlayFadeIn { 77 | from { 78 | opacity: 0; 79 | backdrop-filter: blur(0px) brightness(1); 80 | -webkit-backdrop-filter: blur(0px) brightness(1); 81 | } 82 | to { 83 | opacity: 1; 84 | backdrop-filter: blur(20px) brightness(1.1); 85 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 86 | } 87 | } 88 | 89 | 90 | 91 | @keyframes mainContentModalFadeIn { 92 | from { 93 | opacity: 0; 94 | backdrop-filter: blur(0px) brightness(1); 95 | -webkit-backdrop-filter: blur(0px) brightness(1); 96 | } 97 | to { 98 | opacity: 1; 99 | backdrop-filter: blur(20px) brightness(1.1); 100 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 101 | } 102 | } 103 | 104 | .messagebox-content { 105 | padding: 20px; 106 | text-align: center; 107 | } 108 | 109 | .messagebox-content p { 110 | margin: 0; 111 | color: #333; 112 | font-size: 14px; 113 | line-height: 1.5; 114 | word-break: break-all; 115 | } 116 | 117 | .messagebox-footer { 118 | padding: 12px 20px; 119 | display: flex; 120 | justify-content: flex-end; 121 | border-top: 1px solid #eee; 122 | } 123 | 124 | .messagebox-button { 125 | background-color: #3e4c6d; 126 | color: white; 127 | border: none; 128 | border-radius: 4px; 129 | padding: 5px 14px; 130 | font-size: 12px; 131 | cursor: pointer; 132 | transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1); 133 | will-change: background-color; 134 | transform: translateZ(0); 135 | width: 70px; 136 | height: 26px; 137 | } 138 | 139 | .messagebox-button:hover { 140 | background-color: #2e3b52; 141 | } -------------------------------------------------------------------------------- /src/main/ipc/handlers/window.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 窗口相关IPC处理程序 3 | */ 4 | const { ipcMain } = require('electron'); 5 | const logger = require('../../../utils/logger'); 6 | const utils = require('../utils'); 7 | const { WINDOW } = require('../constants'); 8 | const singbox = require('../../../utils/sing-box'); 9 | const settingsManager = require('../../settings-manager'); 10 | 11 | /** 12 | * 处理窗口控制命令 13 | * @param {Event} event IPC事件 14 | * @param {Object} params 参数对象 15 | * @param {String} params.action 动作类型: minimize, maximize, close 16 | */ 17 | async function handleWindowControl(event, params) { 18 | const { action } = params; 19 | const mainWindow = utils.getMainWindow(); 20 | if (!mainWindow) return; 21 | 22 | logger.info(`窗口控制: ${action}`); 23 | 24 | switch (action) { 25 | case 'minimize': 26 | // 改为隐藏窗口而不是最小化 27 | mainWindow.hide(); 28 | break; 29 | case 'maximize': 30 | if (mainWindow.isMaximized()) { 31 | mainWindow.restore(); 32 | // 确保恢复后的窗口不小于最小尺寸 33 | const [width, height] = mainWindow.getSize(); 34 | if (width < 800 || height < 600) { 35 | mainWindow.setSize(Math.max(width, 800), Math.max(height, 600)); 36 | } 37 | } else { 38 | mainWindow.maximize(); 39 | } 40 | break; 41 | case 'close': 42 | // 检查仅前台运行设置 43 | const settings = settingsManager.getSettings(); 44 | if (settings.foregroundOnly) { 45 | // 仅前台运行模式:执行退出操作 46 | try { 47 | logger.info('仅前台运行模式,从窗口控制退出程序'); 48 | await singbox.disableSystemProxy(); 49 | await singbox.stopCore(); 50 | global.isQuitting = true; 51 | require('electron').app.quit(); 52 | } catch (error) { 53 | logger.error('退出前清理失败:', error); 54 | global.isQuitting = true; 55 | require('electron').app.quit(); 56 | } 57 | } else { 58 | // 正常模式:隐藏窗口 59 | mainWindow.hide(); 60 | } 61 | break; 62 | default: 63 | logger.warn(`未知的窗口控制命令: ${action}`); 64 | } 65 | } 66 | 67 | /** 68 | * 处理窗口操作请求 69 | * @param {Event} event IPC事件 70 | * @param {Object} params 参数对象 71 | * @param {String} params.type 操作类型: show, quit 72 | * @returns {Promise} 操作结果 73 | */ 74 | async function handleWindowAction(event, params) { 75 | const { type } = params; 76 | logger.info(`窗口操作: ${type}`); 77 | 78 | try { 79 | switch (type) { 80 | case 'show': 81 | const windowManager = require('../../window'); 82 | windowManager.showWindow(); 83 | return utils.createSuccess(); 84 | 85 | case 'quit': 86 | // 退出前清理 87 | await singbox.disableSystemProxy(); 88 | await singbox.stopCore(); 89 | 90 | // 标记为真正退出 91 | global.isQuitting = true; 92 | require('electron').app.quit(); 93 | return utils.createSuccess(); 94 | 95 | default: 96 | logger.warn(`未知的窗口操作类型: ${type}`); 97 | return { 98 | success: false, 99 | error: `未支持的窗口操作: ${type}` 100 | }; 101 | } 102 | } catch (error) { 103 | return utils.handleError(error, `窗口操作(${type})`); 104 | } 105 | } 106 | 107 | /** 108 | * 设置窗口相关IPC处理程序 109 | */ 110 | function setup() { 111 | // 注册窗口控制事件 (send模式) 112 | ipcMain.on(WINDOW.CONTROL, handleWindowControl); 113 | 114 | // 注册窗口操作事件 (invoke模式) 115 | ipcMain.handle(WINDOW.ACTION, handleWindowAction); 116 | 117 | logger.info('窗口IPC处理程序已设置'); 118 | } 119 | 120 | /** 121 | * 清理窗口相关IPC处理程序 122 | */ 123 | function cleanup() { 124 | ipcMain.removeListener(WINDOW.CONTROL, handleWindowControl); 125 | ipcMain.removeHandler(WINDOW.ACTION); 126 | 127 | logger.info('窗口IPC处理程序已清理'); 128 | } 129 | 130 | module.exports = { 131 | setup, 132 | cleanup 133 | }; -------------------------------------------------------------------------------- /resource/icon/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/messageBox.js: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import React, { useState, useEffect } from 'react'; 3 | import MessageBox from '../components/MessageBox'; 4 | 5 | // 创建一个容器并挂载到DOM 6 | let messageBoxRoot = null; 7 | let messageBoxContainer = null; 8 | 9 | // 确保只初始化一次 10 | function ensureContainer() { 11 | if (!messageBoxContainer) { 12 | messageBoxContainer = document.createElement('div'); 13 | messageBoxContainer.id = 'message-box-container'; 14 | messageBoxContainer.style.position = 'absolute'; 15 | messageBoxContainer.style.top = '0'; 16 | messageBoxContainer.style.left = '0'; 17 | messageBoxContainer.style.right = '0'; 18 | messageBoxContainer.style.bottom = '0'; 19 | messageBoxContainer.style.pointerEvents = 'none'; 20 | messageBoxContainer.style.zIndex = '100'; 21 | 22 | // 将容器添加到 body,但样式会让它定位到 main-content 23 | document.body.appendChild(messageBoxContainer); 24 | messageBoxRoot = createRoot(messageBoxContainer); 25 | } 26 | 27 | // 每次调用时检查是否需要重新定位到 main-content 28 | const mainContent = document.querySelector('.main-content'); 29 | if (mainContent && messageBoxContainer.parentNode !== mainContent) { 30 | mainContent.appendChild(messageBoxContainer); 31 | } 32 | } 33 | 34 | // MessageBox状态管理组件 35 | const MessageBoxManager = () => { 36 | const [state, setState] = useState({ 37 | isOpen: false, 38 | message: '', 39 | onClose: null, 40 | queue: [] 41 | }); 42 | 43 | // 显示下一个消息 44 | const showNextMessage = () => { 45 | if (state.queue.length > 0) { 46 | const nextMessage = state.queue[0]; 47 | const newQueue = state.queue.slice(1); 48 | setState({ 49 | ...state, 50 | isOpen: true, 51 | message: nextMessage.message, 52 | onClose: () => { 53 | if (nextMessage.onClose) { 54 | nextMessage.onClose(); 55 | } 56 | handleClose(newQueue); 57 | }, 58 | queue: newQueue 59 | }); 60 | } 61 | }; 62 | 63 | // 关闭当前消息 64 | const handleClose = (newQueue) => { 65 | setState({ 66 | ...state, 67 | isOpen: false, 68 | message: '', 69 | onClose: null, 70 | queue: newQueue || state.queue 71 | }); 72 | 73 | // 设置延迟以确保关闭动画完成后再显示下一个消息 74 | setTimeout(() => { 75 | if ((newQueue || state.queue).length > 0) { 76 | showNextMessage(); 77 | } 78 | }, 300); 79 | }; 80 | 81 | // 添加新消息到队列 82 | window.showMessage = (message, onClose) => { 83 | const newMessage = { message, onClose }; 84 | 85 | if (!state.isOpen) { 86 | setState({ 87 | ...state, 88 | isOpen: true, 89 | message: newMessage.message, 90 | onClose: () => { 91 | if (newMessage.onClose) { 92 | newMessage.onClose(); 93 | } 94 | handleClose(); 95 | } 96 | }); 97 | } else { 98 | setState({ 99 | ...state, 100 | queue: [...state.queue, newMessage] 101 | }); 102 | } 103 | }; 104 | 105 | return ( 106 | 111 | ); 112 | }; 113 | 114 | // 初始化并渲染MessageBoxManager 115 | export function initMessageBox() { 116 | ensureContainer(); 117 | messageBoxRoot.render(); 118 | } 119 | 120 | // 显示消息的全局方法 121 | export function showMessage(message, onClose) { 122 | ensureContainer(); 123 | if (window.showMessage) { 124 | window.showMessage(message, onClose); 125 | } else { 126 | setTimeout(() => { 127 | if (window.showMessage) { 128 | window.showMessage(message, onClose); 129 | } else { 130 | console.warn('MessageBox not initialized properly'); 131 | alert(message); 132 | } 133 | }, 100); 134 | } 135 | } -------------------------------------------------------------------------------- /src/utils/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 数据存储模块 3 | * 用于持久化存储应用数据 4 | */ 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const { app } = require('electron'); 8 | const os = require('os'); 9 | const { getAppDataDir, getStorePath } = require('./paths'); 10 | 11 | /** 12 | * 存储数据 13 | * @param {String} key 数据键 14 | * @param {Any} value 数据值 15 | * @returns {Promise} 是否成功 16 | */ 17 | async function set(key, value) { 18 | try { 19 | const storePath = getStorePath(); 20 | 21 | let storeData = {}; 22 | // 如果文件存在,先读取现有数据 23 | if (fs.existsSync(storePath)) { 24 | try { 25 | const fileContent = fs.readFileSync(storePath, 'utf8'); 26 | storeData = JSON.parse(fileContent); 27 | } catch (e) { 28 | console.error(`读取存储文件失败: ${e.message}`); 29 | // 文件可能损坏,使用空对象 30 | storeData = {}; 31 | } 32 | } 33 | 34 | // 更新数据 35 | const keys = key.split('.'); 36 | let current = storeData; 37 | 38 | // 处理嵌套键 39 | for (let i = 0; i < keys.length - 1; i++) { 40 | const k = keys[i]; 41 | if (!current[k] || typeof current[k] !== 'object') { 42 | current[k] = {}; 43 | } 44 | current = current[k]; 45 | } 46 | 47 | // 设置最终值 48 | current[keys[keys.length - 1]] = value; 49 | 50 | // 写入文件 51 | fs.writeFileSync(storePath, JSON.stringify(storeData, null, 2), 'utf8'); 52 | return true; 53 | } catch (error) { 54 | console.error(`存储数据失败: ${error.message}`); 55 | return false; 56 | } 57 | } 58 | 59 | /** 60 | * 获取数据 61 | * @param {String} key 数据键 62 | * @returns {Promise} 数据值 63 | */ 64 | async function get(key) { 65 | try { 66 | const storePath = getStorePath(); 67 | 68 | // 如果文件不存在,返回null 69 | if (!fs.existsSync(storePath)) { 70 | return null; 71 | } 72 | 73 | // 读取文件 74 | const fileContent = fs.readFileSync(storePath, 'utf8'); 75 | const storeData = JSON.parse(fileContent); 76 | 77 | // 处理嵌套键 78 | const keys = key.split('.'); 79 | let current = storeData; 80 | 81 | for (const k of keys) { 82 | if (current === null || current === undefined || !Object.hasOwn(current, k)) { 83 | return null; 84 | } 85 | current = current[k]; 86 | } 87 | 88 | return current; 89 | } catch (error) { 90 | console.error(`获取数据失败: ${error.message}`); 91 | return null; 92 | } 93 | } 94 | 95 | /** 96 | * 删除数据 97 | * @param {String} key 数据键 98 | * @returns {Promise} 是否成功 99 | */ 100 | async function remove(key) { 101 | try { 102 | const storePath = getStorePath(); 103 | 104 | // 如果文件不存在,直接返回成功 105 | if (!fs.existsSync(storePath)) { 106 | return true; 107 | } 108 | 109 | // 读取文件 110 | const fileContent = fs.readFileSync(storePath, 'utf8'); 111 | const storeData = JSON.parse(fileContent); 112 | 113 | // 处理嵌套键 114 | const keys = key.split('.'); 115 | let current = storeData; 116 | 117 | // 导航到倒数第二层 118 | for (let i = 0; i < keys.length - 1; i++) { 119 | const k = keys[i]; 120 | if (current === null || current === undefined || !Object.hasOwn(current, k)) { 121 | // 键路径不存在,视为删除成功 122 | return true; 123 | } 124 | current = current[k]; 125 | } 126 | 127 | // 删除最后一个键 128 | const lastKey = keys[keys.length - 1]; 129 | if (current && typeof current === 'object' && Object.hasOwn(current, lastKey)) { 130 | delete current[lastKey]; 131 | } 132 | 133 | // 写入文件 134 | fs.writeFileSync(storePath, JSON.stringify(storeData, null, 2), 'utf8'); 135 | return true; 136 | } catch (error) { 137 | console.error(`删除数据失败: ${error.message}`); 138 | return false; 139 | } 140 | } 141 | 142 | module.exports = { 143 | set, 144 | get, 145 | remove 146 | }; -------------------------------------------------------------------------------- /src/utils/ipcOptimizer.js: -------------------------------------------------------------------------------- 1 | class IPCOptimizer { 2 | constructor() { 3 | this.cache = new Map(); 4 | this.pendingRequests = new Map(); 5 | this.debounceTimers = new Map(); 6 | this.lastRequestTime = new Map(); 7 | 8 | this.CACHE_TTL = 5000; 9 | this.DEBOUNCE_DELAY = 100; 10 | this.MIN_REQUEST_INTERVAL = 500; 11 | } 12 | 13 | getCacheKey(channel, args) { 14 | return `${channel}:${JSON.stringify(args)}`; 15 | } 16 | 17 | isValidCache(cacheEntry) { 18 | return Date.now() - cacheEntry.timestamp < this.CACHE_TTL; 19 | } 20 | 21 | async invoke(channel, ...args) { 22 | const cacheKey = this.getCacheKey(channel, args); 23 | 24 | const cachedResult = this.cache.get(cacheKey); 25 | if (cachedResult && this.isValidCache(cachedResult)) { 26 | return cachedResult.data; 27 | } 28 | 29 | if (this.pendingRequests.has(cacheKey)) { 30 | return this.pendingRequests.get(cacheKey); 31 | } 32 | 33 | const lastRequestTime = this.lastRequestTime.get(channel) || 0; 34 | const timeSinceLastRequest = Date.now() - lastRequestTime; 35 | 36 | if (timeSinceLastRequest < this.MIN_REQUEST_INTERVAL) { 37 | await new Promise(resolve => 38 | setTimeout(resolve, this.MIN_REQUEST_INTERVAL - timeSinceLastRequest) 39 | ); 40 | } 41 | 42 | const requestPromise = this.executeRequest(channel, args, cacheKey); 43 | this.pendingRequests.set(cacheKey, requestPromise); 44 | 45 | try { 46 | const result = await requestPromise; 47 | this.cache.set(cacheKey, { 48 | data: result, 49 | timestamp: Date.now() 50 | }); 51 | this.lastRequestTime.set(channel, Date.now()); 52 | return result; 53 | } finally { 54 | this.pendingRequests.delete(cacheKey); 55 | } 56 | } 57 | 58 | async executeRequest(channel, args, cacheKey) { 59 | if (!window.electron) { 60 | throw new Error('Electron IPC not available'); 61 | } 62 | 63 | if (channel.includes('.')) { 64 | const [namespace, method] = channel.split('.'); 65 | if (window.electron[namespace] && typeof window.electron[namespace][method] === 'function') { 66 | return window.electron[namespace][method](...args); 67 | } 68 | } 69 | 70 | if (window.electron.invoke) { 71 | return window.electron.invoke(channel, ...args); 72 | } 73 | 74 | throw new Error(`No handler found for channel: ${channel}`); 75 | } 76 | 77 | debounce(channel, callback, delay = this.DEBOUNCE_DELAY) { 78 | const existingTimer = this.debounceTimers.get(channel); 79 | if (existingTimer) { 80 | clearTimeout(existingTimer); 81 | } 82 | 83 | const timer = setTimeout(() => { 84 | callback(); 85 | this.debounceTimers.delete(channel); 86 | }, delay); 87 | 88 | this.debounceTimers.set(channel, timer); 89 | } 90 | 91 | invalidateCache(pattern) { 92 | if (typeof pattern === 'string') { 93 | for (const key of this.cache.keys()) { 94 | if (key.startsWith(pattern)) { 95 | this.cache.delete(key); 96 | } 97 | } 98 | } else { 99 | this.cache.clear(); 100 | } 101 | } 102 | 103 | clearPendingRequests() { 104 | this.pendingRequests.clear(); 105 | } 106 | 107 | cleanup() { 108 | this.cache.clear(); 109 | this.pendingRequests.clear(); 110 | 111 | for (const timer of this.debounceTimers.values()) { 112 | clearTimeout(timer); 113 | } 114 | this.debounceTimers.clear(); 115 | } 116 | } 117 | 118 | export const ipcOptimizer = new IPCOptimizer(); 119 | 120 | export const optimizedInvoke = (channel, ...args) => { 121 | return ipcOptimizer.invoke(channel, ...args); 122 | }; 123 | 124 | export const debouncedCall = (channel, callback, delay) => { 125 | return ipcOptimizer.debounce(channel, callback, delay); 126 | }; 127 | 128 | export const invalidateIPCCache = (pattern) => { 129 | return ipcOptimizer.invalidateCache(pattern); 130 | }; 131 | -------------------------------------------------------------------------------- /flatpak/lvory-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # lvory Flatpak 启动包装脚本 3 | # 此脚本处理 Flatpak 沙盒环境中的应用程序启动 4 | 5 | set -e 6 | 7 | # 调试信息 8 | DEBUG=${LVORY_DEBUG:-0} 9 | if [ "$DEBUG" = "1" ]; then 10 | set -x 11 | echo "lvory Flatpak 启动脚本开始执行" 12 | fi 13 | 14 | # 环境变量设置 15 | export ELECTRON_IS_DEV=0 16 | export ELECTRON_TRASH=gio 17 | export ELECTRON_DISABLE_SECURITY_WARNINGS=1 18 | 19 | # Flatpak 应用数据目录 20 | FLATPAK_APP_DATA="$HOME/.var/app/com.lvory.app" 21 | FLATPAK_CONFIG_DIR="$FLATPAK_APP_DATA/config/lvory" 22 | FLATPAK_DATA_DIR="$FLATPAK_APP_DATA/data/lvory" 23 | FLATPAK_CORES_DIR="$FLATPAK_DATA_DIR/cores" 24 | 25 | # 创建必要的目录结构 26 | mkdir -p "$FLATPAK_CONFIG_DIR" 27 | mkdir -p "$FLATPAK_DATA_DIR" 28 | mkdir -p "$FLATPAK_CORES_DIR" 29 | mkdir -p "$FLATPAK_DATA_DIR/logs" 30 | mkdir -p "$FLATPAK_DATA_DIR/bin" 31 | 32 | # 便携模式兼容性处理 33 | # 在 Flatpak 环境中模拟便携模式的目录结构 34 | if [ ! -L "$FLATPAK_DATA_DIR/data" ]; then 35 | ln -sf "$FLATPAK_DATA_DIR" "$FLATPAK_DATA_DIR/data" 2>/dev/null || true 36 | fi 37 | 38 | # sing-box 核心文件管理 39 | SINGBOX_BINARY="$FLATPAK_CORES_DIR/sing-box" 40 | SINGBOX_VERSION="1.8.0" 41 | 42 | # 检查并下载 sing-box 核心 43 | download_singbox() { 44 | local arch=$(uname -m) 45 | local arch_name 46 | 47 | case $arch in 48 | x86_64) arch_name="amd64" ;; 49 | aarch64) arch_name="arm64" ;; 50 | armv7l) arch_name="armv7" ;; 51 | *) 52 | echo "警告: 不支持的架构 $arch,尝试使用 amd64" 53 | arch_name="amd64" 54 | ;; 55 | esac 56 | 57 | local download_url="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${arch_name}.tar.gz" 58 | local temp_dir=$(mktemp -d) 59 | 60 | echo "正在下载 sing-box 核心 v${SINGBOX_VERSION} (${arch_name})..." 61 | 62 | if command -v curl >/dev/null 2>&1; then 63 | curl -L "$download_url" -o "$temp_dir/sing-box.tar.gz" 64 | elif command -v wget >/dev/null 2>&1; then 65 | wget "$download_url" -O "$temp_dir/sing-box.tar.gz" 66 | else 67 | echo "错误: 未找到 curl 或 wget,无法下载 sing-box 核心" 68 | rm -rf "$temp_dir" 69 | return 1 70 | fi 71 | 72 | cd "$temp_dir" 73 | tar -xzf sing-box.tar.gz 74 | 75 | # 查找 sing-box 二进制文件 76 | local singbox_file=$(find . -name "sing-box" -type f | head -1) 77 | if [ -n "$singbox_file" ]; then 78 | cp "$singbox_file" "$SINGBOX_BINARY" 79 | chmod +x "$SINGBOX_BINARY" 80 | echo "✓ sing-box 核心下载完成: $SINGBOX_BINARY" 81 | else 82 | echo "错误: 在下载的文件中未找到 sing-box 二进制文件" 83 | rm -rf "$temp_dir" 84 | return 1 85 | fi 86 | 87 | rm -rf "$temp_dir" 88 | return 0 89 | } 90 | 91 | # 检查 sing-box 核心是否存在 92 | if [ ! -f "$SINGBOX_BINARY" ]; then 93 | echo "未找到 sing-box 核心,正在下载..." 94 | if ! download_singbox; then 95 | echo "警告: sing-box 核心下载失败,应用程序可能无法正常工作" 96 | fi 97 | elif [ ! -x "$SINGBOX_BINARY" ]; then 98 | echo "修复 sing-box 核心文件权限..." 99 | chmod +x "$SINGBOX_BINARY" 100 | fi 101 | 102 | # 设置应用程序环境变量,指向 Flatpak 数据目录 103 | export LVORY_PORTABLE_MODE=true 104 | export LVORY_DATA_DIR="$FLATPAK_DATA_DIR" 105 | export LVORY_CONFIG_DIR="$FLATPAK_CONFIG_DIR" 106 | export LVORY_CORES_DIR="$FLATPAK_CORES_DIR" 107 | 108 | # 兼容性符号链接(如果应用程序期望特定路径) 109 | if [ ! -e "$FLATPAK_DATA_DIR/sing-box.json" ] && [ -f "$FLATPAK_CONFIG_DIR/sing-box.json" ]; then 110 | ln -sf "$FLATPAK_CONFIG_DIR/sing-box.json" "$FLATPAK_DATA_DIR/sing-box.json" 2>/dev/null || true 111 | fi 112 | 113 | # 调试信息 114 | if [ "$DEBUG" = "1" ]; then 115 | echo "环境变量:" 116 | echo " FLATPAK_APP_DATA=$FLATPAK_APP_DATA" 117 | echo " FLATPAK_CONFIG_DIR=$FLATPAK_CONFIG_DIR" 118 | echo " FLATPAK_DATA_DIR=$FLATPAK_DATA_DIR" 119 | echo " FLATPAK_CORES_DIR=$FLATPAK_CORES_DIR" 120 | echo " SINGBOX_BINARY=$SINGBOX_BINARY" 121 | echo "目录结构:" 122 | ls -la "$FLATPAK_APP_DATA" 2>/dev/null || echo " (无法列出目录)" 123 | fi 124 | 125 | # 启动应用程序 126 | echo "启动 lvory..." 127 | exec zypak-wrapper /app/main/lvory "$@" 128 | -------------------------------------------------------------------------------- /src/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import '../assets/css/sidebar.css'; 3 | import SystemStatus from './SystemStatus'; 4 | import logoSvg from '../../resource/icon/logo.svg'; 5 | 6 | const Sidebar = ({ activeItem, onItemClick, profilesCount, isMinimized }) => { 7 | // 为菜单项添加点击涟漪效果 8 | const [rippleStyle, setRippleStyle] = useState({ top: '0px', left: '0px', display: 'none' }); 9 | 10 | // 处理菜单项点击并触发涟漪效果 11 | const handleItemClick = (item, e) => { 12 | if (!e) return; 13 | 14 | const rect = e.currentTarget.getBoundingClientRect(); 15 | const x = e.clientX - rect.left; 16 | const y = e.clientY - rect.top; 17 | 18 | setRippleStyle({ 19 | top: `${y}px`, 20 | left: `${x}px`, 21 | display: 'block' 22 | }); 23 | 24 | // 触发父组件的点击事件 25 | onItemClick(item); 26 | 27 | // 300ms后隐藏涟漪效果 28 | setTimeout(() => { 29 | setRippleStyle({ ...rippleStyle, display: 'none' }); 30 | }, 300); 31 | }; 32 | 33 | return ( 34 |
35 |
36 | LVORY Logo 37 | {!isMinimized &&

LVORY

} 38 |
39 | 40 |
41 |
    42 | 51 | 60 | 71 | 80 | 89 |
90 |
91 | 92 | {!isMinimized && } 93 |
94 | ); 95 | }; 96 | 97 | export default Sidebar; -------------------------------------------------------------------------------- /src/assets/css/profile-table.css: -------------------------------------------------------------------------------- 1 | /* 配置文件表格样式 */ 2 | .profiles-table-container { 3 | flex: 1; 4 | overflow: auto; 5 | background-color: #fff; 6 | border-radius: 8px; 7 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 8 | } 9 | 10 | .profiles-table { 11 | width: 100%; 12 | border-collapse: collapse; 13 | } 14 | 15 | .profiles-table th, 16 | .profiles-table td { 17 | padding: 12px 16px; 18 | text-align: left; 19 | border-bottom: 1px solid #eee; 20 | } 21 | 22 | .profiles-table th { 23 | font-weight: 500; 24 | color: #333; 25 | background-color: #f9f9f9; 26 | } 27 | 28 | .profiles-table tr:hover { 29 | background-color: #f5f7f9; 30 | } 31 | 32 | .profiles-table tr.selected-row { 33 | background-color: #e8f0fe; 34 | } 35 | 36 | /* 协议列样式 */ 37 | .protocol-column { 38 | width: 80px; 39 | text-align: center; 40 | } 41 | 42 | .protocol-column .protocol-badge { 43 | margin-left: 0; 44 | display: inline-block; 45 | } 46 | 47 | /* 表格状态行 */ 48 | .loading-row, .empty-row { 49 | text-align: center; 50 | color: #888; 51 | padding: 30px 0; 52 | } 53 | 54 | .loading-spinner { 55 | width: 30px; 56 | height: 30px; 57 | border: 3px solid #f3f3f3; 58 | border-top: 3px solid #3498db; 59 | border-radius: 50%; 60 | margin: 0 auto 10px; 61 | animation: spin 1s linear infinite; 62 | } 63 | 64 | @keyframes spin { 65 | 0% { transform: rotate(0deg); } 66 | 100% { transform: rotate(360deg); } 67 | } 68 | 69 | .empty-state { 70 | display: flex; 71 | flex-direction: column; 72 | align-items: center; 73 | gap: 10px; 74 | } 75 | 76 | 77 | /* 文件名单元格 */ 78 | .file-name-cell { 79 | cursor: pointer; 80 | display: flex; 81 | align-items: center; 82 | gap: 8px; 83 | transition: color 0.2s; 84 | font-weight: 500; 85 | } 86 | 87 | .file-name-cell:hover { 88 | color: #50b2d0; 89 | } 90 | 91 | .file-name-cell:active { 92 | color: #3a8ba8; 93 | } 94 | 95 | .file-name-cell.active-profile { 96 | color: #50b2d0; 97 | font-weight: 600; 98 | } 99 | 100 | .active-label { 101 | font-size: 10px; 102 | color: #50b2d0; 103 | background-color: rgba(80, 178, 208, 0.1); 104 | padding: 2px 6px; 105 | border-radius: 10px; 106 | margin-left: 8px; 107 | font-weight: 600; 108 | text-transform: uppercase; 109 | } 110 | 111 | /* 协议标识 */ 112 | .protocol-badge { 113 | display: inline-flex; 114 | align-items: center; 115 | font-size: 10px; 116 | padding: 2px 6px; 117 | border-radius: 3px; 118 | margin-left: 8px; 119 | font-weight: 500; 120 | text-transform: uppercase; 121 | } 122 | 123 | .protocol-badge.lvory { 124 | background-color: #e8f4f8; 125 | color: #2980b9; 126 | border: 1px solid #bde3f0; 127 | } 128 | 129 | .protocol-badge.singbox { 130 | background-color: #f0f8e8; 131 | color: #27ae60; 132 | border: 1px solid #c8e6c9; 133 | } 134 | 135 | /* 状态标识 */ 136 | .status-badge { 137 | display: inline-block; 138 | margin-left: 8px; 139 | font-size: 11px; 140 | padding: 2px 6px; 141 | border-radius: 3px; 142 | font-weight: normal; 143 | } 144 | 145 | .status-badge.expired { 146 | background-color: #ffebee; 147 | color: #e53935; 148 | } 149 | 150 | .status-badge.incomplete { 151 | background-color: #fff8e1; 152 | color: #ff8f00; 153 | } 154 | 155 | .status-badge.cached { 156 | background-color: #e3f2fd; 157 | color: #1976d2; 158 | border: 1px solid #bbdefb; 159 | } 160 | 161 | /* 本地载入按钮样式 */ 162 | .load-local-button { 163 | padding: 6px 12px; 164 | background-color: #27ae60; 165 | color: white; 166 | border: none; 167 | border-radius: 4px; 168 | cursor: pointer; 169 | display: flex; 170 | align-items: center; 171 | font-size: 13px; 172 | transition: background-color 0.2s; 173 | margin-right: 8px; 174 | } 175 | 176 | .load-local-button:hover { 177 | background-color: #229954; 178 | } 179 | 180 | .load-local-button:disabled { 181 | background-color: #cccccc; 182 | cursor: not-allowed; 183 | } 184 | 185 | .load-local-button .icon { 186 | margin-right: 6px; 187 | font-size: 14px; 188 | } 189 | 190 | /* 隐藏的文件输入 */ 191 | .hidden-file-input { 192 | position: absolute; 193 | opacity: 0; 194 | width: 0; 195 | height: 0; 196 | pointer-events: none; 197 | } -------------------------------------------------------------------------------- /src/main/data-managers/base-database-manager.js: -------------------------------------------------------------------------------- 1 | const { DatabaseSync } = require('node:sqlite'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const logger = require('../../utils/logger'); 5 | const { getAppDataDir } = require('../../utils/paths'); 6 | 7 | /** 8 | * 数据库管理基类 9 | * 提供通用的数据库初始化、连接管理和错误处理功能 10 | */ 11 | class BaseDatabaseManager { 12 | constructor(dbFileName, logPrefix = '数据库') { 13 | this.db = null; 14 | this.dbPath = null; 15 | this.initialized = false; 16 | this.dbFileName = dbFileName; 17 | this.logPrefix = logPrefix; 18 | } 19 | 20 | /** 21 | * 初始化数据库连接 22 | * @param {string} dbFileName - 数据库文件名(可选,覆盖构造函数中的值) 23 | * @returns {boolean} 初始化是否成功 24 | */ 25 | initDatabase(dbFileName = null) { 26 | if (this.initialized) { 27 | return true; 28 | } 29 | 30 | try { 31 | const appDataDir = getAppDataDir(); 32 | const dataDir = path.join(appDataDir, 'data'); 33 | 34 | // 确保数据目录存在 35 | if (!fs.existsSync(dataDir)) { 36 | fs.mkdirSync(dataDir, { recursive: true }); 37 | logger.debug(`数据目录已创建: ${dataDir}`); 38 | } 39 | 40 | // 设置数据库路径 41 | const fileName = dbFileName || this.dbFileName; 42 | this.dbPath = path.join(dataDir, fileName); 43 | 44 | // 创建数据库连接 45 | this.db = new DatabaseSync(this.dbPath); 46 | 47 | // 调用子类的表创建方法 48 | this.createTables(); 49 | 50 | this.initialized = true; 51 | logger.info(`${this.logPrefix}初始化成功: ${this.dbPath}`); 52 | 53 | return true; 54 | } catch (error) { 55 | logger.error(`${this.logPrefix}初始化失败:`, error); 56 | throw error; 57 | } 58 | } 59 | 60 | /** 61 | * 创建数据库表(由子类实现) 62 | * 子类必须重写此方法以创建特定的表结构 63 | */ 64 | createTables() { 65 | throw new Error('createTables() 方法必须由子类实现'); 66 | } 67 | 68 | /** 69 | * 确保数据库已初始化 70 | * @throws {Error} 如果数据库未初始化 71 | */ 72 | ensureInitialized() { 73 | if (!this.initialized) { 74 | this.initDatabase(); 75 | } 76 | } 77 | 78 | /** 79 | * 执行SQL语句(带错误处理) 80 | * @param {string} sql - SQL语句 81 | * @param {string} operation - 操作描述(用于日志) 82 | * @returns {boolean} 执行是否成功 83 | */ 84 | executeSql(sql, operation = 'SQL操作') { 85 | try { 86 | this.ensureInitialized(); 87 | this.db.exec(sql); 88 | return true; 89 | } catch (error) { 90 | logger.error(`${operation}失败:`, error); 91 | throw error; 92 | } 93 | } 94 | 95 | /** 96 | * 开始事务 97 | */ 98 | beginTransaction() { 99 | this.ensureInitialized(); 100 | this.db.exec('BEGIN TRANSACTION'); 101 | } 102 | 103 | /** 104 | * 提交事务 105 | */ 106 | commit() { 107 | this.db.exec('COMMIT'); 108 | } 109 | 110 | /** 111 | * 回滚事务 112 | */ 113 | rollback() { 114 | try { 115 | this.db.exec('ROLLBACK'); 116 | } catch (error) { 117 | logger.error('事务回滚失败:', error); 118 | } 119 | } 120 | 121 | /** 122 | * 执行带事务的批量操作 123 | * @param {Function} operation - 要执行的操作函数 124 | * @returns {Object} 操作结果 {success: boolean, error?: string} 125 | */ 126 | async executeInTransaction(operation) { 127 | try { 128 | this.beginTransaction(); 129 | const result = await operation(); 130 | this.commit(); 131 | return { success: true, ...result }; 132 | } catch (error) { 133 | this.rollback(); 134 | logger.error('事务执行失败:', error); 135 | return { success: false, error: error.message }; 136 | } 137 | } 138 | 139 | /** 140 | * 关闭数据库连接 141 | */ 142 | close() { 143 | if (this.db) { 144 | try { 145 | this.db.close(); 146 | this.initialized = false; 147 | logger.info(`${this.logPrefix}连接已关闭`); 148 | } catch (error) { 149 | logger.error(`${this.logPrefix}关闭失败:`, error); 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * 获取数据库路径 156 | * @returns {string|null} 数据库文件路径 157 | */ 158 | getDbPath() { 159 | return this.dbPath; 160 | } 161 | 162 | /** 163 | * 检查数据库是否已初始化 164 | * @returns {boolean} 是否已初始化 165 | */ 166 | isInitialized() { 167 | return this.initialized; 168 | } 169 | } 170 | 171 | module.exports = BaseDatabaseManager; -------------------------------------------------------------------------------- /src/assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* 为 macOS 平台添加特殊样式 */ 2 | .mac-os .window-draggable-area { 3 | /* macOS 的拖动区域应避开系统控制按钮 */ 4 | left: 80px; /* 留出足够的空间给系统控制按钮 */ 5 | } 6 | 7 | .app-container { 8 | display: flex; 9 | flex-direction: column; 10 | height: 100vh; 11 | overflow: hidden; 12 | position: relative; 13 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.95) 100%); 14 | backdrop-filter: blur(20px) brightness(1.1); 15 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 16 | } 17 | 18 | /* 窗口可拖动区域 */ 19 | .window-draggable-area { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | height: 60px; 25 | -webkit-app-region: drag; 26 | z-index: 100; 27 | } 28 | 29 | /* 顶部控制区域 */ 30 | .top-controls { 31 | height: 45px; 32 | width: 100%; 33 | display: flex; 34 | justify-content: flex-end; 35 | align-items: center; 36 | padding: 0 15px; 37 | position: relative; 38 | } 39 | 40 | /* 横线 */ 41 | .horizontal-line { 42 | height: 1px; 43 | width: 100%; 44 | background-color: #eaeaea; 45 | margin-bottom: 0; 46 | } 47 | 48 | /* 窗口控制按钮样式 */ 49 | .window-controls { 50 | position: absolute; 51 | top: 15px; 52 | right: 15px; 53 | display: flex; 54 | gap: 8px; 55 | z-index: 101; 56 | } 57 | 58 | .control-button { 59 | width: 15.4px; 60 | height: 15.4px; 61 | border-radius: 50%; 62 | border: none; 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | font-size: 10px; 67 | color: transparent; 68 | cursor: pointer; 69 | transition: filter 0.15s ease, transform 0.1s ease; 70 | -webkit-app-region: no-drag; 71 | will-change: filter, transform; 72 | transform: translateZ(0); 73 | } 74 | 75 | .minimize { 76 | background-color: #ffbd2e; 77 | } 78 | 79 | .maximize { 80 | background-color: #28c940; 81 | } 82 | 83 | .close { 84 | background-color: #ff5f57; 85 | } 86 | 87 | .window-draggable-area:hover ~ .window-controls .control-button, 88 | .window-controls:hover .control-button { 89 | color: #fff; 90 | } 91 | 92 | .control-button:hover { 93 | filter: brightness(0.9); 94 | } 95 | 96 | /* 内容区域 */ 97 | .content-container { 98 | display: flex; 99 | flex: 1; 100 | overflow: hidden; 101 | transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1); 102 | will-change: margin-left; 103 | } 104 | 105 | .main-content { 106 | flex: 1; 107 | overflow: hidden; 108 | background: rgba(255, 255, 255, 0.2); 109 | backdrop-filter: blur(10px) brightness(1.02); 110 | -webkit-backdrop-filter: blur(10px) brightness(1.02); 111 | position: relative; 112 | transition: padding 0.2s cubic-bezier(0.4, 0, 0.2, 1); 113 | will-change: padding; 114 | contain: layout style paint; 115 | } 116 | 117 | .view-container { 118 | width: 100%; 119 | height: 100%; 120 | will-change: transform; 121 | transform: translateZ(0); 122 | contain: layout style paint; 123 | } 124 | 125 | /* Dashboard 视图特定样式 - 强制高度不能超出视窗 */ 126 | .main-content .dashboard { 127 | height: 100%; 128 | overflow: hidden; 129 | display: flex; 130 | flex-direction: column; 131 | } 132 | 133 | /* Activity 视图需要滚动 */ 134 | .main-content .activity-view { 135 | overflow-y: auto; 136 | height: 100%; 137 | } 138 | 139 | /* 当 Settings 激活时的特定样式 */ 140 | .app-container.settings-active .main-content { 141 | /* Settings 组件有自己的 padding,移除外部的 padding */ 142 | padding: 0; 143 | } 144 | 145 | /* 设置组件相关规则 */ 146 | .app-container.settings-active .content-container { 147 | /* 确保 Settings 视图没有额外空间 */ 148 | margin: 0; 149 | } 150 | 151 | /* 为简化的侧边栏添加 badge 样式 */ 152 | .minimized-badge { 153 | position: absolute; 154 | top: 4px; 155 | right: 4px; 156 | font-size: 10px; 157 | padding: 1px 4px; 158 | line-height: 1; 159 | background-color: #50b2d0; /* 或其他颜色 */ 160 | color: white; 161 | border-radius: 7px; /* 半圆形 */ 162 | min-width: 14px; 163 | text-align: center; 164 | } 165 | 166 | /* 全局动画定义 */ 167 | @keyframes fadeIn { 168 | from { 169 | opacity: 0; 170 | backdrop-filter: blur(0px) brightness(1); 171 | -webkit-backdrop-filter: blur(0px) brightness(1); 172 | } 173 | to { 174 | opacity: 1; 175 | backdrop-filter: blur(20px) brightness(1.1); 176 | -webkit-backdrop-filter: blur(20px) brightness(1.1); 177 | } 178 | } -------------------------------------------------------------------------------- /src/main/data-managers/node-connection-monitor.js: -------------------------------------------------------------------------------- 1 | const logger = require('../../utils/logger'); 2 | 3 | // fetch 将在需要时动态导入 4 | let fetch; 5 | const nodeHistoryManager = require('./node-history-manager'); 6 | const settingsManager = require('../settings-manager'); 7 | 8 | class NodeConnectionMonitor { 9 | constructor() { 10 | this.isRunning = false; 11 | this.monitorInterval = null; 12 | this.apiAddress = '127.0.0.1:9090'; 13 | this.nodeTrafficData = new Map(); // 用于跟踪节点流量变化 14 | } 15 | 16 | // 设置API地址 17 | setApiAddress(address) { 18 | if (address && address !== this.apiAddress) { 19 | this.apiAddress = address; 20 | logger.info(`节点监控API地址已更新为: ${address}`); 21 | 22 | // 如果监控已在运行,重启它以使用新地址 23 | if (this.isRunning) { 24 | this.stopMonitoring(); 25 | this.startMonitoring(); 26 | } 27 | } 28 | } 29 | 30 | // 开始监控节点连接 31 | startMonitoring() { 32 | if (this.isRunning) return; 33 | 34 | this.isRunning = true; 35 | 36 | // 每30秒获取一次连接数据 37 | this.monitorInterval = setInterval(() => { 38 | this.fetchNodeConnections(); 39 | }, 30000); 40 | 41 | // 立即执行一次 42 | this.fetchNodeConnections(); 43 | 44 | logger.info('节点连接监控已启动'); 45 | } 46 | 47 | // 停止监控 48 | stopMonitoring() { 49 | if (!this.isRunning) return; 50 | 51 | clearInterval(this.monitorInterval); 52 | this.monitorInterval = null; 53 | this.isRunning = false; 54 | 55 | logger.info('节点连接监控已停止'); 56 | } 57 | 58 | // 获取节点连接数据 59 | async fetchNodeConnections() { 60 | try { 61 | // 检查是否启用节点历史数据功能 62 | const settings = settingsManager.getSettings(); 63 | if (!settings.keepNodeTrafficHistory) { 64 | return; 65 | } 66 | 67 | // 确保 fetch 已被初始化 68 | if (!fetch) { 69 | const nodeFetch = await import('node-fetch'); 70 | fetch = nodeFetch.default; 71 | } 72 | 73 | const response = await fetch(`http://${this.apiAddress}/connections`); 74 | const data = await response.json(); 75 | 76 | if (data && Array.isArray(data.connections)) { 77 | // 创建节点流量映射 (outbound -> 流量数据) 78 | const nodeTraffic = new Map(); 79 | 80 | // 处理连接数据 81 | data.connections.forEach(conn => { 82 | const outbound = conn.chains ? conn.chains[conn.chains.length - 1] : 'direct'; 83 | 84 | // 跳过非出站节点流量 85 | if (!outbound || outbound === 'direct') return; 86 | 87 | // 汇总节点流量 88 | if (nodeTraffic.has(outbound)) { 89 | const current = nodeTraffic.get(outbound); 90 | current.upload += conn.upload || 0; 91 | current.download += conn.download || 0; 92 | current.total = current.upload + current.download; 93 | } else { 94 | nodeTraffic.set(outbound, { 95 | upload: conn.upload || 0, 96 | download: conn.download || 0, 97 | total: (conn.upload || 0) + (conn.download || 0) 98 | }); 99 | } 100 | }); 101 | 102 | // 更新到历史数据管理器 103 | for (const [nodeTag, trafficData] of nodeTraffic.entries()) { 104 | // 只保存有流量变化的节点数据 105 | const previousData = this.nodeTrafficData.get(nodeTag) || { upload: 0, download: 0, total: 0 }; 106 | 107 | const uploadDiff = trafficData.upload - previousData.upload; 108 | const downloadDiff = trafficData.download - previousData.download; 109 | 110 | if (uploadDiff > 0 || downloadDiff > 0) { 111 | // 保存流量增量 112 | nodeHistoryManager.updateNodeTraffic(nodeTag, { 113 | upload: uploadDiff, 114 | download: downloadDiff 115 | }); 116 | 117 | // 更新内部跟踪数据 118 | this.nodeTrafficData.set(nodeTag, trafficData); 119 | } 120 | } 121 | } 122 | } catch (error) { 123 | logger.error('获取节点连接数据失败:', error); 124 | } 125 | } 126 | 127 | // 重置流量计数器 128 | resetTrafficCounters() { 129 | this.nodeTrafficData.clear(); 130 | logger.info('节点流量计数器已重置'); 131 | } 132 | } 133 | 134 | // 创建单例实例 135 | const nodeConnectionMonitor = new NodeConnectionMonitor(); 136 | module.exports = nodeConnectionMonitor; -------------------------------------------------------------------------------- /src/assets/css/activity-icons.css: -------------------------------------------------------------------------------- 1 | /* Activity Icons - Base64 Embedded */ 2 | 3 | .icon-ghost { 4 | content: ''; 5 | display: inline-block; 6 | width: 20px; 7 | height: 20px; 8 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAAsTAAALEwEAmpwYAAAERElEQVR4nM2aW6gWVRTHfx6Px7S8pFJmYSSVqUetlyRfwopKyQdTKksswUtWUr4khAYadiFN6UIJhccDGSFo6kOdvGIodB66eYEiK7WstDx2U4/p+WLFGplWa+abz2++mfnDfpm997rs2bPWf689kC4uB2YDzcBu4DBwEjgBfA98AqwApgA9KSCGA+uAv4FSwvY78DLQjwKgE/A0cLoCB2z7CRifpxOdgaYyRh4HvgMOAL/FjDsDTMvLkeccg+TNvAPcBfR15vQH7gFagA4z9ywwNmsnbnEM+QIYUYGMO4GfjYxfgMvICPXAHmNAK9DrPGRdBxwxsprJCJONYtn7V1Qh73YjrwNoJAO0GsUStarFRiOziRqj0SiUbdEjBbkj9WMvaTtxnls1MV40jixNUfYHRvZMapg3fjDKbjDJcYJSlEuc+RercfdqwLB4wMjeVStHbjWK9pr+5lCfJMCrTP74MtS/Aagz8y9U2hL+6K+phSMrjSMLYiKPtNWh/ledfnkzFk1mzMK0nRgItBsl4dVaFkFPAnzj9K9K8NZ/BLqn5cQgYKdRsM2MWR3BoQIj/nL6PnR01QHfmnFC+6+tlpq/Z8JisHfHmLGLHUPlLBLAMgFpb0TonROxKPJNPgZckNSBeiWEUdT8NWfOUMfh50P9TzpyRsfob43QXVI2fVs5J3o68Txocmh6yYk2AR5ROh5sm3CilFVcq33i8FNl7OjrbOdwExmPR03uAmxxJv2pbyHJPhXmOkxzShRJTMrL6oBxwFsOsQzaQ97EV5yB6/Qcnjc6A1P1uwvb94el/aOdPf5szMrmhf7A18ZOeWPnsM10vk1xcb1Z9PaAYA51Elkfio31xub70AgSfric4mOmsfl1efi+eWiTXRFxs7F5Kw4lGK+huMhoNDYLl/sPdQ6aPFsD3EgxMcghlxwrk0HDdD1v1AEDdNeE7WyTzocTlDxn52z8VGBHjJ1SKP8XF2lxbYIWltucrXZpDk50d0Kt105FCRCjvzKD55E9ViRwoqTfcyTuMIO3ky2GOWVZqQW8CcwH5uri3g80xAlqUFIWCPmVbLHEOLE2ovqSCJ8ZYWkU45Jij9FdSYH8f9hghMnrzorldoT0HqpWoD2jyL1HFngi7Qq9LQR8rgUASUq1Qi+HNk2sVujVzoEryPgtelmT5uFrFPCpU2yIjUpJsbxMDP8YGFyFfIlEs/TquuS0SaRIEeY52d5emcnJrVIIo/goRu4z1ADdgLuBd5USWKWyr3tXuECbIxxoA6aTAYTCvOB8P1IDTopHHQcOAYvy4HUPGkPaNUCUwwCtDYTnCq/K9UDXYgzaVCbK1OuY8Jx9QFdyxhDnnLBJT5e2vHqT48TZmDpw5lga8dHKdcJ+3fv2fiVo5erAmaLB2WJJ2rICVjPpore+Sf4UOqo/HhQaV+rBR3KE/MIkd+aSdw4qm55Ri+PAP3BUZtyzI2IZAAAAAElFTkSuQmCC'); 9 | background-size: contain; 10 | background-repeat: no-repeat; 11 | background-position: center; 12 | transition: all 0.2s ease; 13 | } 14 | 15 | /* Confusion icon for "疑惑解答" function */ 16 | .icon-confusion { 17 | content: ''; 18 | display: inline-block; 19 | width: 20px; 20 | height: 20px; 21 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAACXBIWXMAAAsTAAALEwEAmpwYAAAD0ElEQVR4nO2ay2/NURDHP1QIUnGp3qIUSzsrNt6kSSUkaCXaioUFrYZgIbFDKeIfEK8qTejKowur1sazpNqQVKzbiqAXm0raypFp8svJnPt73N/v1uJ+k7O5nZlz5pw5M98zv0IBBRSQT6SBGqAZ6ACeAT0yuuU387dqoJT/DAuAY8AbYCLEGAdeA01AaqoduAL8CumANn4CLVPh0EHgawwO2OMLcCAfDswD7vss5jvwCLgIHAb2yzgiu/4Y+OFjox0oTsqJxUCvY+JR4BawEZgewFYRsAloFV3N5jugLAknPjsu7E1gaQ62l4lDxpZt/1Oczphweq9MMghsiWsSYBsw5DiZWMJMuxO9ckpxYwnQp8x3L47spDkxn+SQcjhTH9XgQiXFDiZ0EtrJDCupOVKduaJc7DjvhB+2KwnApO/QVduu2CY75Rt3rDVkwp7KMcvAH2A5+Ue5UmcawxjosZRNsXNhFrBGmG9YlImuseFCq7WWl0GNp5XYNBVbww4rIVwHZgSYw8jc8OgZG1UO2a3KXS0J4kiNwp2mO3Yzo6TJkwHmOKXoGVtpB50ZsWT3BnGk2VIyBFDDPgdHMg8oP3Q7dGsc8k8suXNBHOmwlAyLdYWVthjDbv1gL2xyVDnkL1lyhm344pmlZKi4hjlC6uzF7Awwxy5FbwCY7ZBviHDq/0iaV6k2i+xKCT0Twx98ZG3UiY7RfQisyCJbqxDJWB3JF+qsNb2NM7TyiYYoodWRK79JAJejXPbzEbJQ0ui01nQ2iFK1UhBNUYqKacA6qU9Phf68AI77UBMvC7AL7x4CoFShKJsJj5nyMNMeSd5YLw7wBJ7wjLGgFAXpAHqVb2eRXQucltpQCRwSovctiwPecc1nLW2W/HNCoMlSHhVKraEoRLvUnPQr4IyE8AlJpYsctivkCRGZxqekjek1YHbZhVU+ncffwFVZmHYHzLNaw13LTiZKv6BF2U0Try6sBj5aOj8kCwaOaQ8qlbt6gQhIyYPfa2hIGgNkCbP1EjYb5MJHQbky93Au3Zt6JUz6Eu6ap4B+ZV7TP84J7Q5nsp0MOZxEvzKfaULkjGKFSE4etWnZxAVzJ+xwmpACOjeuSdKOt8e47JZpREdFhWQnrYk9kMQnujLHyUzWmTvSwAtCZ2ZIBmxT6sSE5yQS+85YLA3lbEVvRJ6yl+TjTq2MBvmt09G0sO9EbOHkl820eM51DE/FQy4lRdNmAFFGRtjx/Hw7YTt0VDqA2oV1jTGh841T7YCGEnkrGFryAOjy/MNAl7zsTE9qd0TaUkABBRANfwF2JFbCkkqrdQAAAABJRU5ErkJggg=='); 22 | background-size: contain; 23 | background-repeat: no-repeat; 24 | background-position: center; 25 | transition: all 0.2s ease; 26 | } 27 | 28 | /* Icon button styles for using CSS classes instead of img tags */ 29 | .icon-button .icon-ghost, 30 | .icon-button .icon-confusion { 31 | user-select: none; 32 | pointer-events: none; 33 | } 34 | 35 | .activity-actions .icon-button { 36 | margin-left: -4px; 37 | } 38 | 39 | /* Ghost icon states */ 40 | .icon-button:not(.active) .icon-ghost { 41 | filter: grayscale(100%) opacity(0.6); 42 | } 43 | 44 | .icon-button.active .icon-ghost { 45 | filter: none; 46 | transform: scale(1.05); 47 | } 48 | 49 | .icon-button:hover .icon-ghost { 50 | filter: brightness(1.1) saturate(1.2); 51 | transform: scale(1.1); 52 | } 53 | 54 | /* Confusion icon states */ 55 | .icon-button.info-button .icon-confusion { 56 | filter: sepia(20%) hue-rotate(240deg) saturate(1.2); 57 | } 58 | 59 | .icon-button.info-button:hover .icon-confusion { 60 | filter: sepia(20%) hue-rotate(240deg) saturate(1.5) brightness(1.1); 61 | transform: scale(1.1); 62 | } -------------------------------------------------------------------------------- /src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 通用格式化工具函数 3 | */ 4 | 5 | /** 6 | * @param {number} bytes - 字节数 7 | * @param {string} suffix - 后缀 (如 '/s' 表示速率) 8 | * @param {number} decimals - 小数位数 9 | * @returns {string} 格式化后的字符串 10 | */ 11 | export const formatBytes = (bytes, suffix = '', decimals = 2) => { 12 | if (bytes === undefined || bytes === null || bytes === 0) { 13 | return `0 B${suffix}`; 14 | } 15 | 16 | const k = 1024; 17 | const dm = decimals < 0 ? 0 : decimals; 18 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 19 | 20 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 21 | const value = bytes / Math.pow(k, i); 22 | 23 | // 如果数值大于100显示整数,否则保留指定小数位 24 | const formattedValue = value > 100 ? Math.round(value) : parseFloat(value.toFixed(dm)); 25 | 26 | return `${formattedValue} ${sizes[i]}${suffix}`; 27 | }; 28 | 29 | /** 30 | * 格式化字节数为对象格式 (用于复杂显示) 31 | * @param {number} bytes - 字节数 32 | * @param {string} suffix - 后缀 (如 '/s' 表示速率) 33 | * @returns {object} {value: number, unit: string} 34 | */ 35 | export const formatBytesToObject = (bytes, suffix = '') => { 36 | if (bytes === undefined || bytes === null || bytes === 0) { 37 | return { value: 0, unit: `B${suffix}` }; 38 | } 39 | 40 | const k = 1024; 41 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 42 | 43 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 44 | const value = bytes / Math.pow(k, i); 45 | 46 | // 如果数值大于100显示整数,否则保留一位小数 47 | const formattedValue = value > 100 ? Math.round(value) : Math.round(value * 10) / 10; 48 | 49 | return { 50 | value: formattedValue, 51 | unit: `${sizes[i]}${suffix}` 52 | }; 53 | }; 54 | 55 | /** 56 | * 自动进行单位换算,确保数值不超过3位数 57 | * @param {object} formatResult - {value: number, unit: string} 58 | * @returns {object} {value: number, unit: string} 59 | */ 60 | export const formatWithOptimalUnit = (formatResult) => { 61 | let { value, unit } = formatResult; 62 | const unitMap = { 63 | 'B/s': ['B/s', 'KB/s', 'MB/s', 'GB/s'], 64 | 'KB/s': ['KB/s', 'MB/s', 'GB/s'], 65 | 'MB/s': ['MB/s', 'GB/s'], 66 | 'B': ['B', 'KB', 'MB', 'GB', 'TB'], 67 | 'KB': ['KB', 'MB', 'GB', 'TB'], 68 | 'MB': ['MB', 'GB', 'TB'], 69 | 'GB': ['GB', 'TB'] 70 | }; 71 | 72 | const units = unitMap[unit] || [unit]; 73 | let unitIndex = 0; 74 | 75 | // 如果数值大于999,进行单位换算 76 | while (value > 999 && unitIndex < units.length - 1) { 77 | value = value / 1024; 78 | unitIndex++; 79 | } 80 | 81 | // 处理小数,如果数值大于100显示整数,否则保留一位小数 82 | value = value > 100 ? Math.round(value) : Math.round(value * 10) / 10; 83 | 84 | return { value, unit: units[unitIndex] }; 85 | }; 86 | 87 | /** 88 | * 格式化时间戳为 HH:MM:SS 格式 89 | * @param {string|number|Date} timestamp - 时间戳 90 | * @param {boolean} includeMilliseconds - 是否包含毫秒 91 | * @returns {string} 格式化后的时间字符串 92 | */ 93 | export const formatTimestamp = (timestamp, includeMilliseconds = false) => { 94 | if (!timestamp) return '--:--:--'; 95 | 96 | try { 97 | const date = new Date(timestamp); 98 | if (isNaN(date.getTime())) { 99 | return '--:--:--'; 100 | } 101 | 102 | const hours = date.getHours().toString().padStart(2, '0'); 103 | const minutes = date.getMinutes().toString().padStart(2, '0'); 104 | const seconds = date.getSeconds().toString().padStart(2, '0'); 105 | 106 | if (includeMilliseconds) { 107 | const milliseconds = date.getMilliseconds().toString().padStart(3, '0'); 108 | return `${hours}:${minutes}:${seconds}.${milliseconds}`; 109 | } 110 | 111 | return `${hours}:${minutes}:${seconds}`; 112 | } catch (error) { 113 | console.error('Error formatting timestamp:', error); 114 | return timestamp.toString(); 115 | } 116 | }; 117 | 118 | /** 119 | * 格式化延迟时间 120 | * @param {number} latency - 延迟时间 (毫秒) 121 | * @returns {string} 格式化后的延迟字符串 122 | */ 123 | export const formatLatency = (latency) => { 124 | if (latency == null) { 125 | return 'timeout'; 126 | } 127 | 128 | if (typeof latency === 'number') { 129 | return `${latency}ms`; 130 | } 131 | 132 | return latency.toString(); 133 | }; 134 | 135 | /** 136 | * 格式化百分比 137 | * @param {number} value - 数值 138 | * @param {number} decimals - 小数位数 139 | * @returns {string} 格式化后的百分比字符串 140 | */ 141 | export const formatPercentage = (value, decimals = 1) => { 142 | if (value === undefined || value === null || isNaN(value)) { 143 | return '0%'; 144 | } 145 | 146 | return `${parseFloat(value.toFixed(decimals))}%`; 147 | }; 148 | -------------------------------------------------------------------------------- /docs/program/node_score.md: -------------------------------------------------------------------------------- 1 | # 代理节点延迟评估与打分算法 - Alpha 2 | 3 | ## 1. 算法概述 4 | 5 | 算法旨在对代理节点进行全面评估,通过多维度指标和动态计算方法,实现对节点服务质量的精准打分。基于正态分布模型的动态权重系统,能够在保证评分稳定性的同时,对节点性能波动做出敏感响应 6 | 7 | ```mermaid 8 | graph TD 9 | A[数据采集层] --> B[指标计算层] 10 | B --> C[正态分布标准化] 11 | C --> D[动态权重计算] 12 | D --> E[综合评分输出] 13 | ``` 14 | 15 | ## 2. 评估指标体系 16 | 17 | ### 2.1 基础指标与默认权重 18 | 19 | | 指标名称 | 权重占比 | 说明 | 20 | |---------|---------|------| 21 | | 延迟 | 37% | 节点响应时间,单位ms | 22 | | 丢包率 | 30% | 数据包传输丢失比例 | 23 | | 节点位置 | 15% | 地理位置评估 | 24 | | IP变更次数 | 6% | 节点IP稳定性 | 25 | | IP纯净度 | 12% | IP地址质量评估 | 26 | 27 | ### 2.2 单项指标评分标准 28 | 29 | #### 2.2.1 延迟评分 30 | - ≤50ms: 100分(极佳) 31 | - 50-100ms: 80分(良好) 32 | - 100-200ms: 60分(一般) 33 | - >200ms: 30分(较差) 34 | 35 | #### 2.2.2 丢包率评分 36 | - ≤1%: 100分(极佳) 37 | - 1%-3%: 80分(良好) 38 | - 3%-5%: 50分(一般) 39 | - >5%: 0分(不可用) 40 | 41 | #### 2.2.3 位置评分 42 | - 最优区域: 100分 43 | - 次优区域: 70分 44 | - 普通区域: 40分 45 | 46 | #### 2.2.4 IP变更评分 47 | - 月变更≤1次: 100分(极稳定) 48 | - 2-3次: 80分(稳定) 49 | - 4-5次: 50分(一般) 50 | - 小于5次: 20分(不稳定) 51 | 52 | #### 2.2.5 IP纯净度评分 53 | - 家庭 ISP IP且纯净度低于 15%: 100分(优质) 54 | - IDC 宽带 IP: 60分(一般) 55 | - 高风险 IP: 0分(风险) 56 | 57 | ## 3. 正态分布动态评分模型 58 | 59 | ### 3.1 正态化处理原理 60 | 61 | 标准化评分通过正态分布模型对原始数据进行处理,将各项指标转换为标准得分(Z-score),然后映射到累积分布函数上获得最终评分。 62 | 63 | 计算步骤如下: 64 | 65 | 1. **计算Z-score**: 66 | \[ Z = \frac{x - \mu}{\sigma} \] 67 | 68 | 其中: 69 | - x: 当前指标测量值 70 | - μ: 滑动窗口内该指标的平均值 71 | - σ: 滑动窗口内该指标的标准差 72 | 73 | 2. **标准化评分计算**: 74 | \[ 标准化评分 = (1 - \Phi(|Z|)) \times 100 \] 75 | 76 | 其中: 77 | - Φ: 标准正态分布的累积分布函数 78 | - |Z|: Z-score的绝对值 79 | 80 | ### 3.2 动态权重调整机制 81 | 82 | 为了体现数据的动态变化,系统基于历史波动性调整各指标权重: 83 | 84 | \[ 动态权重系数 = \frac{1}{当前指标\sigma / 历史\sigma} \] 85 | 86 | \[ 最终权重 = 基础权重 \times 动态系数 \] 87 | 88 | \[ 归一化权重 = \frac{最终权重}{\sum 所有指标最终权重} \] 89 | 90 | ## 4. 综合评分计算公式 91 | 92 | \[ 总分 = \sum_{i=1}^{n} (标准化评分_i \times 归一化权重_i) \] 93 | 94 | 其中: 95 | - n: 指标总数 (n=5) 96 | - 标准化评分_i: 第i个指标的标准化评分 97 | - 归一化权重_i: 第i个指标的归一化权重 98 | 99 | ## 5. 数据采集与处理机制 100 | 101 | ### 5.1 滑动窗口参数 102 | 103 | - **窗口大小**: 24小时 104 | - **采样频率**: 5分钟/次 105 | - **更新频率**: 1小时/次 106 | 107 | ### 5.2 数据异常处理 108 | 109 | 1. **异常值检测**: 110 | - 使用3σ原则识别异常值 111 | - Z-score > 3或< -3的数据点标记为异常 112 | 113 | 2. **异常处理策略**: 114 | - 单点异常: 使用插值替代 115 | - 连续异常: 触发告警并降低其在评分中的权重 116 | 117 | ## 6. 实施建议 118 | 119 | ### 6.1 部署架构 120 | 121 | ```mermaid 122 | graph TD 123 | A[数据采集代理] --> B[中央数据处理] 124 | C[历史数据库] <--> B 125 | B --> D[评分引擎] 126 | D --> E[可视化界面] 127 | D --> F[告警系统] 128 | ``` 129 | 130 | ### 6.2 优化策略 131 | 132 | 1. **差异化阈值设置**: 133 | - 为不同地区、不同类型的代理设置差异化评分标准 134 | - 根据用户业务类型调整指标权重 135 | 136 | 2. **季节性调整**: 137 | - 识别指标的时间模式(如工作日/周末差异) 138 | - 根据季节性模式调整μ和σ的计算 139 | 140 | 3. **人工干预机制**: 141 | - 设置管理员权限覆盖异常评分 142 | - 针对特殊事件(如大规模网络维护)的评分调整机制 143 | 144 | ## 7. 参考实现 145 | 146 | ### 7.1 伪代码示例 147 | 148 | ```python 149 | # 数据采集 150 | def collect_metrics(node_id): 151 | return { 152 | 'latency': measure_latency(node_id), 153 | 'packet_loss': measure_packet_loss(node_id), 154 | 'location': get_location_score(node_id), 155 | 'ip_changes': count_ip_changes(node_id), 156 | 'ip_cleanliness': evaluate_ip_cleanliness(node_id) 157 | } 158 | 159 | # 计算Z-score 160 | def calculate_z_score(value, metric_name): 161 | mean = get_sliding_window_mean(metric_name) 162 | std_dev = get_sliding_window_std_dev(metric_name) 163 | return (value - mean) / std_dev 164 | 165 | # 标准化评分 166 | def normalize_score(z_score): 167 | return (1 - cumulative_normal_distribution(abs(z_score))) * 100 168 | 169 | # 动态权重计算 170 | def calculate_dynamic_weight(metric_name, base_weight): 171 | current_std_dev = get_sliding_window_std_dev(metric_name) 172 | historical_std_dev = get_historical_std_dev(metric_name) 173 | dynamic_factor = 1 / (current_std_dev / historical_std_dev) 174 | return base_weight * dynamic_factor 175 | 176 | # 归一化权重 177 | def normalize_weights(weight_dict): 178 | total = sum(weight_dict.values()) 179 | return {k: v/total for k, v in weight_dict.items()} 180 | 181 | # 总评分计算 182 | def calculate_final_score(normalized_scores, normalized_weights): 183 | return sum(normalized_scores[metric] * normalized_weights[metric] 184 | for metric in normalized_scores) 185 | ``` 186 | 187 | ## 8. 总结 188 | 189 | 算法通过结合正态分布模型和动态权重机制,为代理节点延迟评估提供了一套全面、客观、灵敏的评分体系。该体系具有以下特点: 190 | 191 | 1. **多维度评估**: 综合考虑延迟、丢包率、地理位置等多个性能指标 192 | 2. **动态适应性**: 通过正态分布模型对数据波动做出敏感响应 193 | 3. **自平衡机制**: 动态权重根据数据稳定性自动调整影响力 194 | 4. **异常处理能力**: 对极端数据和连续异常有检测和处理机制 195 | -------------------------------------------------------------------------------- /src/main/data-managers/log-cleanup-manager.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const logger = require('../../utils/logger'); 4 | const { getLogDir } = require('../../utils/paths'); 5 | 6 | class LogCleanupManager { 7 | constructor() { 8 | this.logRetentionDays = 7; 9 | this.cleanupInterval = null; 10 | this.cleanupIntervalMs = 24 * 60 * 60 * 1000; 11 | } 12 | 13 | startAutoCleanup() { 14 | this.performCleanup(); 15 | 16 | this.cleanupInterval = setInterval(() => { 17 | this.performCleanup(); 18 | }, this.cleanupIntervalMs); 19 | 20 | logger.info(`日志自动清理已启动,保留期限: ${this.logRetentionDays} 天`); 21 | } 22 | 23 | stopAutoCleanup() { 24 | if (this.cleanupInterval) { 25 | clearInterval(this.cleanupInterval); 26 | this.cleanupInterval = null; 27 | logger.info('日志自动清理已停止'); 28 | } 29 | } 30 | 31 | setRetentionDays(days) { 32 | if (days < 1) { 33 | return { success: false, error: '保留天数必须大于0' }; 34 | } 35 | 36 | this.logRetentionDays = days; 37 | logger.info(`日志保留期限已更新为 ${days} 天`); 38 | return { success: true }; 39 | } 40 | 41 | getRetentionDays() { 42 | return { success: true, retentionDays: this.logRetentionDays }; 43 | } 44 | 45 | performCleanup() { 46 | try { 47 | const logDir = getLogDir(); 48 | 49 | if (!fs.existsSync(logDir)) { 50 | return { success: true, deletedCount: 0 }; 51 | } 52 | 53 | const cutoffTime = Date.now() - (this.logRetentionDays * 24 * 60 * 60 * 1000); 54 | const files = fs.readdirSync(logDir); 55 | let deletedCount = 0; 56 | let deletedSize = 0; 57 | 58 | files.forEach(file => { 59 | if (file.endsWith('.log') || file.endsWith('.txt')) { 60 | const filePath = path.join(logDir, file); 61 | 62 | try { 63 | const stats = fs.statSync(filePath); 64 | 65 | if (stats.mtime.getTime() < cutoffTime) { 66 | const fileSize = stats.size; 67 | fs.unlinkSync(filePath); 68 | deletedCount++; 69 | deletedSize += fileSize; 70 | logger.debug(`已删除过期日志文件: ${file}`); 71 | } 72 | } catch (error) { 73 | logger.error(`处理日志文件失败 ${file}:`, error); 74 | } 75 | } 76 | }); 77 | 78 | if (deletedCount > 0) { 79 | const sizeMB = (deletedSize / (1024 * 1024)).toFixed(2); 80 | logger.info(`已清理 ${deletedCount} 个过期日志文件,释放空间 ${sizeMB} MB(保留 ${this.logRetentionDays} 天)`); 81 | } 82 | 83 | return { success: true, deletedCount, deletedSize }; 84 | } catch (error) { 85 | logger.error('清理过期日志文件失败:', error); 86 | return { success: false, error: error.message }; 87 | } 88 | } 89 | 90 | getLogFileStats() { 91 | try { 92 | const logDir = getLogDir(); 93 | 94 | if (!fs.existsSync(logDir)) { 95 | return { success: true, stats: { totalFiles: 0, totalSize: 0, oldestFile: null, newestFile: null } }; 96 | } 97 | 98 | const files = fs.readdirSync(logDir); 99 | let totalFiles = 0; 100 | let totalSize = 0; 101 | let oldestTime = null; 102 | let newestTime = null; 103 | 104 | files.forEach(file => { 105 | if (file.endsWith('.log') || file.endsWith('.txt')) { 106 | const filePath = path.join(logDir, file); 107 | 108 | try { 109 | const stats = fs.statSync(filePath); 110 | totalFiles++; 111 | totalSize += stats.size; 112 | 113 | const mtime = stats.mtime.getTime(); 114 | if (oldestTime === null || mtime < oldestTime) { 115 | oldestTime = mtime; 116 | } 117 | if (newestTime === null || mtime > newestTime) { 118 | newestTime = mtime; 119 | } 120 | } catch (error) { 121 | logger.error(`获取日志文件状态失败 ${file}:`, error); 122 | } 123 | } 124 | }); 125 | 126 | return { 127 | success: true, 128 | stats: { 129 | totalFiles, 130 | totalSize, 131 | totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), 132 | oldestFile: oldestTime ? new Date(oldestTime).toISOString() : null, 133 | newestFile: newestTime ? new Date(newestTime).toISOString() : null 134 | } 135 | }; 136 | } catch (error) { 137 | logger.error('获取日志文件统计失败:', error); 138 | return { success: false, error: error.message }; 139 | } 140 | } 141 | } 142 | 143 | const logCleanupManager = new LogCleanupManager(); 144 | module.exports = logCleanupManager; 145 | 146 | -------------------------------------------------------------------------------- /flatpak/README.md: -------------------------------------------------------------------------------- 1 | # lvory Flatpak 打包 2 | 3 | 这个目录包含了为 lvory Electron 应用程序创建 Flatpak 包的完整配置和脚本。 4 | 5 | ## 文件结构 6 | 7 | ``` 8 | flatpak/ 9 | ├── com.lvory.app.yml # 主要的 Flatpak 清单文件 10 | ├── com.lvory.app.desktop # 桌面文件 11 | ├── com.lvory.app.metainfo.xml # AppStream 元数据 12 | ├── lvory-wrapper.sh # 应用程序启动包装脚本 13 | ├── download-singbox.sh # sing-box 核心下载脚本 14 | ├── portable-mode-patch.js # 便携模式兼容性补丁 15 | ├── generate-sources.sh # Node.js 依赖源生成脚本 16 | ├── build.sh # 构建脚本 17 | ├── install.sh # 安装脚本 18 | ├── uninstall.sh # 卸载脚本 19 | ├── test.sh # 测试脚本 20 | └── README.md # 本文件 21 | ``` 22 | 23 | ## 快速开始 24 | 25 | ### 1. 准备环境 26 | 27 | 确保系统已安装必要的依赖: 28 | 29 | ```bash 30 | # Ubuntu/Debian 31 | sudo apt install flatpak flatpak-builder jq curl 32 | 33 | # Fedora 34 | sudo dnf install flatpak flatpak-builder jq curl 35 | 36 | # Arch Linux 37 | sudo pacman -S flatpak flatpak-builder jq curl 38 | ``` 39 | 40 | 添加 Flathub 仓库: 41 | 42 | ```bash 43 | flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 44 | ``` 45 | 46 | ### 2. 生成 Node.js 依赖源 47 | 48 | ```bash 49 | cd flatpak 50 | chmod +x generate-sources.sh 51 | ./generate-sources.sh 52 | ``` 53 | 54 | ### 3. 构建和安装 55 | 56 | ```bash 57 | chmod +x build.sh install.sh 58 | ./install.sh 59 | ``` 60 | 61 | ### 4. 运行应用程序 62 | 63 | ```bash 64 | flatpak run com.lvory.app 65 | ``` 66 | 67 | ## 详细说明 68 | 69 | ### 构建流程 70 | 71 | 1. **依赖检查**: 检查 flatpak-builder、Node.js 等必要工具 72 | 2. **运行时安装**: 自动安装所需的 Flatpak 运行时 73 | 3. **源文件生成**: 生成 NPM 依赖的离线源文件 74 | 4. **应用程序构建**: 使用 electron-builder 构建应用程序 75 | 5. **打包**: 将应用程序打包为 Flatpak 格式 76 | 77 | ### 便携模式兼容性 78 | 79 | Flatpak 版本完全支持便携模式功能: 80 | 81 | - **数据存储**: 应用程序数据存储在 `~/.var/app/com.lvory.app/data/lvory` 82 | - **配置文件**: 配置文件存储在 `~/.var/app/com.lvory.app/config/lvory` 83 | - **核心文件**: sing-box 核心文件存储在数据目录的 `cores` 子目录中 84 | - **路径映射**: 自动将便携模式路径映射到 Flatpak 沙盒路径 85 | 86 | ### sing-box 核心管理 87 | 88 | - **自动下载**: 首次运行时自动下载适合当前架构的 sing-box 核心 89 | - **版本管理**: 支持指定版本或自动获取最新版本 90 | - **权限处理**: 在 Flatpak 沙盒中正确处理核心文件权限 91 | 92 | ### 网络代理功能 93 | 94 | Flatpak 版本保留了完整的网络代理功能: 95 | 96 | - **网络访问**: 具有完整的网络访问权限 97 | - **系统代理**: 支持设置系统代理(通过 NetworkManager) 98 | - **端口监听**: 支持监听代理端口 99 | - **TUN 模式**: 支持 TUN 模式(需要额外权限) 100 | 101 | ## 脚本说明 102 | 103 | ### build.sh - 构建脚本 104 | 105 | ```bash 106 | ./build.sh [选项] 107 | 108 | 选项: 109 | -i, --install 构建后安装应用程序 110 | -r, --repo 创建本地仓库 111 | -t, --test 构建后测试应用程序 112 | -c, --clean 清理构建目录 113 | -f, --force-sources 强制重新生成依赖源 114 | ``` 115 | 116 | ### install.sh - 安装脚本 117 | 118 | ```bash 119 | ./install.sh [选项] 120 | 121 | 选项: 122 | -l, --local 从本地仓库安装 123 | -b, --build 构建并安装(默认) 124 | -s, --shortcut 创建桌面快捷方式 125 | -f, --force 强制重新安装 126 | ``` 127 | 128 | ### test.sh - 测试脚本 129 | 130 | ```bash 131 | ./test.sh [选项] 132 | 133 | 选项: 134 | -q, --quick 快速测试 135 | -v, --verbose 详细输出 136 | -c, --cleanup 测试后清理环境 137 | ``` 138 | 139 | ### uninstall.sh - 卸载脚本 140 | 141 | ```bash 142 | ./uninstall.sh [选项] 143 | 144 | 选项: 145 | -d, --remove-data 删除应用程序数据 146 | -r, --remove-runtimes 删除未使用的运行时 147 | --purge 完全清理 148 | ``` 149 | 150 | ## 开发和调试 151 | 152 | ### 进入应用程序沙盒 153 | 154 | ```bash 155 | flatpak run --command=sh com.lvory.app 156 | ``` 157 | 158 | ### 查看应用程序日志 159 | 160 | ```bash 161 | flatpak logs com.lvory.app 162 | ``` 163 | 164 | ### 调试模式启动 165 | 166 | ```bash 167 | LVORY_DEBUG=1 flatpak run com.lvory.app 168 | ``` 169 | 170 | ### 检查权限 171 | 172 | ```bash 173 | flatpak info --show-permissions com.lvory.app 174 | ``` 175 | 176 | ## 分发 177 | 178 | ### 创建本地仓库 179 | 180 | ```bash 181 | ./build.sh -r 182 | ``` 183 | 184 | ### 导出应用程序 185 | 186 | ```bash 187 | flatpak build-export repo build 188 | ``` 189 | 190 | ### 创建单文件包 191 | 192 | ```bash 193 | flatpak build-bundle repo lvory.flatpak com.lvory.app 194 | ``` 195 | 196 | ## 故障排除 197 | 198 | ### 常见问题 199 | 200 | 1. **构建失败**: 检查 Node.js 依赖源是否正确生成 201 | 2. **运行时错误**: 确保所有必要的运行时已安装 202 | 3. **权限问题**: 检查 Flatpak 权限配置 203 | 4. **网络问题**: 确保网络访问权限已正确配置 204 | 205 | ### 清理和重建 206 | 207 | ```bash 208 | # 完全清理 209 | ./uninstall.sh --purge 210 | ./build.sh -c 211 | 212 | # 重新构建 213 | ./build.sh -f -i 214 | ``` 215 | 216 | ### 查看详细日志 217 | 218 | ```bash 219 | # 构建日志 220 | ./build.sh -v 221 | 222 | # 运行日志 223 | flatpak run --verbose com.lvory.app 224 | ``` 225 | 226 | ## 跨平台兼容性 227 | 228 | 此 Flatpak 配置支持以下 Linux 发行版: 229 | 230 | - **Ubuntu/Debian** (18.04+) 231 | - **Fedora** (30+) 232 | - **Arch Linux** 233 | - **openSUSE** 234 | - **CentOS/RHEL** (8+) 235 | 236 | 支持的架构: 237 | - **x86_64** (amd64) 238 | - **aarch64** (arm64) 239 | - **armv7l** (arm32) 240 | 241 | ## 许可证 242 | 243 | 此 Flatpak 配置遵循与主项目相同的 Apache-2.0 许可证。 244 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const { DefinePlugin } = require('webpack'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | module.exports = { 10 | mode: isProduction ? 'production' : 'development', 11 | entry: './src/index.js', 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: isProduction ? '[name].[contenthash].js' : '[name].js', 15 | chunkFilename: isProduction ? '[name].[contenthash].chunk.js' : '[name].chunk.js', 16 | publicPath: isProduction ? './' : '/', 17 | clean: true 18 | }, 19 | optimization: { 20 | minimize: isProduction, 21 | minimizer: isProduction ? [ 22 | new TerserPlugin({ 23 | terserOptions: { 24 | compress: { 25 | drop_console: true, 26 | drop_debugger: true, 27 | pure_funcs: ['console.log', 'console.info', 'console.debug'] 28 | }, 29 | mangle: true, 30 | output: { 31 | comments: false 32 | } 33 | }, 34 | extractComments: false 35 | }) 36 | ] : [], 37 | splitChunks: { 38 | chunks: 'all', 39 | maxInitialRequests: 25, 40 | minSize: 20000, 41 | cacheGroups: { 42 | echarts: { 43 | test: /[\\/]node_modules[\\/]echarts[\\/]/, 44 | name: 'echarts', 45 | chunks: 'all', 46 | priority: 30, 47 | reuseExistingChunk: true 48 | }, 49 | react: { 50 | test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, 51 | name: 'react', 52 | chunks: 'all', 53 | priority: 20, 54 | reuseExistingChunk: true 55 | }, 56 | vendor: { 57 | test: /[\\/]node_modules[\\/]/, 58 | name: 'vendors', 59 | chunks: 'all', 60 | priority: 10, 61 | reuseExistingChunk: true 62 | } 63 | } 64 | }, 65 | runtimeChunk: 'single', 66 | usedExports: true, 67 | sideEffects: false 68 | }, 69 | module: { 70 | rules: [ 71 | { 72 | test: /\.(js|jsx)$/, 73 | exclude: /node_modules/, 74 | use: { 75 | loader: 'babel-loader', 76 | options: { 77 | presets: [ 78 | ['@babel/preset-env', { 79 | targets: { 80 | node: '24' 81 | }, 82 | modules: false 83 | }], 84 | ['@babel/preset-react', { 85 | runtime: 'automatic' 86 | }] 87 | ] 88 | } 89 | } 90 | }, 91 | { 92 | test: /\.css$/, 93 | use: [ 94 | isProduction 95 | ? MiniCssExtractPlugin.loader 96 | : 'style-loader', 97 | { 98 | loader: 'css-loader', 99 | options: { 100 | importLoaders: 1 101 | } 102 | } 103 | ] 104 | }, 105 | { 106 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 107 | type: 'asset', 108 | parser: { 109 | dataUrlCondition: { 110 | maxSize: 8 * 1024 // 8kb 111 | } 112 | } 113 | } 114 | ] 115 | }, 116 | resolve: { 117 | extensions: ['.js', '.jsx', '.mjs'], 118 | alias: { 119 | '@': path.resolve(__dirname, 'src') 120 | } 121 | }, 122 | plugins: [ 123 | new DefinePlugin({ 124 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 125 | }), 126 | new HtmlWebpackPlugin({ 127 | template: './public/index.html', 128 | filename: 'index.html', 129 | minify: isProduction ? { 130 | removeComments: true, 131 | collapseWhitespace: true, 132 | removeRedundantAttributes: true, 133 | useShortDoctype: true, 134 | removeEmptyAttributes: true, 135 | removeStyleLinkTypeAttributes: true, 136 | keepClosingSlash: true, 137 | minifyJS: true, 138 | minifyCSS: true, 139 | minifyURLs: true 140 | } : false 141 | }), 142 | ...(isProduction ? [ 143 | new MiniCssExtractPlugin({ 144 | filename: '[name].[contenthash].css', 145 | chunkFilename: '[name].[contenthash].chunk.css' 146 | }) 147 | ] : []) 148 | ], 149 | target: isProduction ? 'electron-renderer' : 'web', 150 | devServer: { 151 | static: { 152 | directory: path.join(__dirname, 'public'), 153 | watch: true, 154 | publicPath: '/', 155 | serveIndex: true 156 | }, 157 | port: 3000, 158 | hot: true, 159 | historyApiFallback: true, 160 | compress: true, 161 | open: false, 162 | setupMiddlewares: (middlewares, devServer) => { 163 | if (!devServer) { 164 | throw new Error('webpack-dev-server is not defined'); 165 | } 166 | return middlewares; 167 | } 168 | }, 169 | devtool: isProduction ? false : 'eval-source-map' 170 | }; -------------------------------------------------------------------------------- /src/assets/css/profile-actions.css: -------------------------------------------------------------------------------- 1 | /* 配置文件操作样式 */ 2 | .action-column { 3 | width: 60px; 4 | text-align: center; 5 | } 6 | 7 | .dropdown { 8 | position: relative; 9 | display: inline-block; 10 | } 11 | 12 | .action-button { 13 | width: 32px; 14 | height: 32px; 15 | border-radius: 4px; 16 | background-color: transparent; 17 | border: none; 18 | cursor: pointer; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | transition: background-color 0.2s; 23 | } 24 | 25 | .action-button:hover { 26 | background-color: #f0f2f5; 27 | } 28 | 29 | .action-dots { 30 | font-size: 18px; 31 | color: #666; 32 | font-weight: bold; 33 | } 34 | 35 | .dropdown-menu { 36 | position: absolute; 37 | right: 0; 38 | top: 100%; 39 | margin-top: 4px; 40 | background-color: white; 41 | border-radius: 4px; 42 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 43 | min-width: 150px; 44 | z-index: 10; 45 | overflow: hidden; 46 | } 47 | 48 | .dropdown-item { 49 | display: flex; 50 | align-items: center; 51 | padding: 8px 12px; 52 | width: 100%; 53 | border: none; 54 | background-color: transparent; 55 | text-align: left; 56 | font-size: 13px; 57 | color: #222; 58 | cursor: pointer; 59 | transition: background-color 0.2s; 60 | } 61 | 62 | .dropdown-item:hover { 63 | background-color: #f5f7f9; 64 | } 65 | 66 | .dropdown-item.delete-item { 67 | color: #e74c3c; 68 | } 69 | 70 | .dropdown-icon { 71 | width: 16px; 72 | height: 16px; 73 | margin-right: 8px; 74 | background-color: #666; 75 | mask-size: contain; 76 | mask-repeat: no-repeat; 77 | mask-position: center; 78 | -webkit-mask-size: contain; 79 | -webkit-mask-repeat: no-repeat; 80 | -webkit-mask-position: center; 81 | } 82 | 83 | .delete-item .dropdown-icon { 84 | background-color: #e74c3c; 85 | } 86 | 87 | .link-icon { 88 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E"); 89 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E"); 90 | } 91 | 92 | .edit-icon { 93 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'%3E%3C/path%3E%3Cpath d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'%3E%3C/path%3E%3C/svg%3E"); 94 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'%3E%3C/path%3E%3Cpath d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'%3E%3C/path%3E%3C/svg%3E"); 95 | } 96 | 97 | .refresh-icon { 98 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='23 4 23 10 17 10'%3E%3C/polyline%3E%3Cpath d='M20.49 15a9 9 0 1 1-2.12-9.36L23 10'%3E%3C/path%3E%3C/svg%3E"); 99 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='23 4 23 10 17 10'%3E%3C/polyline%3E%3Cpath d='M20.49 15a9 9 0 1 1-2.12-9.36L23 10'%3E%3C/path%3E%3C/svg%3E"); 100 | } 101 | 102 | .delete-icon { 103 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3,6 5,6 21,6'%3E%3C/polyline%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'%3E%3C/path%3E%3C/svg%3E"); 104 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3,6 5,6 21,6'%3E%3C/polyline%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'%3E%3C/path%3E%3C/svg%3E"); 105 | } 106 | 107 | .dropdown-divider { 108 | height: 1px; 109 | background-color: #eee; 110 | margin: 4px 0; 111 | } -------------------------------------------------------------------------------- /src/utils/sing-box/config-parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SingBox 配置解析模块 3 | * 负责解析配置文件并提取关键信息 4 | */ 5 | const fs = require('fs'); 6 | const logger = require('../logger'); 7 | 8 | class ConfigParser { 9 | constructor() { 10 | this.defaultProxyConfig = { 11 | host: '127.0.0.1', 12 | port: 7890, 13 | enableSystemProxy: true 14 | }; 15 | } 16 | 17 | /** 18 | * 解析配置文件并提取代理端口 19 | * @param {String} configPath 配置文件路径 20 | * @returns {Object} 解析结果,包含端口信息 21 | */ 22 | parseConfigFile(configPath) { 23 | try { 24 | const config = this._loadAndParseConfig(configPath); 25 | if (!config) return null; 26 | 27 | const result = { port: this.defaultProxyConfig.port }; 28 | const proxyPort = this._extractProxyPort(config.inbounds); 29 | 30 | if (proxyPort) { 31 | result.port = proxyPort; 32 | } 33 | 34 | return result; 35 | } catch (error) { 36 | logger.error(`[ConfigParser] 解析配置文件出错: ${error.message}`); 37 | return null; 38 | } 39 | } 40 | 41 | /** 42 | * 加载并解析配置文件 43 | * @param {String} configPath 配置文件路径 44 | * @returns {Object|null} 解析后的配置对象 45 | * @private 46 | */ 47 | _loadAndParseConfig(configPath) { 48 | if (!fs.existsSync(configPath)) { 49 | logger.error(`[ConfigParser] 配置文件不存在: ${configPath}`); 50 | return null; 51 | } 52 | 53 | try { 54 | const configContent = fs.readFileSync(configPath, 'utf8'); 55 | return JSON.parse(configContent); 56 | } catch (e) { 57 | logger.error(`[ConfigParser] 解析配置文件失败: ${e.message}`); 58 | return null; 59 | } 60 | } 61 | 62 | /** 63 | * 从入站配置中提取代理端口 64 | * @param {Array} inbounds 入站配置数组 65 | * @returns {Number|null} 代理端口 66 | * @private 67 | */ 68 | _extractProxyPort(inbounds) { 69 | if (!inbounds || !Array.isArray(inbounds)) { 70 | logger.warn(`[ConfigParser] 配置文件中没有找到入站配置`); 71 | return null; 72 | } 73 | 74 | logger.info(`[ConfigParser] 配置文件包含 ${inbounds.length} 个入站配置`); 75 | 76 | // 优先查找 HTTP 或 mixed 代理 77 | const httpPort = this._findPortByTypes(inbounds, ['http', 'mixed'], 'HTTP'); 78 | if (httpPort) return httpPort; 79 | 80 | // 如果没有找到 HTTP 代理,查找 SOCKS 代理 81 | const socksPort = this._findPortByTypes(inbounds, ['socks', 'mixed'], 'SOCKS'); 82 | return socksPort; 83 | } 84 | 85 | /** 86 | * 根据指定类型查找端口 87 | * @param {Array} inbounds 入站配置数组 88 | * @param {Array} types 要查找的类型数组 89 | * @param {String} logType 日志中显示的类型名称 90 | * @returns {Number|null} 找到的端口 91 | * @private 92 | */ 93 | _findPortByTypes(inbounds, types, logType) { 94 | for (const inbound of inbounds) { 95 | logger.info(`[ConfigParser] 检查入站: 类型=${inbound.type}, 端口=${inbound.listen_port}`); 96 | 97 | if (types.includes(inbound.type) && inbound.listen_port) { 98 | logger.info(`[ConfigParser] 从配置文件解析到${logType}代理端口: ${inbound.listen_port}`); 99 | return inbound.listen_port; 100 | } 101 | } 102 | return null; 103 | } 104 | 105 | /** 106 | * 验证配置文件格式 107 | * @param {String} configPath 配置文件路径 108 | * @returns {Object} 验证结果 109 | */ 110 | validateConfig(configPath) { 111 | try { 112 | if (!fs.existsSync(configPath)) { 113 | return { valid: false, error: '配置文件不存在' }; 114 | } 115 | 116 | const configContent = fs.readFileSync(configPath, 'utf8'); 117 | 118 | try { 119 | const config = JSON.parse(configContent); 120 | 121 | // 基本结构验证 122 | if (!config.inbounds || !Array.isArray(config.inbounds)) { 123 | return { valid: false, error: '配置文件缺少有效的入站配置' }; 124 | } 125 | 126 | if (!config.outbounds || !Array.isArray(config.outbounds)) { 127 | return { valid: false, error: '配置文件缺少有效的出站配置' }; 128 | } 129 | 130 | return { valid: true, config }; 131 | } catch (e) { 132 | return { valid: false, error: `JSON 格式错误: ${e.message}` }; 133 | } 134 | } catch (error) { 135 | return { valid: false, error: `读取文件失败: ${error.message}` }; 136 | } 137 | } 138 | 139 | /** 140 | * 提取配置信息摘要 141 | * @param {String} configPath 配置文件路径 142 | * @returns {Object} 配置摘要 143 | */ 144 | getConfigSummary(configPath) { 145 | const parseResult = this.parseConfigFile(configPath); 146 | const validationResult = this.validateConfig(configPath); 147 | 148 | if (!validationResult.valid) { 149 | return { 150 | valid: false, 151 | error: validationResult.error 152 | }; 153 | } 154 | 155 | const config = validationResult.config; 156 | 157 | return { 158 | valid: true, 159 | proxyPort: parseResult ? parseResult.port : this.defaultProxyConfig.port, 160 | inboundsCount: config.inbounds ? config.inbounds.length : 0, 161 | outboundsCount: config.outbounds ? config.outbounds.length : 0, 162 | inboundTypes: config.inbounds ? config.inbounds.map(i => i.type).filter(Boolean) : [], 163 | hasTunMode: config.inbounds ? config.inbounds.some(i => i.type === 'tun') : false, 164 | hasRoute: !!config.route 165 | }; 166 | } 167 | } 168 | 169 | module.exports = ConfigParser; -------------------------------------------------------------------------------- /src/assets/css/servicenodes.css: -------------------------------------------------------------------------------- 1 | .service-nodes { 2 | padding: 20px; 3 | background: #fff; 4 | border-radius: 8px; 5 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 6 | } 7 | 8 | .nodes-header { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | margin-bottom: 20px; 13 | } 14 | 15 | .nodes-header h3 { 16 | margin: 0; 17 | color: #333; 18 | } 19 | 20 | .speed-test-icon { 21 | position: relative; 22 | width: 36px; 23 | height: 36px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | background: #f0f4ff; 28 | color: #4CAF50; 29 | border-radius: 50%; 30 | cursor: pointer; 31 | transition: all 0.3s ease; 32 | } 33 | 34 | .speed-test-icon:hover { 35 | background: #e0ebff; 36 | transform: scale(1.05); 37 | } 38 | 39 | .speed-test-icon svg { 40 | width: 24px; 41 | height: 24px; 42 | } 43 | 44 | .speed-test-icon svg.spinning { 45 | animation: spin 1.5s linear infinite; 46 | } 47 | 48 | @keyframes spin { 49 | 0% { transform: rotate(0deg); } 50 | 100% { transform: rotate(360deg); } 51 | } 52 | 53 | .testing-tooltip { 54 | position: absolute; 55 | top: -30px; 56 | right: -20px; 57 | background: rgba(0, 0, 0, 0.7); 58 | color: white; 59 | padding: 4px 8px; 60 | border-radius: 4px; 61 | font-size: 12px; 62 | white-space: nowrap; 63 | } 64 | 65 | .testing-tooltip:after { 66 | content: ''; 67 | position: absolute; 68 | bottom: -5px; 69 | right: 30px; 70 | border-left: 5px solid transparent; 71 | border-right: 5px solid transparent; 72 | border-top: 5px solid rgba(0, 0, 0, 0.7); 73 | } 74 | 75 | .test-speed-btn { 76 | padding: 8px 16px; 77 | background: #4CAF50; 78 | color: white; 79 | border: none; 80 | border-radius: 4px; 81 | cursor: pointer; 82 | transition: background 0.3s; 83 | } 84 | 85 | .test-speed-btn:hover { 86 | background: #45a049; 87 | } 88 | 89 | .test-speed-btn.testing { 90 | background: #cccccc; 91 | cursor: not-allowed; 92 | } 93 | 94 | .nodes-grid { 95 | display: grid; 96 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 97 | gap: 16px; 98 | } 99 | 100 | .node-card { 101 | padding: 16px; 102 | background: #f5f5f5; 103 | border-radius: 6px; 104 | border: 1px solid #e0e0e0; 105 | } 106 | 107 | .node-name { 108 | font-weight: bold; 109 | margin-bottom: 8px; 110 | color: #333; 111 | } 112 | 113 | .node-type { 114 | font-size: 0.9em; 115 | color: #666; 116 | margin-bottom: 8px; 117 | } 118 | 119 | .node-stats { 120 | display: flex; 121 | flex-direction: column; 122 | gap: 8px; 123 | } 124 | 125 | .node-delay { 126 | font-size: 0.9em; 127 | color: #4CAF50; 128 | } 129 | 130 | .node-delay .timeout { 131 | color: #f44336; 132 | } 133 | 134 | .node-traffic { 135 | margin-top: 4px; 136 | display: flex; 137 | flex-direction: column; 138 | gap: 4px; 139 | font-size: 0.85em; 140 | } 141 | 142 | .traffic-item { 143 | display: flex; 144 | justify-content: space-between; 145 | color: #666; 146 | } 147 | 148 | .traffic-item.total { 149 | margin-top: 2px; 150 | font-weight: bold; 151 | color: #333; 152 | border-top: 1px dashed #ddd; 153 | padding-top: 4px; 154 | } 155 | 156 | .traffic-label { 157 | margin-right: 8px; 158 | } 159 | 160 | .traffic-value { 161 | font-family: monospace; 162 | } 163 | 164 | .tabs { 165 | display: flex; 166 | gap: 20px; 167 | } 168 | 169 | .tab { 170 | cursor: pointer; 171 | padding: 6px 0; 172 | position: relative; 173 | color: #666; 174 | font-weight: 500; 175 | transition: color 0.3s; 176 | } 177 | 178 | .tab:hover { 179 | color: #333; 180 | } 181 | 182 | .tab.active { 183 | color: #4CAF50; 184 | font-weight: 600; 185 | } 186 | 187 | .tab.active:after { 188 | content: ''; 189 | position: absolute; 190 | left: 0; 191 | bottom: -2px; 192 | width: 100%; 193 | height: 2px; 194 | background-color: #4CAF50; 195 | } 196 | 197 | .rule-set-card { 198 | background: #f8f9fa; 199 | transition: transform 0.2s, box-shadow 0.2s; 200 | } 201 | 202 | .rule-set-card:hover { 203 | transform: translateY(-2px); 204 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 205 | } 206 | 207 | .rule-tag { 208 | display: flex; 209 | align-items: center; 210 | gap: 8px; 211 | margin-bottom: 8px; 212 | } 213 | 214 | .rule-type-indicator { 215 | width: 8px; 216 | height: 8px; 217 | border-radius: 50%; 218 | flex-shrink: 0; 219 | } 220 | 221 | .rule-name { 222 | font-weight: bold; 223 | color: #333; 224 | white-space: nowrap; 225 | overflow: hidden; 226 | text-overflow: ellipsis; 227 | } 228 | 229 | .rule-info { 230 | font-size: 0.85em; 231 | color: #666; 232 | } 233 | 234 | .rule-type { 235 | display: inline-block; 236 | background-color: #f0f0f0; 237 | padding: 2px 6px; 238 | border-radius: 4px; 239 | font-size: 0.9em; 240 | margin-bottom: 6px; 241 | } 242 | 243 | .rule-url { 244 | font-size: 0.9em; 245 | color: #888; 246 | margin-bottom: 4px; 247 | white-space: nowrap; 248 | overflow: hidden; 249 | text-overflow: ellipsis; 250 | } 251 | 252 | .rule-update { 253 | font-size: 0.9em; 254 | color: #888; 255 | } 256 | 257 | .empty-rules { 258 | width: 100%; 259 | text-align: center; 260 | padding: 30px; 261 | color: #999; 262 | font-style: italic; 263 | } -------------------------------------------------------------------------------- /flatpak/com.lvory.app.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.lvory.app 4 | CC0-1.0 5 | Apache-2.0 6 | lvory 7 | lvory 8 | Minimalist Cross-Platform Client for Singbox 9 | 简约跨平台 Sing-box 客户端 10 | 11 | 12 |

13 | lvory is a minimalist cross-platform GUI client for sing-box, providing easy proxy management and configuration. 14 | It offers a clean and intuitive interface for managing your network proxy settings with sing-box backend. 15 |

16 |

17 | lvory 是一个简约的跨平台 sing-box GUI 客户端,提供简单的代理管理和配置功能。 18 | 它为 sing-box 后端提供了一个清洁直观的界面来管理您的网络代理设置。 19 |

20 |

Features:

21 |
    22 |
  • Clean and intuitive user interface
  • 23 |
  • Easy proxy configuration management
  • 24 |
  • Real-time connection monitoring
  • 25 |
  • Cross-platform support (Windows, macOS, Linux)
  • 26 |
  • Portable mode support
  • 27 |
  • Built-in traceroute functionality
  • 28 |
  • Multi-language support
  • 29 |
30 |

功能特性:

31 |
    32 |
  • 简洁直观的用户界面
  • 33 |
  • 简单的代理配置管理
  • 34 |
  • 实时连接监控
  • 35 |
  • 跨平台支持 (Windows, macOS, Linux)
  • 36 |
  • 便携模式支持
  • 37 |
  • 内置路由追踪功能
  • 38 |
  • 多语言支持
  • 39 |
40 |
41 | 42 | com.lvory.app.desktop 43 | 44 | 45 | 46 | Main interface 47 | 主界面 48 | https://raw.githubusercontent.com/sxueck/lvory/main/docs/screenshot/main.png 49 | 50 | 51 | 52 | https://github.com/sxueck/lvory 53 | https://github.com/sxueck/lvory/issues 54 | https://github.com/sxueck/lvory#readme 55 | https://github.com/sxueck/lvory 56 | 57 | lvory Team 58 | lvory 团队 59 | 60 | 61 | Network 62 | Utility 63 | 64 | 65 | 66 | proxy 67 | vpn 68 | singbox 69 | network 70 | tunnel 71 | 代理 72 | 网络 73 | 隧道 74 | 75 | 76 | 77 | lvory 78 | 79 | 80 | 81 | 82 | 83 |

Latest stable release with improved performance and bug fixes

84 |

最新稳定版本,改进了性能并修复了错误

85 |
86 |
87 |
88 | 89 | 90 | none 91 | none 92 | none 93 | none 94 | none 95 | none 96 | none 97 | none 98 | none 99 | none 100 | none 101 | none 102 | none 103 | none 104 | none 105 | none 106 | none 107 | none 108 | none 109 | none 110 | none 111 | mild 112 | none 113 | none 114 | none 115 | none 116 | none 117 | 118 |
119 | -------------------------------------------------------------------------------- /docs/ipc-optimization.md: -------------------------------------------------------------------------------- 1 | # IPC 接口优化说明 2 | 3 | ## 概述 4 | 5 | 为了减少资源消耗和提高代码维护性,我对 IPC 接口进行了合并优化。优化主要将相关功能的 IPC 接口合并到统一的命名空间下,移除了重复和冗余的接口,实现更清晰的架构。 6 | 7 | ## 优化内容 8 | 9 | ### 1. 窗口管理接口合并 10 | 11 | **统一接口:** 12 | ```javascript 13 | electron.window.minimize() // 最小化窗口 14 | electron.window.maximize() // 最大化窗口 15 | electron.window.close() // 关闭窗口 16 | electron.window.show() // 显示窗口 17 | electron.window.quit() // 退出应用 18 | electron.window.onVisibilityChange(callback) // 监听窗口可见性变化 19 | ``` 20 | 21 | ### 2. 下载管理接口合并 22 | 23 | **统一接口:** 24 | ```javascript 25 | electron.download.profile(data) // 下载配置文件 26 | electron.download.core() // 下载核心 27 | electron.download.onCoreProgress(callback) // 监听核心下载进度 28 | electron.download.onComplete(callback) // 监听下载完成 29 | ``` 30 | 31 | ### 3. SingBox 版本接口优化 32 | 33 | **优化:** 34 | - 移除了重复的 `getSingBoxVersion()` 接口 35 | - 统一使用 `electron.singbox.getVersion()` 36 | - 将版本更新监听合并到 SingBox 命名空间下 37 | 38 | **接口:** 39 | ```javascript 40 | electron.singbox.getVersion() // 获取版本 41 | electron.singbox.onVersionUpdate(callback) // 监听版本更新 42 | ``` 43 | 44 | ### 4. 配置文件管理接口合并 45 | 46 | **统一接口:** 47 | ```javascript 48 | electron.profiles.getData() // 获取配置数据 49 | electron.profiles.getFiles() // 获取配置文件列表 50 | electron.profiles.getMetadata(fileName) // 获取元数据 51 | electron.profiles.update(fileName) // 更新配置文件 52 | electron.profiles.updateAll() // 更新所有配置文件 53 | electron.profiles.delete(fileName) // 删除配置文件 54 | electron.profiles.openInEditor(fileName) // 在编辑器中打开 55 | electron.profiles.openAddDialog() // 打开添加对话框 56 | electron.profiles.onData(callback) // 监听数据变化 57 | electron.profiles.onUpdated(callback) // 监听更新事件 58 | electron.profiles.onChanged(callback) // 监听变更事件 59 | ``` 60 | 61 | ### 5. 配置路径管理接口合并 62 | 63 | **统一接口:** 64 | ```javascript 65 | electron.config.getPath() // 获取配置路径 66 | electron.config.setPath(path) // 设置配置路径 67 | electron.config.getCurrent() // 获取当前配置 68 | ``` 69 | 70 | ### 6. 日志管理接口优化 71 | 72 | **统一接口:** 73 | ```javascript 74 | electron.logs.onMessage(callback) // 监听日志消息 75 | electron.logs.onActivity(callback) // 监听活动日志 76 | electron.logs.onConnection(callback) // 监听连接日志 77 | electron.logs.getHistory() // 获取日志历史 78 | electron.logs.getConnectionHistory() // 获取连接日志历史 79 | electron.logs.clear() // 清除日志 80 | electron.logs.clearConnection() // 清除连接日志 81 | electron.logs.startConnectionMonitoring() // 开始连接监听 82 | electron.logs.stopConnectionMonitoring() // 停止连接监听 83 | ``` 84 | 85 | ### 7. 设置管理接口合并 86 | 87 | **统一接口:** 88 | ```javascript 89 | electron.settings.save(settings) // 保存设置 90 | electron.settings.get() // 获取设置 91 | electron.settings.setAutoLaunch(enable) // 设置开机自启 92 | electron.settings.getAutoLaunch() // 获取开机自启状态 93 | ``` 94 | 95 | ### 8. 节点管理接口合并 96 | 97 | **统一接口:** 98 | ```javascript 99 | electron.nodes.getHistory(nodeTag) // 获取节点历史 100 | electron.nodes.loadAllHistory() // 加载所有历史 101 | electron.nodes.isHistoryEnabled() // 检查历史功能是否启用 102 | electron.nodes.getTotalTraffic(nodeTag) // 获取节点总流量 103 | electron.nodes.getAllTotalTraffic() // 获取所有节点总流量 104 | electron.nodes.resetTotalTraffic(nodeTag) // 重置节点总流量 105 | ``` 106 | 107 | ### 9. 版本管理接口合并 108 | 109 | **统一接口:** 110 | ```javascript 111 | electron.version.checkForUpdates() // 检查更新 112 | electron.version.getAll() // 获取所有版本 113 | ``` 114 | 115 | ## 使用指南 116 | 117 | ### 新的接口结构 118 | 119 | 所有接口都采用统一的命名空间结构: 120 | 121 | ```javascript 122 | // 窗口操作 123 | electron.window.minimize(); 124 | electron.window.show(); 125 | 126 | // 下载管理 127 | electron.download.profile(data); 128 | electron.download.core(); 129 | 130 | // 配置管理 131 | electron.profiles.getFiles(); 132 | electron.config.getPath(); 133 | 134 | // 系统设置 135 | electron.settings.save(settings); 136 | electron.settings.setAutoLaunch(true); 137 | 138 | // 日志系统 139 | electron.logs.getHistory(); 140 | electron.logs.clear(); 141 | 142 | // 节点管理 143 | electron.nodes.getHistory(nodeTag); 144 | electron.nodes.getTotalTraffic(nodeTag); 145 | 146 | // 版本管理 147 | electron.version.checkForUpdates(); 148 | ``` 149 | 150 | ## 优化效果 151 | 152 | 1. **显著减少内存占用**:移除重复接口,减少事件监听器数量 153 | 2. **提高代码可维护性**:相关功能集中在统一命名空间下 154 | 3. **改善开发体验**:更清晰的接口组织结构,易于理解和使用 155 | 4. **减少bundle大小**:移除冗余代码,优化打包体积 156 | 5. **提升性能**:减少IPC通道数量,提高通信效率 157 | 158 | ## 迁移注意事项 159 | 160 | 由于移除了旧的接口,需要将现有代码更新为新的接口: 161 | 162 | ### 重要变更 163 | 164 | 1. **窗口控制**:`minimizeWindow()` → `window.minimize()` 165 | 2. **下载功能**:`downloadProfile()` → `download.profile()` 166 | 3. **配置管理**:`getProfileFiles()` → `profiles.getFiles()` 167 | 4. **设置管理**:`saveSettings()` → `settings.save()` 168 | 5. **节点管理**:`getNodeHistory()` → `nodes.getHistory()` 169 | 6. **版本管理**:`checkForUpdates()` → `version.checkForUpdates()` 170 | 171 | ### 快速迁移 172 | 173 | 可以使用查找替换功能快速更新代码: 174 | 175 | ```javascript 176 | // 查找: electron.minimizeWindow() 177 | // 替换: electron.window.minimize() 178 | 179 | // 查找: electron.downloadProfile( 180 | // 替换: electron.download.profile( 181 | 182 | // 查找: electron.getProfileFiles() 183 | // 替换: electron.profiles.getFiles() 184 | ``` 185 | 186 | ## 最佳实践 187 | 188 | 1. **使用新的统一接口**进行所有新功能开发 189 | 2. **保持命名空间一致性**,按功能模块组织代码 190 | 3. **利用IDE的自动完成**功能,提高开发效率 191 | 4. **参考文档**了解新接口的完整功能 192 | -------------------------------------------------------------------------------- /src/utils/config-processor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 配置文件处理工具 3 | * 用于处理下载的配置文件,包括验证和基本清理 4 | */ 5 | 6 | const logger = require('./logger'); 7 | 8 | /** 9 | * 检测配置是否包含TUN相关配置 10 | * @param {Object} config 配置对象 11 | * @returns {Boolean} 是否包含TUN配置 12 | */ 13 | function hasTunConfiguration(config) { 14 | if (!config || !config.inbounds || !Array.isArray(config.inbounds)) { 15 | return false; 16 | } 17 | 18 | return config.inbounds.some(inbound => inbound.type === 'tun'); 19 | } 20 | 21 | /** 22 | * 从配置中移除所有TUN相关配置(仅在特定情况下使用) 23 | * @param {Object} config 配置对象 24 | * @returns {Object} 清理后的配置对象 25 | */ 26 | function removeTunConfiguration(config) { 27 | if (!config || typeof config !== 'object') { 28 | return config; 29 | } 30 | 31 | // 深拷贝配置以避免修改原始对象 32 | const cleanedConfig = JSON.parse(JSON.stringify(config)); 33 | 34 | // 移除TUN入站配置 35 | if (cleanedConfig.inbounds && Array.isArray(cleanedConfig.inbounds)) { 36 | const originalLength = cleanedConfig.inbounds.length; 37 | cleanedConfig.inbounds = cleanedConfig.inbounds.filter(inbound => { 38 | if (inbound.type === 'tun') { 39 | logger.info(`移除TUN入站配置: ${inbound.tag || 'unnamed'}`); 40 | return false; 41 | } 42 | return true; 43 | }); 44 | 45 | const removedCount = originalLength - cleanedConfig.inbounds.length; 46 | if (removedCount > 0) { 47 | logger.info(`共移除 ${removedCount} 个TUN入站配置`); 48 | } 49 | } 50 | 51 | // 移除路由中的TUN相关规则 52 | if (cleanedConfig.route && cleanedConfig.route.rules && Array.isArray(cleanedConfig.route.rules)) { 53 | const originalLength = cleanedConfig.route.rules.length; 54 | cleanedConfig.route.rules = cleanedConfig.route.rules.filter(rule => { 55 | if (rule.inbound && Array.isArray(rule.inbound)) { 56 | rule.inbound = rule.inbound.filter(inbound => inbound !== 'tun-in' && inbound !== 'tun'); 57 | return rule.inbound.length > 0; 58 | } else if (rule.inbound === 'tun-in' || rule.inbound === 'tun') { 59 | logger.info('移除指向TUN的路由规则'); 60 | return false; 61 | } 62 | return true; 63 | }); 64 | 65 | const removedRules = originalLength - cleanedConfig.route.rules.length; 66 | if (removedRules > 0) { 67 | logger.info(`移除 ${removedRules} 个TUN相关路由规则`); 68 | } 69 | } 70 | 71 | // 移除实验性配置中的TUN相关项 72 | if (cleanedConfig.experimental) { 73 | if (cleanedConfig.experimental.clash_api && cleanedConfig.experimental.clash_api.tun) { 74 | delete cleanedConfig.experimental.clash_api.tun; 75 | logger.info('移除实验性配置中的TUN设置'); 76 | } 77 | } 78 | 79 | return cleanedConfig; 80 | } 81 | 82 | /** 83 | * 处理下载的配置文件 84 | * @param {String} content 配置文件内容 85 | * @param {String} fileName 文件名 86 | * @returns {Object} 处理结果 87 | */ 88 | function processDownloadedConfig(content, fileName) { 89 | try { 90 | // 尝试解析JSON配置 91 | let config; 92 | try { 93 | config = JSON.parse(content); 94 | } catch (parseError) { 95 | // 不是JSON格式,可能是YAML或其他格式 96 | logger.info(`文件 ${fileName} 不是JSON格式,跳过处理`); 97 | return { 98 | success: true, 99 | content: content, 100 | modified: false, 101 | message: '非JSON格式,跳过处理' 102 | }; 103 | } 104 | 105 | // 注意:不再自动清理TUN配置 106 | // TUN配置现在由映射引擎根据用户设置动态管理 107 | logger.info(`配置文件 ${fileName} 处理完成,TUN配置由系统设置管控`); 108 | 109 | return { 110 | success: true, 111 | content: content, 112 | modified: false, 113 | message: 'TUN配置由程序设置管控' 114 | }; 115 | 116 | } catch (error) { 117 | logger.error(`处理配置文件 ${fileName} 时出错: ${error.message}`); 118 | return { 119 | success: false, 120 | content: content, 121 | modified: false, 122 | error: error.message 123 | }; 124 | } 125 | } 126 | 127 | /** 128 | * 验证配置文件格式 129 | * @param {String} content 配置文件内容 130 | * @param {String} fileName 文件名 131 | * @returns {Object} 验证结果 132 | */ 133 | function validateConfig(content, fileName) { 134 | try { 135 | // 检测文件类型 136 | const isYaml = fileName.endsWith('.yaml') || fileName.endsWith('.yml'); 137 | const isJson = fileName.endsWith('.json'); 138 | 139 | if (isYaml) { 140 | // YAML文件验证 141 | return { 142 | valid: true, 143 | type: 'yaml', 144 | message: 'YAML配置文件' 145 | }; 146 | } 147 | 148 | if (isJson) { 149 | // 验证JSON格式 150 | const config = JSON.parse(content); 151 | 152 | // 基本结构验证 153 | if (!config.inbounds && !config.outbounds) { 154 | return { 155 | valid: false, 156 | type: 'json', 157 | message: '配置文件缺少基本的入站或出站配置' 158 | }; 159 | } 160 | 161 | return { 162 | valid: true, 163 | type: 'json', 164 | message: 'SingBox配置文件' 165 | }; 166 | } 167 | 168 | // 尝试当作JSON解析 169 | try { 170 | JSON.parse(content); 171 | return { 172 | valid: true, 173 | type: 'json', 174 | message: 'JSON配置文件' 175 | }; 176 | } catch { 177 | return { 178 | valid: false, 179 | type: 'unknown', 180 | message: '未知格式的配置文件' 181 | }; 182 | } 183 | 184 | } catch (error) { 185 | return { 186 | valid: false, 187 | type: 'unknown', 188 | message: error.message 189 | }; 190 | } 191 | } 192 | 193 | module.exports = { 194 | hasTunConfiguration, 195 | removeTunConfiguration, 196 | processDownloadedConfig, 197 | validateConfig 198 | }; -------------------------------------------------------------------------------- /src/assets/css/stats-overview.css: -------------------------------------------------------------------------------- 1 | /* Stats Overview 组件样式 - 基于 Material Design 3 */ 2 | 3 | .stats-overview-container { 4 | display: flex; 5 | flex-direction: column; 6 | width: 100%; 7 | height: 100%; 8 | padding: 0px 10px 10px 10px; 9 | box-sizing: border-box; 10 | overflow: hidden; 11 | border-radius: 16px; 12 | position: relative; 13 | margin-top: 8px; 14 | line-height: 1.5; 15 | } 16 | 17 | .stats-content { 18 | display: flex; 19 | width: 100%; 20 | height: 100%; 21 | } 22 | 23 | .stats-metrics { 24 | flex: 1 1 50%; 25 | display: flex; 26 | flex-direction: column; 27 | padding-left: 20px; 28 | } 29 | 30 | .stats-header { 31 | margin-bottom: 15px; 32 | padding-left: 0; 33 | } 34 | 35 | .stats-title { 36 | font-size: 32px; 37 | font-weight: 500; 38 | color: var(--md-sys-color-on-surface, #1C1B1F); /* MD3 系统颜色 - 在表面上的文字 */ 39 | margin: 0 0 2px 0; 40 | font-family: 'Roboto', 'Segoe UI', Arial, sans-serif; /* MD3 推荐字体 */ 41 | letter-spacing: 0; 42 | } 43 | 44 | .stats-date { 45 | font-size: 12px; 46 | color: var(--md-sys-color-on-surface-variant, #49454F); /* MD3 系统颜色 - 表面变体上的文字 */ 47 | font-family: 'Roboto', 'Segoe UI', Arial, sans-serif; 48 | margin-top: 2px; 49 | cursor: pointer; 50 | transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1); /* MD3 标准过渡效果 */ 51 | white-space: nowrap; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | max-width: 100%; 55 | } 56 | 57 | .stats-date:hover { 58 | color: var(--md-sys-color-primary, #6750A4); 59 | } 60 | 61 | .ip-location { 62 | font-size: 12px; 63 | color: var(--md-sys-color-on-surface-variant, #49454F); 64 | font-family: 'Roboto', 'Segoe UI', Arial, sans-serif; 65 | margin-top: -7px; 66 | margin-bottom: 5px; 67 | padding-left: 1px; 68 | } 69 | 70 | .metrics-row { 71 | display: flex; 72 | padding: 0; 73 | margin-top: auto; 74 | margin-bottom: 12px; 75 | } 76 | 77 | .metric-item { 78 | text-align: left; 79 | flex: 1; 80 | padding: 8px 0px; /* 增加内边距 */ 81 | display: flex; 82 | flex-direction: column; 83 | align-items: left; 84 | max-width: 22%; 85 | } 86 | 87 | .metric-value { 88 | font-size: 24px; 89 | font-weight: 500; /* MD3 使用 Medium 字重 */ 90 | color: var(--md-sys-color-on-surface, #1C1B1F); 91 | font-family: 'Roboto', 'Segoe UI', Arial, sans-serif; 92 | line-height: 1.1; 93 | display: flex; 94 | align-items: flex-end; 95 | } 96 | 97 | .metric-unit { 98 | font-size: 12px; 99 | margin-left: 2px; 100 | color: var(--md-sys-color-on-surface-variant, #49454F); 101 | font-weight: 400; 102 | line-height: 1.5; 103 | display: inline-block; 104 | margin-bottom: -2px; 105 | } 106 | 107 | .metric-label-container { 108 | display: flex; 109 | align-items: left; 110 | justify-content: left; 111 | margin-top: 2px; 112 | } 113 | 114 | .metric-icon { 115 | display: none; 116 | } 117 | 118 | .metric-icon-upload, .metric-icon-download { 119 | display: none; 120 | } 121 | 122 | .icon-container { 123 | display: none; 124 | } 125 | 126 | .metric-symbol { 127 | display: none; 128 | } 129 | 130 | .metric-label { 131 | font-size: 11px; 132 | color: var(--md-sys-color-on-surface-variant, #49454F); 133 | font-weight: 500; 134 | font-family: 'Roboto', 'Segoe UI', Arial, sans-serif; 135 | text-transform: uppercase; 136 | letter-spacing: 0.5px; 137 | } 138 | 139 | .gauge-container { 140 | flex: 1 1 45%; 141 | min-width: 200px; 142 | display: flex; 143 | flex-direction: column; 144 | align-items: center; 145 | justify-content: center; 146 | padding: 0 5px; 147 | scale: 0.9; 148 | border-radius: 12px; 149 | transition: all 0.3s ease; 150 | } 151 | 152 | .gauge-container:hover { 153 | transform: translateY(-2px); 154 | } 155 | 156 | .gauge-wrapper { 157 | width: 100%; 158 | height: 100%; 159 | display: flex; 160 | flex-direction: column; 161 | align-items: center; 162 | justify-content: center; 163 | padding: 0 5px; 164 | } 165 | 166 | .gauge-chart { 167 | width: 100%; 168 | height: 100%; 169 | position: relative; 170 | border-radius: 8px; 171 | transition: all 0.25s ease-in-out; 172 | } 173 | 174 | .gauge-label { 175 | position: relative; 176 | color: var(--md-sys-color-primary, #6750A4); /* MD3 系统颜色 - 主色 */ 177 | font-size: 13px; 178 | font-weight: 500; /* MD3 使用 Medium 字重 */ 179 | font-family: 'Roboto', 'Arial', sans-serif; 180 | text-align: center; 181 | margin-top: 0; 182 | letter-spacing: 0.5px; 183 | transition: all 0.2s ease; 184 | } 185 | 186 | .gauge-container:hover .gauge-label { 187 | transform: scale(1.05); 188 | letter-spacing: 0.6px; 189 | } 190 | 191 | .metric-value-wrapper { 192 | display: flex; 193 | align-items: center; 194 | justify-content: center; 195 | } 196 | 197 | #upload-metric .metric-value { 198 | color: var(--md-sys-color-primary, #6750A4); /* 主色 */ 199 | } 200 | 201 | #download-metric .metric-value { 202 | color: var(--md-sys-color-tertiary, #7D5260); /* 第三色 */ 203 | } 204 | 205 | #total-metric .metric-value { 206 | color: var(--md-sys-color-secondary, #625B71); /* 次要色 */ 207 | } 208 | 209 | #latency-metric .metric-value { 210 | color: var(--md-sys-color-error, #B3261E); /* 错误色 */ 211 | } 212 | 213 | /* 添加动画效果 */ 214 | @keyframes pulse { 215 | 0% { opacity: 1; transform: scale(1); } 216 | 50% { opacity: 0.85; transform: scale(0.99); } 217 | 100% { opacity: 1; transform: scale(1); } 218 | } 219 | 220 | @keyframes shimmer { 221 | 0% { background-position: -200% 0; } 222 | 100% { background-position: 200% 0; } 223 | } 224 | 225 | .stats-overview-container:hover .gauge-chart { 226 | animation: pulse 3s ease-in-out infinite; 227 | } 228 | -------------------------------------------------------------------------------- /src/utils/sing-box/state-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SingBox 状态管理模块 3 | * 负责管理全局状态、监听器和状态通知 4 | */ 5 | const logger = require('../logger'); 6 | 7 | class StateManager { 8 | constructor() { 9 | this.globalState = { 10 | isRunning: false, 11 | isInitialized: false, 12 | lastError: null, 13 | startTime: null, 14 | connectionMonitor: { 15 | enabled: false, 16 | retryCount: 0, 17 | maxRetries: 5, 18 | retryDelay: 3000, 19 | lastRetryTime: null 20 | } 21 | }; 22 | 23 | this.stateListeners = new Set(); 24 | } 25 | 26 | /** 27 | * 添加状态监听器 28 | * @param {Function} listener 状态变化监听器 29 | */ 30 | addStateListener(listener) { 31 | if (typeof listener === 'function') { 32 | this.stateListeners.add(listener); 33 | logger.info(`[StateManager] 添加状态监听器,当前监听器数量: ${this.stateListeners.size}`); 34 | } 35 | } 36 | 37 | /** 38 | * 移除状态监听器 39 | * @param {Function} listener 要移除的监听器 40 | */ 41 | removeStateListener(listener) { 42 | this.stateListeners.delete(listener); 43 | logger.info(`[StateManager] 移除状态监听器,当前监听器数量: ${this.stateListeners.size}`); 44 | } 45 | 46 | /** 47 | * 通知所有状态监听器 48 | * @param {Object} stateChange 状态变化信息 49 | */ 50 | notifyStateListeners(stateChange) { 51 | const notification = { 52 | ...stateChange, 53 | timestamp: Date.now(), 54 | globalState: { ...this.globalState } 55 | }; 56 | 57 | logger.info(`[StateManager] 通知状态变化: ${JSON.stringify(stateChange)}`); 58 | 59 | this.stateListeners.forEach(listener => { 60 | try { 61 | listener(notification); 62 | } catch (error) { 63 | logger.error(`[StateManager] 状态监听器执行失败: ${error.message}`); 64 | } 65 | }); 66 | } 67 | 68 | /** 69 | * 更新全局状态 70 | * @param {Object} updates 状态更新 71 | */ 72 | updateGlobalState(updates) { 73 | const oldState = { ...this.globalState }; 74 | this.globalState = { ...this.globalState, ...updates }; 75 | 76 | this.notifyStateListeners({ 77 | type: 'state-update', 78 | oldState, 79 | newState: { ...this.globalState }, 80 | changes: updates 81 | }); 82 | } 83 | 84 | /** 85 | * 获取全局状态 86 | */ 87 | getGlobalState() { 88 | return { ...this.globalState }; 89 | } 90 | 91 | /** 92 | * 重置连接监控状态 93 | */ 94 | resetConnectionMonitor() { 95 | this.updateGlobalState({ 96 | connectionMonitor: { 97 | enabled: false, 98 | retryCount: 0, 99 | maxRetries: 5, 100 | retryDelay: 3000, 101 | lastRetryTime: null 102 | } 103 | }); 104 | 105 | this.notifyStateListeners({ 106 | type: 'connection-monitor-reset', 107 | message: '连接监控已重置' 108 | }); 109 | } 110 | 111 | /** 112 | * 启用连接监控 113 | */ 114 | enableConnectionMonitor() { 115 | this.updateGlobalState({ 116 | connectionMonitor: { 117 | ...this.globalState.connectionMonitor, 118 | enabled: true, 119 | retryCount: 0 120 | } 121 | }); 122 | 123 | this.notifyStateListeners({ 124 | type: 'connection-monitor-enabled', 125 | message: '连接监控已启用' 126 | }); 127 | } 128 | 129 | /** 130 | * 禁用连接监控 131 | */ 132 | disableConnectionMonitor() { 133 | this.updateGlobalState({ 134 | connectionMonitor: { 135 | ...this.globalState.connectionMonitor, 136 | enabled: false 137 | } 138 | }); 139 | 140 | this.notifyStateListeners({ 141 | type: 'connection-monitor-disabled', 142 | message: '连接监控已禁用' 143 | }); 144 | } 145 | 146 | /** 147 | * 记录连接重试 148 | */ 149 | recordConnectionRetry() { 150 | const monitor = this.globalState.connectionMonitor; 151 | const newRetryCount = monitor.retryCount + 1; 152 | 153 | this.updateGlobalState({ 154 | connectionMonitor: { 155 | ...monitor, 156 | retryCount: newRetryCount, 157 | lastRetryTime: Date.now() 158 | } 159 | }); 160 | 161 | if (newRetryCount >= monitor.maxRetries) { 162 | logger.warn(`[StateManager] 连接重试次数已达到上限 (${monitor.maxRetries})`); 163 | this.disableConnectionMonitor(); 164 | 165 | this.notifyStateListeners({ 166 | type: 'connection-monitor-max-retries', 167 | message: `连接重试次数已达到上限 (${monitor.maxRetries}),停止重试` 168 | }); 169 | } 170 | } 171 | 172 | /** 173 | * 保存状态到存储 174 | * @param {Object} additionalState 额外的状态信息 175 | */ 176 | async saveState(additionalState = {}) { 177 | const state = { 178 | ...this.globalState, 179 | ...additionalState, 180 | lastRunTime: new Date().toISOString(), 181 | isDev: process.env.NODE_ENV === 'development' 182 | }; 183 | 184 | const store = require('../store'); 185 | await store.set('singbox.state', state); 186 | } 187 | 188 | /** 189 | * 从存储加载状态 190 | */ 191 | async loadState() { 192 | try { 193 | if (process.env.NODE_ENV === 'development') { 194 | logger.info('[StateManager] 开发模式下不加载状态'); 195 | return null; 196 | } 197 | 198 | const store = require('../store'); 199 | const state = await store.get('singbox.state'); 200 | 201 | if (state && state.isDev === true && process.env.NODE_ENV !== 'development') { 202 | logger.info('[StateManager] 从开发模式切换到生产模式,不加载之前的状态'); 203 | return null; 204 | } 205 | 206 | if (state) { 207 | this.globalState = { ...this.globalState, ...state }; 208 | } 209 | 210 | return state; 211 | } catch (error) { 212 | logger.error(`[StateManager] 加载状态失败: ${error.message}`); 213 | return null; 214 | } 215 | } 216 | } 217 | 218 | module.exports = StateManager; -------------------------------------------------------------------------------- /flatpak/com.lvory.app.yml: -------------------------------------------------------------------------------- 1 | id: com.lvory.app 2 | runtime: org.freedesktop.Platform 3 | runtime-version: '23.08' 4 | sdk: org.freedesktop.Sdk 5 | sdk-extensions: 6 | - org.freedesktop.Sdk.Extension.node18 7 | base: org.electronjs.Electron2.BaseApp 8 | base-version: '23.08' 9 | command: lvory 10 | separate-locales: false 11 | 12 | build-options: 13 | append-path: /usr/lib/sdk/node18/bin 14 | env: 15 | XDG_CACHE_HOME: /run/build/com.lvory.app/flatpak-node/cache 16 | npm_config_cache: /run/build/com.lvory.app/flatpak-node/npm-cache 17 | npm_config_nodedir: /usr/lib/sdk/node18 18 | npm_config_offline: 'true' 19 | NPM_CONFIG_LOGLEVEL: info 20 | 21 | finish-args: 22 | # IPC 和显示 23 | - --share=ipc 24 | - --socket=x11 25 | - --socket=fallback-x11 26 | - --device=dri 27 | 28 | # 音频支持 29 | - --socket=pulseaudio 30 | 31 | # 网络访问(代理功能必需) 32 | - --share=network 33 | 34 | # 文件系统访问 35 | - --filesystem=xdg-documents 36 | - --filesystem=xdg-download 37 | - --filesystem=xdg-config/lvory:create 38 | - --filesystem=home/.config/lvory:create 39 | 40 | # 系统代理设置权限 41 | - --talk-name=org.freedesktop.NetworkManager 42 | - --system-talk-name=org.freedesktop.NetworkManager 43 | 44 | # 环境变量 45 | - --env=ELECTRON_TRASH=gio 46 | - --env=ELECTRON_IS_DEV=0 47 | 48 | # 便携模式支持 49 | - --persist=.config/lvory 50 | - --persist=.local/share/lvory 51 | 52 | cleanup: 53 | - '/include' 54 | - '/lib/pkgconfig' 55 | - '/share/man' 56 | - '/share/doc' 57 | - '*.la' 58 | - '*.a' 59 | 60 | modules: 61 | # Node.js 依赖模块 62 | - name: lvory-dependencies 63 | buildsystem: simple 64 | build-options: 65 | env: 66 | XDG_CACHE_HOME: /run/build/lvory-dependencies/flatpak-node/cache 67 | npm_config_cache: /run/build/lvory-dependencies/flatpak-node/npm-cache 68 | npm_config_nodedir: /usr/lib/sdk/node18 69 | npm_config_offline: 'true' 70 | subdir: main 71 | sources: 72 | - type: archive 73 | url: https://github.com/sxueck/lvory/archive/v0.2.1.tar.gz 74 | sha256: PLACEHOLDER_SHA256_HASH 75 | dest: main 76 | - generated-sources.json 77 | build-commands: 78 | # 安装依赖 79 | - npm install --offline 80 | # 构建前端资源 81 | - npm run build:webpack 82 | # 使用 electron-builder 构建应用 83 | - | 84 | . ../flatpak-node/electron-builder-arch-args.sh 85 | npm run build:linux -- $ELECTRON_BUILDER_ARCH_ARGS --dir 86 | # 复制构建结果 87 | - cp -a dist/linux*unpacked /app/main 88 | # 创建必要的目录结构 89 | - mkdir -p /app/bin /app/cores /app/share/applications /app/share/icons/hicolor/256x256/apps /app/share/metainfo 90 | # 安装应用程序启动脚本 91 | - install -Dm755 ../lvory-wrapper.sh /app/bin/lvory 92 | # 安装桌面文件 93 | - install -Dm644 ../com.lvory.app.desktop /app/share/applications/com.lvory.app.desktop 94 | # 安装图标 95 | - install -Dm644 resource/icon/index256.png /app/share/icons/hicolor/256x256/apps/com.lvory.app.png 96 | # 安装元数据 97 | - install -Dm644 ../com.lvory.app.metainfo.xml /app/share/metainfo/com.lvory.app.metainfo.xml 98 | # 创建 sing-box 核心目录 99 | - mkdir -p /app/cores 100 | # 设置权限 101 | - chmod +x /app/main/lvory 102 | 103 | # sing-box 核心文件处理模块 104 | - name: sing-box-core 105 | buildsystem: simple 106 | build-commands: 107 | # 创建核心文件下载脚本 108 | - install -Dm755 ../download-singbox.sh /app/bin/download-singbox 109 | # 创建核心目录 110 | - mkdir -p /app/cores 111 | sources: 112 | - type: script 113 | dest-filename: download-singbox.sh 114 | commands: 115 | - | 116 | #!/bin/bash 117 | # sing-box 核心下载脚本 118 | # 这个脚本将在首次运行时下载 sing-box 核心 119 | CORES_DIR="/app/cores" 120 | SINGBOX_VERSION="1.8.0" 121 | ARCH=$(uname -m) 122 | 123 | case $ARCH in 124 | x86_64) ARCH_NAME="amd64" ;; 125 | aarch64) ARCH_NAME="arm64" ;; 126 | *) echo "不支持的架构: $ARCH"; exit 1 ;; 127 | esac 128 | 129 | DOWNLOAD_URL="https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${ARCH_NAME}.tar.gz" 130 | 131 | if [ ! -f "$CORES_DIR/sing-box" ]; then 132 | echo "正在下载 sing-box 核心..." 133 | cd /tmp 134 | curl -L "$DOWNLOAD_URL" -o sing-box.tar.gz 135 | tar -xzf sing-box.tar.gz 136 | cp sing-box-*/sing-box "$CORES_DIR/" 137 | chmod +x "$CORES_DIR/sing-box" 138 | rm -rf sing-box* /tmp/sing-box* 139 | echo "sing-box 核心下载完成" 140 | fi 141 | 142 | # 应用程序启动脚本 143 | - name: lvory-wrapper 144 | buildsystem: simple 145 | build-commands: 146 | - install -Dm755 lvory-wrapper.sh /app/bin/lvory-wrapper 147 | sources: 148 | - type: script 149 | dest-filename: lvory-wrapper.sh 150 | commands: 151 | - | 152 | #!/bin/bash 153 | # lvory Flatpak 启动包装脚本 154 | 155 | # 设置环境变量 156 | export ELECTRON_IS_DEV=0 157 | export ELECTRON_TRASH=gio 158 | 159 | # 确保核心目录存在 160 | mkdir -p "$HOME/.var/app/com.lvory.app/config/lvory" 161 | mkdir -p "$HOME/.var/app/com.lvory.app/data/lvory/cores" 162 | 163 | # 检查并下载 sing-box 核心 164 | if [ ! -f "$HOME/.var/app/com.lvory.app/data/lvory/cores/sing-box" ]; then 165 | /app/bin/download-singbox 166 | cp /app/cores/sing-box "$HOME/.var/app/com.lvory.app/data/lvory/cores/" 167 | fi 168 | 169 | # 启动应用程序 170 | exec zypak-wrapper /app/main/lvory "$@" 171 | -------------------------------------------------------------------------------- /src/components/Activity/LogHeader.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import MessageBox from '../MessageBox'; 4 | import '../../assets/css/activity-icons.css'; 5 | 6 | const LogHeader = ({ 7 | searchTerm, 8 | setSearchTerm, 9 | filter, 10 | setFilter, 11 | autoScroll, 12 | setAutoScroll, 13 | onClear, 14 | activeTab, 15 | setActiveTab, 16 | onRetry, 17 | isRetrying, 18 | shouldShowRetry = false 19 | }) => { 20 | const { t } = useTranslation(); 21 | const [showConnectionTip, setShowConnectionTip] = useState(false); 22 | 23 | const handleInfoClick = () => { 24 | setShowConnectionTip(true); 25 | }; 26 | 27 | return ( 28 |
29 |
30 | 36 | 42 | 48 |
49 |
50 |
51 | { 54 | if (activeTab === 'connections') { 55 | return t('activity.searchConnections'); 56 | } else if (activeTab === 'singbox') { 57 | return '搜索 SingBox 日志...'; 58 | } else { 59 | return t('activity.searchLogs'); 60 | } 61 | })()} 62 | value={searchTerm} 63 | onChange={(e) => setSearchTerm(e.target.value)} 64 | /> 65 | {activeTab !== 'singbox' && ( 66 | 84 | )} 85 |
86 |
87 |
setAutoScroll(!autoScroll)} 90 | title={activeTab === 'connections' ? t('activity.keepOldConnections') : t('activity.autoScrolling')} 91 | > 92 | {activeTab === 'connections' ? ( 93 |
94 | ) : ( 95 | // 自动滚动图标 96 | 97 | 98 | 99 | )} 100 |
101 | 102 | {activeTab === 'connections' && ( 103 |
108 |
109 |
110 | )} 111 | 112 | {shouldShowRetry && activeTab === 'connections' && ( 113 | 121 | )} 122 | {activeTab === 'logs' && ( 123 | 126 | )} 127 |
128 |
129 | 130 | {/* 连接监控提示弹窗 */} 131 | {showConnectionTip && ( 132 | setShowConnectionTip(false)} 135 | message={ 136 |
137 |

连接监控说明

138 |

连接监控功能说明:

139 |
    140 |
  • 只能捕获经过 sing-box 内核代理的网络连接
  • 141 |
  • 直连网络流量不会在此处显示
  • 142 |
  • 监控数据实时更新,显示当前活跃连接
  • 143 |
  • 可切换"保留历史"模式查看连接历史记录
  • 144 |
145 |

使用提示:

146 |
    147 |
  • 确保代理模式已启用且应用流量经过代理
  • 148 |
  • 刷新页面可重新启动连接监控
  • 149 |
  • 使用搜索和过滤功能快速定位特定连接
  • 150 |
151 |
152 | } 153 | /> 154 | )} 155 |
156 | ); 157 | }; 158 | 159 | export default LogHeader; -------------------------------------------------------------------------------- /src/utils/paths.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const crypto = require('crypto'); 6 | 7 | const APP_IS_PORTABLE = 'false'; 8 | function isPortableMode() { 9 | return APP_IS_PORTABLE === 'true'; 10 | } 11 | 12 | /** 13 | * 检测是否运行在 AppImage 环境中 14 | * @returns {Boolean} 是否为 AppImage 模式 15 | */ 16 | function isAppImageMode() { 17 | // 检查 APPIMAGE 环境变量(AppImage 运行时会设置此变量) 18 | if (process.env.APPIMAGE) { 19 | return true; 20 | } 21 | 22 | // 检查 APPDIR 环境变量(AppImage 挂载目录) 23 | if (process.env.APPDIR) { 24 | return true; 25 | } 26 | const execPath = process.execPath; 27 | if (execPath && ( 28 | execPath.includes('/.mount_') || // AppImage 挂载目录特征 29 | execPath.includes('/tmp/.mount_') || // 常见的 AppImage 临时挂载路径 30 | execPath.endsWith('.AppImage') // 直接运行 AppImage 文件 31 | )) { 32 | return true; 33 | } 34 | 35 | if (process.platform === 'linux') { 36 | const homeDir = require('os').homedir(); 37 | const currentDir = process.cwd(); 38 | 39 | if (currentDir === homeDir && execPath && execPath.includes('/tmp/')) { 40 | if (execPath.match(/\/tmp\/\.mount_[^\/]+\//) || 41 | execPath.match(/\/tmp\/appimage[^\/]*\//) || 42 | execPath.includes('squashfs-root')) { 43 | return true; 44 | } 45 | } 46 | } 47 | 48 | return false; 49 | } 50 | 51 | 52 | function getAppDataDir() { 53 | let appDir; 54 | 55 | if (isPortableMode()) { 56 | appDir = path.join(process.cwd(), 'data'); 57 | } else if (isAppImageMode()) { 58 | // AppImage 模式:所有文件存储到 XDG_CONFIG_HOME 或 ~/.config 59 | const homeDir = os.homedir(); 60 | const xdgConfigHome = process.env.XDG_CONFIG_HOME; 61 | if (xdgConfigHome) { 62 | appDir = path.join(xdgConfigHome, 'lvory'); 63 | } else { 64 | appDir = path.join(homeDir, '.config', 'lvory'); 65 | } 66 | } else { 67 | if (process.platform === 'win32') { 68 | const appDataDir = process.env.LOCALAPPDATA || ''; 69 | appDir = path.join(appDataDir, 'lvory'); 70 | } else if (process.platform === 'darwin') { 71 | const homeDir = os.homedir(); 72 | appDir = path.join(homeDir, 'Library', 'Application Support', 'lvory'); 73 | } else { 74 | const homeDir = os.homedir(); 75 | const xdgConfigHome = process.env.XDG_CONFIG_HOME; 76 | if (xdgConfigHome) { 77 | appDir = path.join(xdgConfigHome, 'lvory'); 78 | } else { 79 | appDir = path.join(homeDir, '.config', 'lvory'); 80 | } 81 | } 82 | } 83 | 84 | if (!fs.existsSync(appDir)) { 85 | try { 86 | fs.mkdirSync(appDir, { recursive: true }); 87 | } catch (error) { 88 | console.error(`创建应用数据目录失败: ${error.message}`); 89 | } 90 | } 91 | 92 | return appDir; 93 | } 94 | 95 | 96 | function getConfigDir() { 97 | const appDataDir = getAppDataDir(); 98 | const configDir = path.join(appDataDir, 'configs'); 99 | 100 | if (!fs.existsSync(configDir)) { 101 | try { 102 | fs.mkdirSync(configDir, { recursive: true }); 103 | } catch (error) { 104 | console.error(`创建配置目录失败: ${error.message}`); 105 | } 106 | } 107 | 108 | return configDir; 109 | } 110 | 111 | function getBinDir() { 112 | let binDir; 113 | 114 | if (isPortableMode()) { 115 | binDir = process.cwd(); 116 | } else if (isAppImageMode()) { 117 | // AppImage 模式:内核文件也存储到配置目录下的 bin 子目录 118 | const appDataDir = getAppDataDir(); 119 | binDir = path.join(appDataDir, 'bin'); 120 | 121 | if (!fs.existsSync(binDir)) { 122 | try { 123 | fs.mkdirSync(binDir, { recursive: true }); 124 | } catch (error) { 125 | console.error(`创建bin目录失败: ${error.message}`); 126 | } 127 | } 128 | } else { 129 | const appDataDir = getAppDataDir(); 130 | binDir = path.join(appDataDir, 'bin'); 131 | 132 | if (!fs.existsSync(binDir)) { 133 | try { 134 | fs.mkdirSync(binDir, { recursive: true }); 135 | } catch (error) { 136 | console.error(`创建bin目录失败: ${error.message}`); 137 | } 138 | } 139 | } 140 | 141 | return binDir; 142 | } 143 | 144 | function getUserSettingsPath() { 145 | const appDataDir = getAppDataDir(); 146 | return path.join(appDataDir, 'settings.json'); 147 | } 148 | 149 | function getStorePath() { 150 | const appDataDir = getAppDataDir(); 151 | return path.join(appDataDir, 'store.json'); 152 | } 153 | 154 | function getLogDir() { 155 | const appDataDir = getAppDataDir(); 156 | const logDir = path.join(appDataDir, 'logs'); 157 | 158 | if (!fs.existsSync(logDir)) { 159 | try { 160 | fs.mkdirSync(logDir, { recursive: true }); 161 | } catch (error) { 162 | console.error(`创建日志目录失败: ${error.message}`); 163 | } 164 | } 165 | 166 | return logDir; 167 | } 168 | 169 | function getTempLogDir() { 170 | return getLogDir(); 171 | } 172 | 173 | function generateDefaultLogPath() { 174 | const logDir = getLogDir(); 175 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 176 | const uuid = crypto.randomUUID().split('-')[0]; 177 | return path.join(logDir, `sing-box-${timestamp}-${uuid}.log`); 178 | } 179 | 180 | /** 181 | * 获取当前运行模式信息 182 | * @returns {Object} 运行模式信息 183 | */ 184 | function getRunModeInfo() { 185 | return { 186 | isPortable: isPortableMode(), 187 | isAppImage: isAppImageMode(), 188 | platform: process.platform, 189 | mode: isPortableMode() ? 'portable' : 190 | isAppImageMode() ? 'appimage' : 191 | 'standard' 192 | }; 193 | } 194 | 195 | module.exports = { 196 | getAppDataDir, 197 | getConfigDir, 198 | getBinDir, 199 | getUserSettingsPath, 200 | getStorePath, 201 | getLogDir, 202 | getTempLogDir, 203 | generateDefaultLogPath, 204 | isPortableMode, 205 | isAppImageMode, 206 | getRunModeInfo 207 | }; 208 | 209 | -------------------------------------------------------------------------------- /src/main/ipc-handlers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC事件处理模块入口 3 | * 统一管理Electron的IPC通信处理 4 | */ 5 | const { ipcMain } = require('electron'); 6 | const logger = require('../../utils/logger'); 7 | 8 | // 加载所有处理程序模块 9 | let profileHandlers; 10 | let singboxHandlers; 11 | let downloadHandlers; 12 | let settingsHandlers; 13 | let updateHandlers; 14 | let nodeHistoryHandlers; 15 | let tracerouteHandlers; 16 | let coreManagerHandlers; 17 | let trafficStatsHandlers; 18 | let subscriptionHandlers; 19 | let logCleanupHandlers; 20 | 21 | let ipcHandlersRegistered = false; 22 | 23 | // 所有需要移除的处理程序列表 24 | const HANDLERS_TO_REMOVE = [ 25 | // 配置相关 26 | 'get-config-path', 'set-config-path', 'open-config-dir', 27 | // 配置文件相关 28 | 'get-profile-data', 'getProfileFiles', 'deleteProfile', 29 | 'openFileInEditor', 'openConfigDir', 'getProfileMetadata', 30 | 'updateProfile', 'updateAllProfiles', 'loadLocalProfile', 31 | 'profiles-changed-listen', 'profiles-changed-unlisten', 32 | // Singbox相关 33 | 'singbox-start-core', 'singbox-stop-core', 'singbox-get-status', 34 | 'singbox-get-version', 'singbox-check-installed', 'singbox-check-config', 35 | 'singbox-format-config', 'singbox-download-core', 36 | // 下载相关 37 | 'download-profile', 38 | // 窗口相关 - 已迁移到新的IPC系统 39 | 'show-window', 'quit-app', 'window-minimize', 'window-maximize', 'window-close', 40 | // 日志相关 41 | 'get-log-history', 'clear-logs', 'get-connection-log-history', 'clear-connection-logs', 42 | 'start-connection-monitoring', 'stop-connection-monitoring', 43 | 'get-singbox-log-files', 'read-singbox-log-file', 'get-current-singbox-log', 44 | 'log-cleanup:perform', 'log-cleanup:set-retention-days', 'log-cleanup:get-retention-days', 'log-cleanup:get-stats', 45 | // 设置相关 46 | 'set-auto-launch', 'get-auto-launch', 'save-settings', 'get-settings', 47 | // 节点历史数据相关 48 | 'get-node-history', 'is-node-history-enabled', 'load-all-node-history', 49 | 'get-node-total-traffic', 'get-all-nodes-total-traffic', 'reset-node-total-traffic', 50 | // 用户配置相关 51 | 'get-user-config', 'save-user-config', 52 | // 规则集相关 53 | 'get-rule-sets', 'get-node-groups', 'get-route-rules', 54 | // 映射引擎相关 55 | 'get-mapping-definition', 'save-mapping-definition', 'apply-config-mapping', 56 | 'get-mapping-definition-path', 'get-default-mapping-definition', 57 | 'get-protocol-template', 'create-protocol-mapping', 58 | // 网络接口相关 59 | 'get-network-interfaces', 60 | // 应用版本相关 61 | 'get-app-version', 62 | 'get-build-date', 63 | 'get-is-portable', 64 | 'get-run-mode-info', 65 | // 版本更新相关 66 | 'check-for-updates', 67 | 'get-all-versions', 68 | // 外部链接相关 69 | 'open-external', 70 | // Traceroute 相关 71 | 'traceroute:execute', 72 | 'traceroute:validate' 73 | ]; 74 | 75 | /** 76 | * 加载指定模块 77 | * @param {String} name 模块名称 78 | * @returns {Object} 加载的模块 79 | */ 80 | function loadHandlerModule(name) { 81 | try { 82 | logger.info(`加载IPC处理程序模块: ${name}`); 83 | return require(`./${name}-handlers`); 84 | } catch (error) { 85 | logger.error(`加载IPC处理程序模块 ${name} 失败:`, error); 86 | return null; 87 | } 88 | } 89 | 90 | /** 91 | * 注册所有IPC处理程序 92 | */ 93 | function setupHandlers() { 94 | if (ipcHandlersRegistered) { 95 | logger.warn('IPC处理程序已注册,跳过'); 96 | return; 97 | } 98 | 99 | try { 100 | // 加载所有处理程序模块 101 | profileHandlers = loadHandlerModule('profile'); 102 | singboxHandlers = loadHandlerModule('singbox'); 103 | downloadHandlers = loadHandlerModule('download'); 104 | settingsHandlers = loadHandlerModule('settings'); 105 | updateHandlers = loadHandlerModule('update'); 106 | nodeHistoryHandlers = loadHandlerModule('node-history'); 107 | tracerouteHandlers = loadHandlerModule('traceroute'); 108 | coreManagerHandlers = loadHandlerModule('core-manager'); 109 | trafficStatsHandlers = loadHandlerModule('traffic-stats'); 110 | subscriptionHandlers = loadHandlerModule('subscription'); 111 | logCleanupHandlers = loadHandlerModule('log-cleanup'); 112 | 113 | // 导入工具模块 114 | const utils = require('./utils'); 115 | 116 | // 设置所有处理程序 117 | if (profileHandlers) profileHandlers.setup(); 118 | if (singboxHandlers) singboxHandlers.setup(); 119 | if (downloadHandlers) downloadHandlers.setup(); 120 | if (settingsHandlers) settingsHandlers.setup(); 121 | if (updateHandlers) updateHandlers.setup(); 122 | if (nodeHistoryHandlers) nodeHistoryHandlers.setup(); 123 | if (tracerouteHandlers) tracerouteHandlers.registerTracerouteHandlers(); 124 | if (coreManagerHandlers) coreManagerHandlers.setup(); 125 | if (trafficStatsHandlers) trafficStatsHandlers.setup(); 126 | if (subscriptionHandlers) subscriptionHandlers.setup(); 127 | if (logCleanupHandlers) logCleanupHandlers.setup(); 128 | 129 | // 设置网络接口处理程序 130 | utils.getNetworkInterfaces(); 131 | utils.getAppVersion(); 132 | utils.getBuildDate(); 133 | 134 | // 设置便携模式检测处理程序 135 | utils.getIsPortable(); 136 | 137 | // 设置运行模式信息处理程序 138 | utils.getAppRunModeInfo(); 139 | 140 | // 设置版本更新检查处理程序 141 | utils.checkForUpdates(); 142 | utils.getAllVersions(); 143 | utils.openExternal(); 144 | 145 | ipcHandlersRegistered = true; 146 | logger.info('所有IPC处理程序注册成功'); 147 | } catch (error) { 148 | logger.error('注册IPC处理程序失败:', error); 149 | } 150 | } 151 | 152 | /** 153 | * 清理所有IPC处理程序 154 | */ 155 | function cleanupHandlers() { 156 | if (!ipcHandlersRegistered) { 157 | return; 158 | } 159 | 160 | logger.info('正在清理IPC处理程序...'); 161 | 162 | HANDLERS_TO_REMOVE.forEach((channel) => { 163 | try { 164 | if (channel.endsWith('-listen') || channel.endsWith('-unlisten')) { 165 | ipcMain.removeAllListeners(channel); 166 | } else { 167 | ipcMain.removeHandler(channel); 168 | } 169 | } catch (error) { 170 | logger.warn(`移除处理程序 ${channel} 失败:`, error); 171 | } 172 | }); 173 | 174 | ipcHandlersRegistered = false; 175 | logger.info('IPC处理程序已清理'); 176 | } 177 | 178 | module.exports = { 179 | setupHandlers, 180 | cleanupHandlers 181 | }; -------------------------------------------------------------------------------- /src/components/Dashboard/SpeedTest.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | // 检查内核状态 4 | const checkSingboxStatus = async () => { 5 | if (!window.electron?.singbox?.getStatus) return true; 6 | 7 | try { 8 | const status = await window.electron.singbox.getStatus(); 9 | return status.isRunning; 10 | } catch (error) { 11 | console.error('获取内核状态失败:', error); 12 | return false; 13 | } 14 | }; 15 | 16 | // 测试单个节点 17 | const testNode = async (node, apiAddress) => { 18 | try { 19 | const nodeKey = node.tag || node.name || 'unknown'; 20 | console.log(`测试节点: ${nodeKey}`); 21 | 22 | const response = await fetch(`http://${apiAddress}/proxies/${encodeURIComponent(nodeKey)}/delay?timeout=5000&url=http://www.gstatic.com/generate_204`, { 23 | method: 'GET', 24 | headers: { 'Content-Type': 'application/json' } 25 | }); 26 | 27 | const data = await response.json(); 28 | 29 | if (!response.ok) { 30 | if (response.status === 408) return 'timeout'; 31 | if (typeof data?.delay === 'number') return data.delay; 32 | console.error(`HTTP错误: ${response.status}`); 33 | return 'timeout'; 34 | } 35 | 36 | return typeof data.delay === 'number' ? data.delay : 'timeout'; 37 | } catch (error) { 38 | console.error(`测试节点失败:`, error); 39 | return 'timeout'; 40 | } 41 | }; 42 | 43 | // 获取并发数量设置 44 | const getConcurrentCount = async () => { 45 | try { 46 | if (window.electron?.userConfig?.get) { 47 | const result = await window.electron.userConfig.get(); 48 | if (result?.success && result?.config?.settings) { 49 | const count = result.config.settings.concurrent_speed_test_count; 50 | // 确保数值在合理范围内,添加性能保护 51 | if (typeof count === 'number' && count >= 1 && count <= 10) { 52 | // 额外的性能保护:如果节点数量很少,限制并发数 53 | return count; 54 | } 55 | } 56 | } 57 | } catch (error) { 58 | console.error('读取并发数量设置失败,使用默认值:', error); 59 | } 60 | // 默认值 61 | return 5; 62 | }; 63 | 64 | const getOptimalConcurrency = async (nodeCount) => { 65 | const configuredCount = await getConcurrentCount(); 66 | 67 | if (nodeCount < configuredCount) { 68 | console.log(`节点数量(${nodeCount})少于配置的并发数(${configuredCount}),调整为${nodeCount}`); 69 | return nodeCount; 70 | } 71 | 72 | if (nodeCount > 50 && configuredCount > 8) { 73 | console.log(`节点数量较多(${nodeCount}),限制并发数为8以保护性能`); 74 | return 8; 75 | } 76 | 77 | return configuredCount; 78 | }; 79 | 80 | // 并发控制函数 - 限制最大并发数 81 | const runConcurrentTasks = async (tasks, maxConcurrency = 5) => { 82 | const results = []; 83 | const executing = []; 84 | 85 | for (const task of tasks) { 86 | const promise = task().then(result => { 87 | executing.splice(executing.indexOf(promise), 1); 88 | return result; 89 | }); 90 | 91 | results.push(promise); 92 | executing.push(promise); 93 | 94 | if (executing.length >= maxConcurrency) { 95 | await Promise.race(executing); 96 | } 97 | } 98 | 99 | return Promise.all(results); 100 | }; 101 | 102 | 103 | 104 | // 并发测试所有节点 105 | const testAllNodesConcurrent = async (profileData, apiAddress, onProgress, onLoadingChange) => { 106 | const results = {}; 107 | 108 | const maxConcurrency = await getOptimalConcurrency(profileData.length); 109 | console.log(`节点数量: ${profileData.length}, 使用并发数量: ${maxConcurrency}`); 110 | 111 | const loadingStates = {}; 112 | profileData.forEach(node => { 113 | const nodeKey = node.tag || node.name || 'unknown'; 114 | loadingStates[nodeKey] = true; 115 | }); 116 | onLoadingChange(loadingStates); 117 | 118 | const testTasks = profileData.map(node => { 119 | return async () => { 120 | const nodeKey = node.tag || node.name || 'unknown'; 121 | try { 122 | const delay = await testNode(node, apiAddress); 123 | results[nodeKey] = delay; 124 | 125 | if (onProgress) { 126 | onProgress({ [nodeKey]: delay }); 127 | } 128 | if (onLoadingChange) { 129 | onLoadingChange(prev => ({ ...prev, [nodeKey]: false })); 130 | } 131 | 132 | return { nodeKey, delay }; 133 | } catch (error) { 134 | console.error(`测试节点 ${nodeKey} 失败:`, error); 135 | results[nodeKey] = 'timeout'; 136 | 137 | if (onProgress) { 138 | onProgress({ [nodeKey]: 'timeout' }); 139 | } 140 | if (onLoadingChange) { 141 | onLoadingChange(prev => ({ ...prev, [nodeKey]: false })); 142 | } 143 | 144 | return { nodeKey, delay: 'timeout' }; 145 | } 146 | }; 147 | }); 148 | 149 | // 并发执行测试任务,使用动态获取的并发数量 150 | await runConcurrentTasks(testTasks, maxConcurrency); 151 | 152 | return results; 153 | }; 154 | 155 | 156 | 157 | const useSpeedTest = (profileData, apiAddress) => { 158 | const [isTesting, setIsTesting] = useState(false); 159 | const [testResults, setTestResults] = useState({}); 160 | const [loadingStates, setLoadingStates] = useState({}); 161 | 162 | const handleSpeedTest = async () => { 163 | if (!profileData?.length || isTesting) return; 164 | 165 | const isSingboxRunning = await checkSingboxStatus(); 166 | if (!isSingboxRunning) { 167 | console.log('内核未运行,不执行节点测速'); 168 | return; 169 | } 170 | 171 | setIsTesting(true); 172 | setTestResults({}); 173 | setLoadingStates({}); 174 | 175 | const onProgress = (partialResults) => { 176 | setTestResults(prev => ({ ...prev, ...partialResults })); 177 | }; 178 | 179 | const onLoadingChange = (loadingUpdate) => { 180 | if (typeof loadingUpdate === 'function') { 181 | setLoadingStates(loadingUpdate); 182 | } else { 183 | setLoadingStates(prev => ({ ...prev, ...loadingUpdate })); 184 | } 185 | }; 186 | 187 | try { 188 | const results = await testAllNodesConcurrent(profileData, apiAddress, onProgress, onLoadingChange); 189 | setTestResults(results); 190 | } catch (error) { 191 | console.error('并发测速失败:', error); 192 | // 清除所有加载状态 193 | setLoadingStates({}); 194 | } finally { 195 | setIsTesting(false); 196 | } 197 | }; 198 | 199 | return { 200 | isTesting, 201 | testResults, 202 | loadingStates, 203 | setTestResults, 204 | handleSpeedTest 205 | }; 206 | }; 207 | 208 | export default useSpeedTest; --------------------------------------------------------------------------------