From a20e0700596b8f692384535ac2e6cc5cbc43143b Mon Sep 17 00:00:00 2001 From: ZenEcho Date: Fri, 20 Feb 2026 16:50:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构游戏扫描模块,按平台拆分扫描逻辑(Steam、Microsoft Store、Epic/EA) - 新增系统代理模式支持,可在系统代理和TUN模式间切换 - 新增语言切换功能,支持中英文界面切换 - 改进主题系统,将主题配置统一管理至store - 新增系统托盘功能,支持窗口最小化到托盘 - 优化网络连接性能,为TCP ping添加noDelay选项 - 新增游戏分类匹配功能,扫描时自动匹配游戏分类 - 改进日志系统,自动捕获前端提示层日志 - 新增会话网络调优选项,支持MTU、UDP模式等高级配置 - 修复多处UI样式问题,统一颜色变量命名 - 更新项目文档,反映新的Electron架构设计 --- docs/electron-structure.md | 56 +- electron/main/bootstrap.ts | 183 ++++++ electron/main/index.ts | 301 +++------ electron/main/logger.ts | 60 +- electron/main/tray.ts | 59 ++ electron/main/window.ts | 92 +++ electron/preload/index.ts | 52 ++ electron/services/database.ts | 28 + electron/services/game-scanner.ts | 360 +---------- electron/services/node.ts | 19 + electron/services/process.ts | 3 + electron/services/proxy-monitor.ts | 3 + electron/services/scanners/flat.ts | 36 ++ electron/services/scanners/microsoft.ts | 143 +++++ electron/services/scanners/steam.ts | 71 +++ electron/services/scanners/types.ts | 53 ++ electron/services/scanners/utils.ts | 133 ++++ electron/services/singbox.ts | 597 ------------------ electron/services/singbox/config.ts | 165 +++++ electron/services/singbox/constants.ts | 8 + electron/services/singbox/index.ts | 240 +++++++ electron/services/singbox/installer.ts | 267 ++++++++ electron/services/singbox/utils.ts | 20 + electron/services/system.ts | 247 +++++++- electron/services/updater.ts | 20 + electron/utils/ping.ts | 3 +- src/App.vue | 2 +- src/api/categories.ts | 1 + src/api/system.ts | 39 ++ src/assets/main.css | 75 +-- src/autoImport/components.d.ts | 4 + src/components/common/LanguageToggle.vue | 41 ++ src/components/dashboard/LatencyChart.vue | 28 +- src/components/dashboard/NodeSelector.vue | 274 ++++---- src/components/node/NodeCard.vue | 10 +- src/components/node/NodeEditModal.vue | 2 +- src/components/node/NodeManager.vue | 310 +++++++-- .../node/SubscriptionManagerModal.vue | 184 ++++++ src/components/settings/AboutSettings.vue | 8 +- src/components/settings/GeneralSettings.vue | 7 +- src/components/settings/LogsSettings.vue | 36 +- src/components/settings/NetworkSettings.vue | 172 ++++- src/components/settings/SettingsSidebar.vue | 2 +- src/components/settings/settings.css | 0 src/composables/useAppUpdater.ts | 3 +- src/composables/useGameScanner.ts | 20 +- src/composables/useTheme.ts | 56 +- src/i18n.ts | 5 +- src/layouts/MainLayout.vue | 6 +- src/locales/en-US.json | 96 ++- src/locales/zh-CN.json | 81 ++- src/stores/games.ts | 171 ++++- src/stores/local-proxy.ts | 2 - src/stores/nodes.ts | 488 +++++++++++++- src/stores/settings.ts | 95 ++- src/types/electron.d.ts | 10 +- src/types/index.ts | 22 + src/utils/runtime-logger.ts | 68 ++ src/utils/singbox-config.test.ts | 102 +++ src/utils/singbox-config.ts | 107 +++- src/views/dashboard/index.vue | 313 +++++++-- src/views/library/index.vue | 103 ++- src/views/nodes/index.vue | 164 ++++- src/views/settings/index.vue | 11 - tests/unit/singbox-config.spec.ts | 43 ++ uno.config.ts | 1 + 66 files changed, 4668 insertions(+), 1713 deletions(-) create mode 100644 electron/main/bootstrap.ts create mode 100644 electron/main/tray.ts create mode 100644 electron/main/window.ts create mode 100644 electron/services/scanners/flat.ts create mode 100644 electron/services/scanners/microsoft.ts create mode 100644 electron/services/scanners/steam.ts create mode 100644 electron/services/scanners/types.ts create mode 100644 electron/services/scanners/utils.ts delete mode 100644 electron/services/singbox.ts create mode 100644 electron/services/singbox/config.ts create mode 100644 electron/services/singbox/constants.ts create mode 100644 electron/services/singbox/index.ts create mode 100644 electron/services/singbox/installer.ts create mode 100644 electron/services/singbox/utils.ts create mode 100644 src/components/common/LanguageToggle.vue create mode 100644 src/components/node/SubscriptionManagerModal.vue delete mode 100644 src/components/settings/settings.css create mode 100644 src/utils/singbox-config.test.ts diff --git a/docs/electron-structure.md b/docs/electron-structure.md index 08ea0ca..19e3324 100644 --- a/docs/electron-structure.md +++ b/docs/electron-structure.md @@ -13,21 +13,35 @@ electron/ │ └── schema.ts # SQLite 数据库表结构定义 (Kysely Schema) │ ├── main/ # [入口层] 主进程核心引导 -│ ├── index.ts # **应用入口**:负责 App 生命周期、窗口创建、IPC 注册、服务组装 -│ └── logger.ts # 日志系统:负责日志文件写入、轮转及向渲染进程广播日志 +│ ├── bootstrap.ts # **启动引导**:环境检查、管理员权限校验、图标加载 +│ ├── index.ts # **应用入口**:负责 App 生命周期、服务组装 +│ ├── logger.ts # 日志系统:负责日志文件写入、轮转及向渲染进程广播日志 +│ ├── tray.ts # 托盘管理:系统托盘图标与菜单逻辑 +│ └── window.ts # 窗口管理:主窗口创建、配置与生命周期控制 │ ├── preload/ # [桥接层] 预加载脚本 │ └── index.ts # 暴露给渲染进程的 API 定义 (ContextBridge) │ ├── services/ # [业务层] 核心业务逻辑服务 (Service) +│ ├── scanners/ # [扫描器] 游戏扫描具体实现 +│ │ ├── flat.ts # 通用目录扫描 (Epic/EA) +│ │ ├── microsoft.ts # Xbox/Microsoft Store 游戏扫描 (Registry/Manifest) +│ │ ├── steam.ts # Steam 库扫描 (libraryfolders.vdf) +│ │ ├── types.ts # 扫描结果类型定义 +│ │ └── utils.ts # 扫描工具函数 +│ ├── singbox/ # [核心] sing-box 内核管理 +│ │ ├── config.ts # 配置管理:校验配置、更新路由规则 +│ │ ├── constants.ts # 常量定义 +│ │ ├── index.ts # **SingBoxService**:服务入口,协调各模块 +│ │ ├── installer.ts # 安装器:负责内核下载与安装 +│ │ └── utils.ts # 工具函数 │ ├── category.ts # 游戏分类服务:管理侧边栏分类数据 │ ├── database.ts # 数据库服务:封装 SQLite 连接与基础 CRUD 操作 -│ ├── game-scanner.ts # 游戏扫描服务:自动识别 Steam/Epic/Xbox/EA 本地已安装游戏 +│ ├── game-scanner.ts # 游戏扫描服务:统一入口,聚合 scanners 目录下的扫描逻辑 │ ├── game.ts # 游戏管理服务:管理“我的游戏”列表、配置与状态 │ ├── node.ts # 节点管理服务:管理代理节点/订阅信息 │ ├── process.ts # 进程管理服务:提供进程树查找、父子进程分析能力 │ ├── proxy-monitor.ts # 监控服务:实时监控游戏进程启动,自动触发加速路由 -│ ├── singbox.ts # **核心加速服务**:管理 sing-box 内核进程、生成配置、处理路由规则 │ ├── system.ts # 系统服务:提供 DNS 刷新、虚拟网卡重置、网络测试等底层能力 │ └── updater.ts # 更新服务:检查 GitHub Releases 新版本 │ @@ -43,20 +57,30 @@ electron/ ## 模块详细说明 ### 1. 入口层 (`main/`) -- **`index.ts`**: - - 是整个 Electron 应用的起点。 - - 负责初始化所有 Service 实例(依赖注入)。 - - 处理窗口管理(最小化、最大化、关闭)。 - - 处理系统托盘 (Tray) 逻辑。 -- **`logger.ts`**: - - 实现了双向日志系统:既记录后端日志,也接收前端日志。 - - 包含日志文件自动清理策略(限制文件大小和总占用空间)。 +经过重构,入口层职责更加单一: +- **`index.ts`**: 核心入口,负责组装各个模块,初始化服务。 +- **`bootstrap.ts`**: 处理应用启动前的环境准备,如强制管理员权限、处理 SQLite 原生模块错误。 +- **`window.ts`**: 封装了 `BrowserWindow` 的创建与配置,管理窗口 IPC 事件(最小化/关闭)。 +- **`tray.ts`**: 独立管理系统托盘逻辑。 ### 2. 业务层 (`services/`) -这是代码最集中的地方,每个文件对应一个具体的业务领域: -- **`singbox.ts`**: 最核心的文件。负责下载 sing-box 内核、生成 `config.json`、启动/停止内核、动态更新路由规则。 -- **`proxy-monitor.ts`**: 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox.ts` 添加分流规则。 -- **`game-scanner.ts`**: 包含复杂的跨平台(目前主要是 Windows)扫描逻辑,读取注册表、Steam 库文件 (`libraryfolders.vdf`) 等信息来定位游戏。 +这是代码最集中的地方,每个文件对应一个具体的业务领域。 + +#### 核心服务:`singbox/` +将原有的 `singbox.ts` 拆分为独立模块: +- **`index.ts`**: 服务外观 (Facade),对外提供 `start`, `stop`, `restart` 接口。 +- **`installer.ts`**: 专注于二进制文件的下载、解压和安装逻辑。 +- **`config.ts`**: 专注于配置文件的生成、校验和动态规则更新。 + +#### 扫描服务:`game-scanner.ts` & `scanners/` +- **`game-scanner.ts`**: 作为统一入口,对外暴露 `scanLocalGamesFromPlatforms`。 +- **`scanners/`**: 包含各平台的具体扫描实现: + - **`steam.ts`**: 解析 Steam 库文件。 + - **`microsoft.ts`**: 结合注册表和 AppxManifest 解析 Xbox 游戏。 + - **`flat.ts`**: 简单的目录遍历扫描 (Epic/EA)。 + +#### 监控服务:`proxy-monitor.ts` +- 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox` 服务添加分流规则。 ### 3. 工具层 (`utils/`) 提炼了纯函数工具,不依赖具体业务逻辑,易于测试和复用。 diff --git a/electron/main/bootstrap.ts b/electron/main/bootstrap.ts new file mode 100644 index 0000000..894c4e4 --- /dev/null +++ b/electron/main/bootstrap.ts @@ -0,0 +1,183 @@ +import { app, dialog, nativeImage, NativeImage } from 'electron' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import fs from 'fs-extra' +import pkg from '../../package.json' + +const ELEVATION_FLAG = '--lagzero-elevated' + +/** + * 格式化 PowerShell 参数字符串 + * @param input 输入字符串 + * @returns 转义后的字符串 + */ +function psQuote(input: string): string { + return `'${String(input || '').replace(/'/g, "''")}'` +} + +/** + * 检查当前进程是否拥有 Windows 管理员权限 + * @returns 是否为管理员 + */ +function isWindowsAdmin(): boolean { + if (process.platform !== 'win32') return true + const script = [ + '$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())', + '$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)', + 'Write-Output $isAdmin' + ].join('; ') + const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { + windowsHide: true, + encoding: 'utf8' + }) + if (result.status !== 0) return false + return String(result.stdout || '').trim().toLowerCase() === 'true' +} + +/** + * 尝试以管理员权限重新启动应用 + * @returns 是否成功触发重启 + */ +function relaunchAsAdmin(): boolean { + if (process.platform !== 'win32') return false + const args = process.argv.slice(1).filter((a) => a !== ELEVATION_FLAG) + args.push(ELEVATION_FLAG) + const argsLiteral = args.map(psQuote).join(', ') + const script = [ + `$exe = ${psQuote(process.execPath)}`, + `$args = @(${argsLiteral})`, + 'Start-Process -FilePath $exe -ArgumentList $args -Verb RunAs' + ].join('; ') + + const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { + windowsHide: true, + encoding: 'utf8' + }) + return result.status === 0 +} + +/** + * 确保应用在启动时拥有管理员权限 (仅 Windows) + * 如果当前没有权限,会尝试申请提权并重启 + */ +export function ensureAdminAtStartup() { + if (process.platform !== 'win32') return + if (isWindowsAdmin()) return + + const relaunched = relaunchAsAdmin() + if (relaunched) { + app.exit(0) + process.exit(0) + } + + console.warn('[Main] Failed to relaunch as administrator. Continuing without elevation.') +} + +/** + * 加载应用图标 + * 依次尝试加载 logo.png, logo.ico, logo.svg + * @returns NativeImage 对象或 null + */ +export function loadAppIcon(): NativeImage | null { + const baseDir = process.env.VITE_PUBLIC || '' + const candidates = [ + path.join(baseDir, 'logo.png'), + path.join(baseDir, 'logo.ico'), + path.join(baseDir, 'logo.svg'), + ] + + const tryFromPath = (p: string): NativeImage | null => { + if (!p || !fs.existsSync(p)) return null + const img = nativeImage.createFromPath(p) + if (!img.isEmpty()) return img + return null + } + + for (const p of candidates) { + const image = tryFromPath(p) + if (image) return image + } + + const svgPath = path.join(baseDir, 'logo.svg') + if (!fs.existsSync(svgPath)) { + console.warn('[Main] Icon file not found:', svgPath) + return null + } + + const svg = fs.readFileSync(svgPath, 'utf-8') + const toImage = (content: string) => { + const svgBase64 = Buffer.from(content, 'utf-8').toString('base64') + return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${svgBase64}`) + } + + const sanitized = svg + .replace(//gi, '') + .replace(/\sfilter="url\(#.*?\)"/gi, '') + + const image = toImage(sanitized) + if (!image.isEmpty()) return image + + const fallbackSvg = ` + + + +` + const fallbackImage = toImage(fallbackSvg) + if (!fallbackImage.isEmpty()) { + console.warn('[Main] logo.svg decode failed, using built-in fallback icon') + return fallbackImage + } + + return null +} + +/** + * 格式化启动错误信息 + * 特别处理 better-sqlite3 常见的架构/版本不匹配错误,提供修复建议 + * @param error 错误对象 + * @returns 格式化后的错误信息 + */ +export function formatStartupError(error: unknown) { + const raw = String((error as any)?.stack || (error as any)?.message || error || 'Unknown error') + const lower = raw.toLowerCase() + + if (lower.includes('better_sqlite3.node') && lower.includes('not a valid win32 application')) { + return [ + 'Failed to load better-sqlite3 native module due to architecture mismatch.', + '', + `Current runtime arch: ${process.arch}`, + 'Fix command: pnpm rebuild better-sqlite3', + '', + 'Details:', + raw + ].join('\n') + } + + if (lower.includes('better_sqlite3.node') && lower.includes('compiled against a different node.js version')) { + return [ + 'Failed to load better-sqlite3 due to native ABI mismatch.', + '', + `Current runtime: electron ${process.versions.electron}, modules ${process.versions.modules}, arch ${process.arch}`, + 'Fix command: pnpm run rebuild:native', + 'Fallback command: pnpm run rebuild:sqlite', + '', + 'Details:', + raw + ].join('\n') + } + + return raw +} + +/** + * 处理应用启动失败 + * 弹出错误框并退出应用 + * @param error 错误对象 + */ +export function handleStartupError(error: unknown) { + const message = formatStartupError(error) + console.error('[Main] App startup failed:', message) + dialog.showErrorBox('LagZero startup failed', message) + app.quit() +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 095e5ec..5de7b63 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage, NativeImage, shell } from 'electron' +import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron' import { fileURLToPath, pathToFileURL } from 'node:url' import path from 'node:path' import fs from 'fs-extra' @@ -15,6 +15,9 @@ import { GameScannerService } from '../services/game-scanner' import { UpdaterService } from '../services/updater' import { SystemService } from '../services/system' import { setupLogger } from './logger' +import { ensureAdminAtStartup, loadAppIcon, handleStartupError } from './bootstrap' +import { WindowManager, VITE_DEV_SERVER_URL, MAIN_DIST, RENDERER_DIST } from './window' +import { TrayManager } from './tray' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __filename = fileURLToPath(import.meta.url) @@ -28,10 +31,6 @@ global.__dirname = __dirname process.env.APP_ROOT = path.join(__dirname, '../') const APP_ID = 'com.' + pkg.name + '.client' -export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') -export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') - process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST if (VITE_DEV_SERVER_URL) { @@ -41,8 +40,11 @@ if (process.platform === 'win32') { app.setAppUserModelId(APP_ID) } -let win: BrowserWindow | null = null -let tray: Tray | null = null +// 确保以管理员权限启动 +ensureAdminAtStartup() + +const windowManager = new WindowManager() +const trayManager = new TrayManager(null as any) // 稍后更新 win 引用 // Services let dbService: DatabaseService | null = null @@ -55,123 +57,17 @@ let nodeService: NodeService | null = null let gameScannerService: GameScannerService | null = null let updaterService: UpdaterService | null = null let systemService: SystemService | null = null +let quitCleanupStarted = false -// Setup Logger -setupLogger(() => win) +// 初始化日志系统 +setupLogger(() => windowManager.get()) -function loadAppIcon(): NativeImage | null { - const baseDir = process.env.VITE_PUBLIC || '' - const candidates = [ - path.join(baseDir, 'logo.png'), - path.join(baseDir, 'logo.ico'), - path.join(baseDir, 'logo.svg'), - ] - - const tryFromPath = (p: string): NativeImage | null => { - if (!p || !fs.existsSync(p)) return null - const img = nativeImage.createFromPath(p) - if (!img.isEmpty()) return img - return null - } - - for (const p of candidates) { - const image = tryFromPath(p) - if (image) return image - } - - const svgPath = path.join(baseDir, 'logo.svg') - if (!fs.existsSync(svgPath)) { - console.warn('[Main] Icon file not found:', svgPath) - return null - } - - const svg = fs.readFileSync(svgPath, 'utf-8') - const toImage = (content: string) => { - const svgBase64 = Buffer.from(content, 'utf-8').toString('base64') - return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${svgBase64}`) - } - - const sanitized = svg - .replace(//gi, '') - .replace(/\sfilter="url\(#.*?\)"/gi, '') - - const image = toImage(sanitized) - if (!image.isEmpty()) return image - - const fallbackSvg = ` - - - -` - const fallbackImage = toImage(fallbackSvg) - if (!fallbackImage.isEmpty()) { - console.warn('[Main] logo.svg decode failed, using built-in fallback icon') - return fallbackImage - } - - return null -} - -function createTray(icon: NativeImage | null) { - if (tray || !icon) return - - const trayIcon = icon.resize({ width: 20, height: 20, quality: 'best' }) - tray = new Tray(trayIcon) - tray.setToolTip(pkg.productName) - tray.setContextMenu(Menu.buildFromTemplate([ - { - label: 'Show Window', - click: () => { - if (!win) return - win.show() - win.focus() - } - }, - { - label: 'Quit', - click: () => app.quit() - } - ])) - - tray.on('double-click', () => { - if (!win) return - if (win.isVisible()) { - win.focus() - } else { - win.show() - win.focus() - } - }) -} - -function createWindow() { - const appIcon = loadAppIcon() - const preloadCandidates = [ - path.join(__dirname, '../preload/index.mjs'), - path.join(__dirname, 'preload.mjs'), - path.join(__dirname, 'index.mjs'), - ] - const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0] - - win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 860, - minHeight: 620, - frame: false, - webPreferences: { - // Compatible with both nested and flat dist-electron outputs. - preload: preloadPath, - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - }, - backgroundColor: '#1e1e1e', - ...(appIcon ? { icon: appIcon } : {}), - }) - - // Initialize Services +/** + * 初始化所有业务服务并注册 IPC 监听 + * @param win 主窗口实例 + */ +function initServices(win: BrowserWindow) { + // 实例化服务 dbService = new DatabaseService() singboxService = new SingBoxService(win) processService = new ProcessService() @@ -183,11 +79,11 @@ function createWindow() { updaterService = new UpdaterService() systemService = new SystemService() - // Register generic DB IPCs + // 注册通用数据库 IPC ipcMain.handle('db:export', () => dbService?.exportData()) ipcMain.handle('db:import', (_, data) => dbService?.importData(data)) - // Register Scanner IPCs + // 注册扫描服务 IPC ipcMain.handle('system:scan-local-games', async () => { try { return await gameScannerService?.scanLocalGamesFromPlatforms() || [] @@ -197,6 +93,7 @@ function createWindow() { } }) ipcMain.handle('dialog:pick-process-folder', async (_, maxDepth: number = 1) => { + const win = windowManager.get() if (!win) return null const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] @@ -206,122 +103,106 @@ function createWindow() { return gameScannerService?.scanDir(dir, maxDepth) }) - // Register Updater IPCs + // 注册更新服务 IPC ipcMain.handle('app:get-version', () => app.getVersion()) ipcMain.handle('app:check-update', () => updaterService?.checkUpdate()) ipcMain.handle('app:open-url', (_, url: string) => { shell.openExternal(url) }) - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL) - win.webContents.openDevTools() - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html')) - } + // 注册系统对话框 IPC + ipcMain.handle('dialog:pick-image', async () => { + const win = windowManager.get() + if (!win) return null + const result = await dialog.showOpenDialog(win, { + properties: ['openFile'], + filters: [ + { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'svg'] } + ] + }) + if (result.canceled || result.filePaths.length === 0) return null + return pathToFileURL(result.filePaths[0]!).toString() + }) - createTray(appIcon) + ipcMain.handle('dialog:pick-process', async () => { + const win = windowManager.get() + if (!win) return null + const result = await dialog.showOpenDialog(win, { + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Executables', extensions: ['exe'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + if (result.canceled || result.filePaths.length === 0) return null + return result.filePaths.map(p => path.basename(p)) + }) } +/** + * 启动应用程序 + * 创建窗口、托盘图标并初始化服务 + */ +function startApp() { + const appIcon = loadAppIcon() + const win = windowManager.create(appIcon) + + // 重新创建托盘以绑定正确的窗口引用 + trayManager.destroy() + // @ts-ignore: 我们知道这是在创建一个新的 + const newTrayManager = new TrayManager(win) + newTrayManager.create(appIcon) + // 保持 trayManager 引用,防止被 GC + Object.assign(trayManager, newTrayManager) + + initServices(win) +} + +// 监听所有窗口关闭事件 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) +// 监听应用激活事件 app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } else if (win) { - win.show() - win.focus() + startApp() + } else { + windowManager.show() } }) -function formatStartupError(error: unknown) { - const raw = String((error as any)?.stack || (error as any)?.message || error || 'Unknown error') - const lower = raw.toLowerCase() - - if (lower.includes('better_sqlite3.node') && lower.includes('not a valid win32 application')) { - return [ - 'Failed to load better-sqlite3 native module due to architecture mismatch.', - '', - `Current runtime arch: ${process.arch}`, - 'Fix command: pnpm rebuild better-sqlite3', - '', - 'Details:', - raw - ].join('\n') - } - - if (lower.includes('better_sqlite3.node') && lower.includes('compiled against a different node.js version')) { - return [ - 'Failed to load better-sqlite3 due to native ABI mismatch.', - '', - `Current runtime: electron ${process.versions.electron}, modules ${process.versions.modules}, arch ${process.arch}`, - 'Fix command: pnpm run rebuild:native', - 'Fallback command: pnpm run rebuild:sqlite', - '', - 'Details:', - raw - ].join('\n') - } - - return raw -} - +// 应用准备就绪 app.whenReady() .then(() => { - createWindow() + startApp() }) .catch((error) => { - const message = formatStartupError(error) - console.error('[Main] App startup failed:', message) - dialog.showErrorBox('LagZero startup failed', message) - app.quit() + handleStartupError(error) }) +// 全局未捕获异常处理 process.on('unhandledRejection', (reason) => { - const message = formatStartupError(reason) + const message = String(reason) console.error('[Main] Unhandled rejection:', message) }) -app.on('before-quit', () => { - tray?.destroy() - tray = null -}) +// 应用退出前清理 +app.on('before-quit', (event) => { + trayManager.destroy() -ipcMain.handle('window-minimize', () => win?.minimize()) -ipcMain.handle('window-maximize', () => { - if (win && win.isMaximized()) { - win.unmaximize() - } else if (win) { - win.maximize() - } -}) -ipcMain.handle('window-close', () => win?.close()) + if (quitCleanupStarted) return + quitCleanupStarted = true + event.preventDefault() -ipcMain.handle('dialog:pick-image', async () => { - if (!win) return null - const result = await dialog.showOpenDialog(win, { - properties: ['openFile'], - filters: [ - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'svg'] } - ] - }) - if (result.canceled || result.filePaths.length === 0) return null - return pathToFileURL(result.filePaths[0]!).toString() -}) - -ipcMain.handle('dialog:pick-process', async () => { - if (!win) return null - const result = await dialog.showOpenDialog(win, { - properties: ['openFile', 'multiSelections'], - filters: [ - { name: 'Executables', extensions: ['exe'] }, - { name: 'All Files', extensions: ['*'] } - ] - }) - if (result.canceled || result.filePaths.length === 0) return null - return result.filePaths.map(p => path.basename(p)) + // 尝试清理系统代理设置 + Promise.resolve(systemService?.clearSystemProxy()) + .catch((e) => { + console.warn('[Main] Failed to clear system proxy on app quit:', e) + }) + .finally(() => { + app.exit(0) + }) }) diff --git a/electron/main/logger.ts b/electron/main/logger.ts index 8b80aed..c543913 100644 --- a/electron/main/logger.ts +++ b/electron/main/logger.ts @@ -75,29 +75,49 @@ function queueLogWrite(task: () => Promise) { }) } -function sanitizeLogText(text?: string) { - if (!text) return '' - return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n') -} +/** + * 净化日志文本 + * + * 移除换行符等特殊字符,防止日志格式错乱。 + */ + function sanitizeLogText(text?: string) { + if (!text) return '' + return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n') + } -function formatLogLine(row: AppLogEntry) { - const iso = new Date(row.timestamp).toISOString() - const base = `[${iso}] [${row.level.toUpperCase()}] [${row.category}] [${row.source}] ${sanitizeLogText(row.message)}` - if (!row.detail) return `${base}\n` - return `${base}\n${sanitizeLogText(row.detail)}\n` -} + /** + * 格式化单行日志 + * + * 格式: [ISO时间] [LEVEL] [CATEGORY] [SOURCE] MESSAGE + */ + function formatLogLine(row: AppLogEntry) { + const iso = new Date(row.timestamp).toISOString() + const base = `[${iso}] [${row.level.toUpperCase()}] [${row.category}] [${row.source}] ${sanitizeLogText(row.message)}` + if (!row.detail) return `${base}\n` + return `${base}\n${sanitizeLogText(row.detail)}\n` + } -async function ensureSingleFileLimit(logFilePath: string, incomingBytes: number) { - const exists = await fs.pathExists(logFilePath) - if (!exists) return - const stat = await fs.stat(logFilePath) - const nextSize = stat.size + incomingBytes - if (nextSize <= MAX_LOG_FILE_BYTES) return - await fs.truncate(logFilePath, 0) -} + /** + * 确保单个日志文件大小不超过限制 + * + * 如果超过限制 (MAX_LOG_FILE_BYTES),则清空文件内容(简单轮转策略)。 + */ + async function ensureSingleFileLimit(logFilePath: string, incomingBytes: number) { + const exists = await fs.pathExists(logFilePath) + if (!exists) return + const stat = await fs.stat(logFilePath) + const nextSize = stat.size + incomingBytes + if (nextSize <= MAX_LOG_FILE_BYTES) return + await fs.truncate(logFilePath, 0) + } -async function pruneLogDirIfNeeded(logDir: string) { - const exists = await fs.pathExists(logDir) + /** + * 清理过期的日志文件 + * + * 如果日志目录总大小超过限制 (MAX_LOG_DIR_BYTES),则删除最旧的文件。 + */ + async function pruneLogDirIfNeeded(logDir: string) { + const exists = await fs.pathExists(logDir) if (!exists) return const dirents = await fs.readdir(logDir, { withFileTypes: true }) diff --git a/electron/main/tray.ts b/electron/main/tray.ts new file mode 100644 index 0000000..b3f420b --- /dev/null +++ b/electron/main/tray.ts @@ -0,0 +1,59 @@ +import { Tray, Menu, NativeImage, app, BrowserWindow } from 'electron' +import pkg from '../../package.json' + +/** + * 系统托盘管理器 + * 负责创建和管理系统托盘图标及菜单 + */ +export class TrayManager { + private tray: Tray | null = null + private win: BrowserWindow | null = null + + constructor(win: BrowserWindow) { + this.win = win + } + + /** + * 创建系统托盘 + * @param icon 应用图标 + */ + create(icon: NativeImage | null) { + if (this.tray || !icon) return + + const trayIcon = icon.resize({ width: 20, height: 20, quality: 'best' }) + this.tray = new Tray(trayIcon) + this.tray.setToolTip(pkg.productName) + this.tray.setContextMenu(Menu.buildFromTemplate([ + { + label: 'Show Window', + click: () => { + if (!this.win) return + this.win.show() + this.win.focus() + } + }, + { + label: 'Quit', + click: () => app.quit() + } + ])) + + this.tray.on('double-click', () => { + if (!this.win) return + if (this.win.isVisible()) { + this.win.focus() + } else { + this.win.show() + this.win.focus() + } + }) + } + + /** + * 销毁系统托盘 + */ + destroy() { + this.tray?.destroy() + this.tray = null + } +} diff --git a/electron/main/window.ts b/electron/main/window.ts new file mode 100644 index 0000000..3acca68 --- /dev/null +++ b/electron/main/window.ts @@ -0,0 +1,92 @@ +import { BrowserWindow, NativeImage, ipcMain } from 'electron' +import path from 'path' +import fs from 'fs-extra' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const APP_ROOT = process.env.APP_ROOT || path.join(__dirname, '../') +export const MAIN_DIST = path.join(APP_ROOT, 'dist-electron') +export const RENDERER_DIST = path.join(APP_ROOT, 'dist') +export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] + +/** + * 窗口管理器 + * 负责主窗口的创建、配置和生命周期管理 + */ +export class WindowManager { + private win: BrowserWindow | null = null + + constructor() { + this.setupIPC() + } + + /** + * 创建主窗口 + * @param icon 窗口图标 + * @returns BrowserWindow 实例 + */ + create(icon: NativeImage | null): BrowserWindow { + const preloadCandidates = [ + path.join(__dirname, 'index.mjs'), + path.join(__dirname, '../preload/index.mjs'), + path.join(__dirname, 'preload.mjs'), + ] + const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0] + console.info('[Main] preload selected:', preloadPath) + + this.win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 960, + minHeight: 720, + frame: false, + webPreferences: { + preload: preloadPath, + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + }, + backgroundColor: '#1e1e1e', + ...(icon ? { icon } : {}), + }) + + if (VITE_DEV_SERVER_URL) { + this.win.loadURL(VITE_DEV_SERVER_URL) + this.win.webContents.openDevTools() + } else { + this.win.loadFile(path.join(RENDERER_DIST, 'index.html')) + } + + return this.win + } + + /** + * 获取当前窗口实例 + */ + get() { + return this.win + } + + /** + * 显示并聚焦窗口 + */ + show() { + this.win?.show() + this.win?.focus() + } + + /** + * 注册窗口控制相关的 IPC 事件 + */ + private setupIPC() { + ipcMain.handle('window-minimize', () => this.win?.minimize()) + ipcMain.handle('window-maximize', () => { + if (this.win && this.win.isMaximized()) { + this.win.unmaximize() + } else if (this.win) { + this.win.maximize() + } + }) + ipcMain.handle('window-close', () => this.win?.close()) + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 3f1ddf6..dcb6da4 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,19 +3,36 @@ * * 使用 ContextBridge 将主进程 API 安全地暴露给渲染进程。 * 定义了 electron, singbox, system, proxyMonitor, nodes, games, categories, app, logs 等全局对象。 + * + * 注意: + * 1. 所有暴露的 API 必须通过 ipcRenderer.invoke 或 ipcRenderer.on/send 通信 + * 2. 避免直接暴露 ipcRenderer 对象,防止安全风险 */ import { contextBridge, ipcRenderer } from 'electron' const ipcListenerMap = new Map void, (...args: any[]) => void>>() const appLogListenerMap = new Map<(entry: any) => void, (...args: any[]) => void>() +// 基础 Electron 功能 contextBridge.exposeInMainWorld('electron', { + /** 最小化窗口 */ minimize: () => ipcRenderer.invoke('window-minimize'), + /** 最大化/还原窗口 */ maximize: () => ipcRenderer.invoke('window-maximize'), + /** 关闭窗口 */ close: () => ipcRenderer.invoke('window-close'), + /** 选择图片文件 */ pickImage: () => ipcRenderer.invoke('dialog:pick-image'), + /** 选择可执行文件 (.exe) */ pickProcess: () => ipcRenderer.invoke('dialog:pick-process'), + /** 选择并扫描文件夹中的可执行文件 */ pickProcessFolder: (maxDepth?: number) => ipcRenderer.invoke('dialog:pick-process-folder', maxDepth), + + /** + * 注册 IPC 事件监听器 + * @param channel 频道名称 + * @param callback 回调函数 + */ on: (channel: string, callback: (...args: any[]) => void) => { const wrapped = (_event: any, ...args: any[]) => callback(...args) const channelMap = ipcListenerMap.get(channel) || new Map() @@ -23,6 +40,12 @@ contextBridge.exposeInMainWorld('electron', { ipcListenerMap.set(channel, channelMap) ipcRenderer.on(channel, wrapped) }, + + /** + * 移除 IPC 事件监听器 + * @param channel 频道名称 + * @param callback 原回调函数引用 + */ off: (channel: string, callback: (...args: any[]) => void) => { const channelMap = ipcListenerMap.get(channel) const wrapped = channelMap?.get(callback) @@ -32,30 +55,54 @@ contextBridge.exposeInMainWorld('electron', { } }) +// Sing-box 内核控制 contextBridge.exposeInMainWorld('singbox', { + /** 启动内核 */ start: (config: string) => ipcRenderer.invoke('singbox-start', config), + /** 停止内核 */ stop: () => ipcRenderer.invoke('singbox-stop'), + /** 重启内核 */ restart: (config: string) => ipcRenderer.invoke('singbox-restart', config), }) +// 系统底层功能 contextBridge.exposeInMainWorld('system', { + /** 扫描系统进程 */ scanProcesses: () => ipcRenderer.invoke('process-scan'), + /** 扫描本地已安装的游戏 (Steam/Xbox/Epic/EA) */ scanLocalGames: () => ipcRenderer.invoke('system:scan-local-games'), + /** 获取进程树 */ getProcessTree: () => ipcRenderer.invoke('process-tree'), + /** ICMP Ping 测试 */ ping: (host: string) => ipcRenderer.invoke('system:ping', host), + /** TCP Ping 测试 */ tcpPing: (host: string, port: number) => ipcRenderer.invoke('system:tcp-ping', host, port), + /** 刷新 DNS 缓存 */ flushDnsCache: () => ipcRenderer.invoke('system:flush-dns-cache'), + /** 重置 TUN 虚拟网卡 */ reinstallTunAdapter: (interfaceName?: string) => ipcRenderer.invoke('system:tun-reinstall', interfaceName), + /** 查找可用端口 */ findAvailablePort: (port: number, count?: number) => ipcRenderer.invoke('system:find-available-port', port, count), + /** 测试 HTTP 代理连通性 */ testHttpProxyConnect: (proxyPort: number, targetHost: string, targetPort?: number, timeoutMs?: number) => ipcRenderer.invoke('system:test-http-proxy-connect', proxyPort, targetHost, targetPort, timeoutMs), + /** 设置系统代理 */ + setSystemProxy: (port: number, bypass?: string) => ipcRenderer.invoke('system:set-system-proxy', port, bypass), + /** 清除系统代理 */ + clearSystemProxy: (snapshot?: any) => ipcRenderer.invoke('system:clear-system-proxy', snapshot), + /** 获取当前系统代理状态 */ + getSystemProxyState: () => ipcRenderer.invoke('system:get-system-proxy-state'), }) +// 进程代理监控 contextBridge.exposeInMainWorld('proxyMonitor', { + /** 开始监控指定游戏进程启动 */ start: (gameId: string, processNames: string[]) => ipcRenderer.invoke('proxy-monitor:start', gameId, processNames), + /** 停止监控 */ stop: () => ipcRenderer.invoke('proxy-monitor:stop'), }) +// 节点管理 contextBridge.exposeInMainWorld('nodes', { getAll: () => ipcRenderer.invoke('nodes:get-all'), save: (node: any) => ipcRenderer.invoke('nodes:save', node), @@ -63,24 +110,29 @@ contextBridge.exposeInMainWorld('nodes', { import: (nodes: any[]) => ipcRenderer.invoke('nodes:import', nodes), }) +// 游戏管理 contextBridge.exposeInMainWorld('games', { getAll: () => ipcRenderer.invoke('games:get-all'), save: (game: any) => ipcRenderer.invoke('games:save', game), delete: (id: string) => ipcRenderer.invoke('games:delete', id), }) +// 分类管理 contextBridge.exposeInMainWorld('categories', { getAll: () => ipcRenderer.invoke('categories:get-all'), save: (category: any) => ipcRenderer.invoke('categories:save', category), delete: (id: string) => ipcRenderer.invoke('categories:delete', id), + match: (name: string, processes: string | string[]) => ipcRenderer.invoke('categories:match', name, processes), }) +// 应用元数据与更新 contextBridge.exposeInMainWorld('app', { getVersion: () => ipcRenderer.invoke('app:get-version'), checkUpdate: () => ipcRenderer.invoke('app:check-update'), openUrl: (url: string) => ipcRenderer.invoke('app:open-url', url), }) +// 日志系统 contextBridge.exposeInMainWorld('logs', { getAll: () => ipcRenderer.invoke('logs:get-all'), clear: () => ipcRenderer.invoke('logs:clear'), diff --git a/electron/services/database.ts b/electron/services/database.ts index d8cf1e3..98ff4f6 100644 --- a/electron/services/database.ts +++ b/electron/services/database.ts @@ -428,11 +428,19 @@ export class DatabaseService { return this.getAllGames() } + /** + * 删除游戏 + * @param id - 游戏 ID + */ async deleteGame(id: string) { await this.db.deleteFrom('games').where('id', '=', id).execute() return this.getAllGames() } + /** + * 获取所有分类 + * @returns Promise + */ async getAllCategories() { const rows = await this.db.selectFrom('categories').selectAll().orderBy('order_index').execute() return rows.map(row => ({ @@ -445,6 +453,10 @@ export class DatabaseService { })) } + /** + * 保存分类(新增或更新) + * @param category - 分类对象 + */ async saveCategory(category: any) { const data = { id: category.id || generateId(), @@ -464,11 +476,19 @@ export class DatabaseService { return this.getAllCategories() } + /** + * 删除分类 + * @param id - 分类 ID + */ async deleteCategory(id: string) { await this.db.deleteFrom('categories').where('id', '=', id).execute() return this.getAllCategories() } + /** + * 获取所有配置文件 + * @returns Promise + */ async getAllProfiles() { const rows = await this.db.selectFrom('profiles').selectAll().execute() return rows.map(row => ({ @@ -480,6 +500,10 @@ export class DatabaseService { })) } + /** + * 保存配置文件(新增或更新) + * @param profile - 配置文件对象 + */ async saveProfile(profile: any) { const data = { id: profile.id || generateId(), @@ -498,6 +522,10 @@ export class DatabaseService { return this.getAllProfiles() } + /** + * 删除配置文件 + * @param id - 配置文件 ID + */ async deleteProfile(id: string) { await this.db.deleteFrom('profiles').where('id', '=', id).execute() return this.getAllProfiles() diff --git a/electron/services/game-scanner.ts b/electron/services/game-scanner.ts index c1279f1..e67ccb0 100644 --- a/electron/services/game-scanner.ts +++ b/electron/services/game-scanner.ts @@ -1,348 +1,22 @@ import fs from 'fs-extra' import path from 'path' -import { runCommand } from '../utils/command' +import { LocalGameScanResult } from './scanners/types' +import { getWindowsDriveRoots } from './scanners/utils' +import { scanSteamGames } from './scanners/steam' +import { scanMicrosoftGames } from './scanners/microsoft' +import { scanFlatPlatformFolder } from './scanners/flat' /** - * 本地游戏扫描结果接口 + * 游戏扫描服务 + * + * 负责扫描本地已安装的游戏,目前支持 Windows 平台。 + * 聚合了 Steam, Microsoft Store (Xbox), Epic Games, EA App 等多个平台的扫描逻辑。 */ -export type LocalGameScanResult = { - /** 游戏显示名称 */ - name: string - /** 游戏主进程名 (如 dota2.exe) */ - processName: string - /** 来源平台 */ - source: 'steam' | 'microsoft' | 'epic' | 'ea' - /** 安装目录绝对路径 */ - installDir: string -} - -const GAME_SCAN_IGNORE_DIR_NAMES = new Set([ - '_commonredist', - 'redist', - 'redistributable', - 'installer', - 'installers', - 'directx', - 'vcredist', - 'prereq', - 'prerequisites', - 'support', - 'tools', - 'launcher' -]) - -const GAME_SCAN_IGNORE_EXE_KEYWORDS = [ - 'setup', - 'unins', - 'uninstall', - 'installer', - 'crashreport', - 'updater', - 'helper', - 'bootstrap' -] - -const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([ - 'unitycrashhandler64.exe', - 'unitycrashhandler32.exe' -]) - -function normalizeDisplayName(name: string) { - return name - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() -} - -function normalizeFsPath(p: string) { - return path.normalize(p).replace(/[\\\/]+$/, '').toLowerCase() -} - -async function safeReadDir(dir: string) { - try { - return await fs.readdir(dir, { withFileTypes: true }) - } catch { - return [] - } -} - -async function getWindowsDriveRoots(): Promise { - if (process.platform !== 'win32') return [] - const roots: string[] = [] - for (let i = 67; i <= 90; i += 1) { - const letter = String.fromCharCode(i) - const root = `${letter}:\\` - if (await fs.pathExists(root)) roots.push(root) - } - return roots -} - -function basenameLower(p: string) { - return path.basename(p).toLowerCase() -} - -function shouldSkipExeByName(exeName: string) { - const lower = exeName.toLowerCase() - if (!lower.endsWith('.exe')) return true - if (GAME_SCAN_EXE_HARD_EXCLUDE.has(lower)) return true - return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k)) -} - -async function collectExePaths(dir: string, maxDepth: number, currentDepth: number = 1): Promise { - const results: string[] = [] - const dirents = await safeReadDir(dir) - - for (const dirent of dirents) { - const full = path.join(dir, dirent.name) - if (dirent.isDirectory()) { - const folderName = dirent.name.toLowerCase() - if (GAME_SCAN_IGNORE_DIR_NAMES.has(folderName)) continue - if (maxDepth === -1 || currentDepth < maxDepth) { - const sub = await collectExePaths(full, maxDepth, currentDepth + 1) - results.push(...sub) - } - continue - } - - if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue - if (shouldSkipExeByName(dirent.name)) continue - results.push(full) - } - - return results -} - -async function pickBestExecutable(gameDir: string, displayName: string): Promise { - const exes = await collectExePaths(gameDir, 3) - if (exes.length === 0) return null - if (exes.length === 1) return exes[0] || null - - const target = normalizeDisplayName(displayName).toLowerCase() - const ranked = await Promise.all(exes.map(async (exe) => { - let score = 0 - const base = basenameLower(exe).replace(/\.exe$/, '') - const full = exe.toLowerCase() - - if (base === target) score += 10 - if (base.includes(target)) score += 6 - if (target.includes(base)) score += 4 - if (!full.includes('launcher')) score += 2 - - try { - const stat = await fs.stat(exe) - score += Math.min(8, Math.floor((stat.size || 0) / (40 * 1024 * 1024))) - } catch { - // ignore stat errors and keep heuristic score - } - - return { exe, score } - })) - - ranked.sort((a, b) => b.score - a.score) - return ranked[0]?.exe || null -} - -async function readSteamLibraryRoots(): Promise { - const drives = await getWindowsDriveRoots() - const candidateSteamRoots = Array.from(new Set( - drives.flatMap(root => [ - path.join(root, 'Program Files (x86)', 'Steam'), - path.join(root, 'Program Files', 'Steam'), - path.join(root, 'Steam') - ]) - )) - const libs = new Set() - - for (const steamRoot of candidateSteamRoots) { - const libraryVdf = path.join(steamRoot, 'steamapps', 'libraryfolders.vdf') - if (!await fs.pathExists(libraryVdf)) continue - - libs.add(path.normalize(steamRoot)) - try { - const content = await fs.readFile(libraryVdf, 'utf8') - const lines = content.split(/\r?\n/) - for (const line of lines) { - const m = line.match(/"path"\s+"([^"]+)"/i) - if (!m?.[1]) continue - const p = m[1].replace(/\\\\/g, '\\').trim() - if (p) libs.add(path.normalize(p)) - } - } catch (e) { - console.warn('[GameScan] Failed to parse Steam libraryfolders.vdf:', e) - } - } - - return Array.from(libs) -} - -async function scanSteamGames(): Promise { - const roots = await readSteamLibraryRoots() - const results: LocalGameScanResult[] = [] - - for (const libRoot of roots) { - const commonDir = path.join(libRoot, 'steamapps', 'common') - if (!await fs.pathExists(commonDir)) continue - - const games = await safeReadDir(commonDir) - for (const entry of games) { - if (!entry.isDirectory()) continue - const installDir = path.join(commonDir, entry.name) - const exe = await pickBestExecutable(installDir, entry.name) - if (!exe) continue - results.push({ - name: normalizeDisplayName(entry.name), - processName: path.basename(exe), - source: 'steam', - installDir - }) - } - } - - return results -} - -async function scanFlatPlatformFolder(source: 'epic' | 'ea' | 'microsoft', roots: string[]): Promise { - const results: LocalGameScanResult[] = [] - - for (const root of roots) { - if (!await fs.pathExists(root)) continue - const entries = await safeReadDir(root) - for (const entry of entries) { - if (!entry.isDirectory()) continue - const installDir = path.join(root, entry.name) - const exe = await pickBestExecutable(installDir, entry.name) - if (!exe) continue - results.push({ - name: normalizeDisplayName(entry.name), - processName: path.basename(exe), - source, - installDir - }) - } - } - - return results -} - -function pickBestNameFromHints(installDir: string, hints: Map) { - const dirKey = normalizeFsPath(installDir) - let bestName = '' - let bestLen = -1 - - for (const [hintPath, hintName] of hints.entries()) { - if (!hintName) continue - if (dirKey === hintPath || dirKey.startsWith(`${hintPath}\\`) || hintPath.startsWith(`${dirKey}\\`)) { - if (hintPath.length > bestLen) { - bestLen = hintPath.length - bestName = hintName - } - } - } - return bestName -} - -function parsePowershellJson(raw: string): T[] { - const text = raw.trim() - if (!text) return [] - try { - const parsed = JSON.parse(text) - if (Array.isArray(parsed)) return parsed as T[] - if (parsed && typeof parsed === 'object') return [parsed as T] - return [] - } catch { - return [] - } -} - -async function getRegistryDisplayNameHints(): Promise> { - const map = new Map() - if (process.platform !== 'win32') return map - - const script = [ - "$roots = @(", - " 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", - " 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", - " 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'", - ")", - "$items = foreach ($r in $roots) {", - " Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |", - " Where-Object { $_.DisplayName -and $_.InstallLocation } |", - " Select-Object DisplayName, InstallLocation", - "}", - "$items | ConvertTo-Json -Compress" - ].join('; ') - - const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 15000) - if (code !== 0 || !output) return map - - const rows = parsePowershellJson<{ DisplayName?: string, InstallLocation?: string }>(output) - for (const row of rows) { - const name = String(row.DisplayName || '').trim() - const location = String(row.InstallLocation || '').trim() - if (!name || !location) continue - map.set(normalizeFsPath(location), name) - } - - return map -} - -function parseDisplayNameFromManifest(content: string): string { - const displayName = content.match(/([^<]+)<\/DisplayName>/i)?.[1]?.trim() || '' - const identityName = content.match(/]*\sName="([^"]+)"/i)?.[1]?.trim() || '' - if (displayName && !/^ms-resource:/i.test(displayName)) return displayName - return identityName -} - -async function getManifestDisplayNameHints(xboxRoots: string[]): Promise> { - const map = new Map() - - for (const root of xboxRoots) { - if (!await fs.pathExists(root)) continue - const entries = await safeReadDir(root) - for (const entry of entries) { - if (!entry.isDirectory()) continue - const gameDir = path.join(root, entry.name) - const manifestCandidates = [ - path.join(gameDir, 'AppxManifest.xml'), - path.join(gameDir, 'Content', 'AppxManifest.xml') - ] - - for (const manifestPath of manifestCandidates) { - if (!await fs.pathExists(manifestPath)) continue - try { - const content = await fs.readFile(manifestPath, 'utf8') - const parsedName = parseDisplayNameFromManifest(content) - if (!parsedName) continue - map.set(normalizeFsPath(gameDir), normalizeDisplayName(parsedName)) - break - } catch { - // ignore manifest read errors - } - } - } - } - - return map -} - -async function scanMicrosoftGames(): Promise { - const drives = await getWindowsDriveRoots() - const roots = drives.map(root => path.join(root, 'XboxGames')) - const base = await scanFlatPlatformFolder('microsoft', roots) - - const [registryHints, manifestHints] = await Promise.all([ - getRegistryDisplayNameHints(), - getManifestDisplayNameHints(roots) - ]) - const hints = new Map([...registryHints, ...manifestHints]) - - return base.map(game => { - const hintName = pickBestNameFromHints(game.installDir, hints) - return hintName ? { ...game, name: hintName } : game - }) -} - export class GameScannerService { + /** + * 扫描所有支持平台的游戏 + * @returns 扫描到的游戏列表 (去重后) + */ async scanLocalGamesFromPlatforms(): Promise { if (process.platform !== 'win32') return [] const drives = await getWindowsDriveRoots() @@ -369,6 +43,7 @@ export class GameScannerService { ))) ]) + // 去重逻辑:名称和进程名都相同的视为同一个游戏 const dedup = new Map() for (const game of [...steam, ...microsoft, ...epic, ...ea]) { const key = `${game.name.toLowerCase()}|${game.processName.toLowerCase()}` @@ -378,6 +53,13 @@ export class GameScannerService { return Array.from(dedup.values()) } + /** + * 扫描指定目录下的可执行文件 + * @param dir 目标目录 + * @param maxDepth 最大递归深度 (-1 为无限) + * @param currentDepth 当前深度 (内部使用) + * @returns 找到的 .exe 文件名列表 + */ async scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise { let results: string[] = [] try { diff --git a/electron/services/node.ts b/electron/services/node.ts index 061123f..7a97dca 100644 --- a/electron/services/node.ts +++ b/electron/services/node.ts @@ -59,22 +59,41 @@ export class NodeService { this.registerIPC() } + /** + * 获取所有节点 + * @returns Promise + */ public getAll() { return this.db.getAllNodes() } + /** + * 保存节点配置 + * @param node - 节点配置对象 + */ public save(node: Partial) { return this.db.saveNode(node) } + /** + * 删除节点 + * @param id - 节点 ID + */ public delete(id: string) { return this.db.deleteNode(id) } + /** + * 批量导入节点 + * @param newNodes - 节点列表 + */ public importNodes(newNodes: Partial[]) { return this.db.importNodes(newNodes) } + /** + * 注册 IPC 监听器 + */ private registerIPC() { ipcMain.handle('nodes:get-all', () => this.getAll()) ipcMain.handle('nodes:save', (_, node: any) => this.save(node)) diff --git a/electron/services/process.ts b/electron/services/process.ts index d8a937d..5b1856d 100644 --- a/electron/services/process.ts +++ b/electron/services/process.ts @@ -26,6 +26,9 @@ export class ProcessService { this.setupIPC() } + /** + * 注册 IPC 监听器 + */ private setupIPC() { ipcMain.handle('process-scan', async () => { return await this.scanProcesses() diff --git a/electron/services/proxy-monitor.ts b/electron/services/proxy-monitor.ts index bf8ab07..871d24d 100644 --- a/electron/services/proxy-monitor.ts +++ b/electron/services/proxy-monitor.ts @@ -28,6 +28,9 @@ export class ProxyMonitorService { this.setupIPC(); } + /** + * 注册 IPC 监听器 + */ private setupIPC() { ipcMain.handle('proxy-monitor:start', (_, gameId: string, processNames: string[]) => { this.startMonitoring(gameId, processNames); diff --git a/electron/services/scanners/flat.ts b/electron/services/scanners/flat.ts new file mode 100644 index 0000000..5ae6190 --- /dev/null +++ b/electron/services/scanners/flat.ts @@ -0,0 +1,36 @@ +import fs from 'fs-extra' +import path from 'path' +import { LocalGameScanResult } from './types' +import { normalizeDisplayName, pickBestExecutable, safeReadDir } from './utils' + +/** + * 扫描指定目录下的通用平台游戏 (Epic, EA, Microsoft Store) + * + * 这种扫描方式主要依赖于目录结构和可执行文件的启发式匹配。 + * + * @param source 游戏平台标识 + * @param roots 需要扫描的根目录列表 + * @returns 扫描到的游戏列表 + */ +export async function scanFlatPlatformFolder(source: 'epic' | 'ea' | 'microsoft', roots: string[]): Promise { + const results: LocalGameScanResult[] = [] + + for (const root of roots) { + if (!await fs.pathExists(root)) continue + const entries = await safeReadDir(root) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const installDir = path.join(root, entry.name) + const exe = await pickBestExecutable(installDir, entry.name) + if (!exe) continue + results.push({ + name: normalizeDisplayName(entry.name), + processName: path.basename(exe), + source, + installDir + }) + } + } + + return results +} diff --git a/electron/services/scanners/microsoft.ts b/electron/services/scanners/microsoft.ts new file mode 100644 index 0000000..5cd5418 --- /dev/null +++ b/electron/services/scanners/microsoft.ts @@ -0,0 +1,143 @@ +import fs from 'fs-extra' +import path from 'path' +import { LocalGameScanResult } from './types' +import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, safeReadDir } from './utils' +import { runCommand } from '../../utils/command' +import { scanFlatPlatformFolder } from './flat' + +/** + * 根据注册表或 Manifest 提供的提示信息,优化游戏名称 + */ +function pickBestNameFromHints(installDir: string, hints: Map) { + const dirKey = normalizeFsPath(installDir) + let bestName = '' + let bestLen = -1 + + for (const [hintPath, hintName] of hints.entries()) { + if (!hintName) continue + if (dirKey === hintPath || dirKey.startsWith(`${hintPath}\\`) || hintPath.startsWith(`${dirKey}\\`)) { + if (hintPath.length > bestLen) { + bestLen = hintPath.length + bestName = hintName + } + } + } + return bestName +} + +/** + * 解析 PowerShell 返回的 JSON 字符串 + */ +function parsePowershellJson(raw: string): T[] { + const text = raw.trim() + if (!text) return [] + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) return parsed as T[] + if (parsed && typeof parsed === 'object') return [parsed as T] + return [] + } catch { + return [] + } +} + +/** + * 从 Windows 注册表中获取已安装软件的名称和安装路径映射 + */ +async function getRegistryDisplayNameHints(): Promise> { + const map = new Map() + if (process.platform !== 'win32') return map + + const script = [ + "$roots = @(", + " 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", + " 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", + " 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'", + ")", + "$items = foreach ($r in $roots) {", + " Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |", + " Where-Object { $_.DisplayName -and $_.InstallLocation } |", + " Select-Object DisplayName, InstallLocation", + "}", + "$items | ConvertTo-Json -Compress" + ].join('; ') + + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 15000) + if (code !== 0 || !output) return map + + const rows = parsePowershellJson<{ DisplayName?: string, InstallLocation?: string }>(output) + for (const row of rows) { + const name = String(row.DisplayName || '').trim() + const location = String(row.InstallLocation || '').trim() + if (!name || !location) continue + map.set(normalizeFsPath(location), name) + } + + return map +} + +/** + * 从 AppxManifest.xml 内容中提取显示名称 + */ +function parseDisplayNameFromManifest(content: string): string { + const displayName = content.match(/([^<]+)<\/DisplayName>/i)?.[1]?.trim() || '' + const identityName = content.match(/]*\sName="([^"]+)"/i)?.[1]?.trim() || '' + if (displayName && !/^ms-resource:/i.test(displayName)) return displayName + return identityName +} + +/** + * 从 XboxGames 目录下的 AppxManifest.xml 解析游戏名称 + */ +async function getManifestDisplayNameHints(xboxRoots: string[]): Promise> { + const map = new Map() + + for (const root of xboxRoots) { + if (!await fs.pathExists(root)) continue + const entries = await safeReadDir(root) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const gameDir = path.join(root, entry.name) + const manifestCandidates = [ + path.join(gameDir, 'AppxManifest.xml'), + path.join(gameDir, 'Content', 'AppxManifest.xml') + ] + + for (const manifestPath of manifestCandidates) { + if (!await fs.pathExists(manifestPath)) continue + try { + const content = await fs.readFile(manifestPath, 'utf8') + const parsedName = parseDisplayNameFromManifest(content) + if (!parsedName) continue + map.set(normalizeFsPath(gameDir), normalizeDisplayName(parsedName)) + break + } catch { + // ignore manifest read errors + } + } + } + } + + return map +} + +/** + * 扫描 Microsoft Store / Xbox 游戏 + * 结合目录扫描、注册表和 AppxManifest 信息来获取准确的游戏名称 + */ +export async function scanMicrosoftGames(): Promise { + const drives = await getWindowsDriveRoots() + const roots = drives.map(root => path.join(root, 'XboxGames')) + const base = await scanFlatPlatformFolder('microsoft', roots) + + const [registryHints, manifestHints] = await Promise.all([ + getRegistryDisplayNameHints(), + getManifestDisplayNameHints(roots) + ]) + const hints = new Map([...registryHints, ...manifestHints]) + + return base.map(game => { + const hintName = pickBestNameFromHints(game.installDir, hints) + return hintName ? { ...game, name: hintName } : game + }) +} diff --git a/electron/services/scanners/steam.ts b/electron/services/scanners/steam.ts new file mode 100644 index 0000000..299d8ad --- /dev/null +++ b/electron/services/scanners/steam.ts @@ -0,0 +1,71 @@ +import fs from 'fs-extra' +import path from 'path' +import { LocalGameScanResult } from './types' +import { getWindowsDriveRoots, normalizeDisplayName, pickBestExecutable, safeReadDir } from './utils' + +/** + * 读取 Steam 库文件夹路径 + * 解析 libraryfolders.vdf 文件获取所有库位置 + */ +async function readSteamLibraryRoots(): Promise { + const drives = await getWindowsDriveRoots() + const candidateSteamRoots = Array.from(new Set( + drives.flatMap(root => [ + path.join(root, 'Program Files (x86)', 'Steam'), + path.join(root, 'Program Files', 'Steam'), + path.join(root, 'Steam') + ]) + )) + const libs = new Set() + + for (const steamRoot of candidateSteamRoots) { + const libraryVdf = path.join(steamRoot, 'steamapps', 'libraryfolders.vdf') + if (!await fs.pathExists(libraryVdf)) continue + + libs.add(path.normalize(steamRoot)) + try { + const content = await fs.readFile(libraryVdf, 'utf8') + const lines = content.split(/\r?\n/) + for (const line of lines) { + const m = line.match(/"path"\s+"([^"]+)"/i) + if (!m?.[1]) continue + const p = m[1].replace(/\\\\/g, '\\').trim() + if (p) libs.add(path.normalize(p)) + } + } catch (e) { + console.warn('[GameScan] Failed to parse Steam libraryfolders.vdf:', e) + } + } + + return Array.from(libs) +} + +/** + * 扫描 Steam 游戏 + * 遍历所有 Steam 库目录下的 common 文件夹 + */ +export async function scanSteamGames(): Promise { + const roots = await readSteamLibraryRoots() + const results: LocalGameScanResult[] = [] + + for (const libRoot of roots) { + const commonDir = path.join(libRoot, 'steamapps', 'common') + if (!await fs.pathExists(commonDir)) continue + + const games = await safeReadDir(commonDir) + for (const entry of games) { + if (!entry.isDirectory()) continue + const installDir = path.join(commonDir, entry.name) + const exe = await pickBestExecutable(installDir, entry.name) + if (!exe) continue + results.push({ + name: normalizeDisplayName(entry.name), + processName: path.basename(exe), + source: 'steam', + installDir + }) + } + } + + return results +} diff --git a/electron/services/scanners/types.ts b/electron/services/scanners/types.ts new file mode 100644 index 0000000..a15780b --- /dev/null +++ b/electron/services/scanners/types.ts @@ -0,0 +1,53 @@ +/** + * 本地游戏扫描结果接口 + */ +export type LocalGameScanResult = { + /** 游戏显示名称 */ + name: string + /** 游戏主进程名 (如 dota2.exe) */ + processName: string + /** 来源平台 */ + source: 'steam' | 'microsoft' | 'epic' | 'ea' + /** 安装目录绝对路径 */ + installDir: string +} + +/** + * 扫描时需要忽略的常见非游戏目录名 + */ +export const GAME_SCAN_IGNORE_DIR_NAMES = new Set([ + '_commonredist', + 'redist', + 'redistributable', + 'installer', + 'installers', + 'directx', + 'vcredist', + 'prereq', + 'prerequisites', + 'support', + 'tools', + 'launcher' +]) + +/** + * 扫描时需要忽略的常见非游戏主进程的可执行文件关键词 + */ +export const GAME_SCAN_IGNORE_EXE_KEYWORDS = [ + 'setup', + 'unins', + 'uninstall', + 'installer', + 'crashreport', + 'updater', + 'helper', + 'bootstrap' +] + +/** + * 必须排除的特定可执行文件名 + */ +export const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([ + 'unitycrashhandler64.exe', + 'unitycrashhandler32.exe' +]) diff --git a/electron/services/scanners/utils.ts b/electron/services/scanners/utils.ts new file mode 100644 index 0000000..eef3192 --- /dev/null +++ b/electron/services/scanners/utils.ts @@ -0,0 +1,133 @@ +import fs from 'fs-extra' +import path from 'path' +import { GAME_SCAN_IGNORE_DIR_NAMES, GAME_SCAN_IGNORE_EXE_KEYWORDS, GAME_SCAN_EXE_HARD_EXCLUDE } from './types' + +/** + * 标准化游戏显示名称 + * 去除下划线、多余空格等 + */ +export function normalizeDisplayName(name: string) { + return name + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * 标准化文件系统路径 + * 统一分隔符、转小写、去除尾部斜杠 + */ +export function normalizeFsPath(p: string) { + return path.normalize(p).replace(/[\\\/]+$/, '').toLowerCase() +} + +/** + * 安全地读取目录内容 + * 遇到错误时返回空数组而不是抛出异常 + */ +export async function safeReadDir(dir: string) { + try { + return await fs.readdir(dir, { withFileTypes: true }) + } catch { + return [] + } +} + +/** + * 获取 Windows 系统下的所有驱动器根目录 (C:\, D:\, ...) + */ +export async function getWindowsDriveRoots(): Promise { + if (process.platform !== 'win32') return [] + const roots: string[] = [] + for (let i = 67; i <= 90; i += 1) { + const letter = String.fromCharCode(i) + const root = `${letter}:\\` + if (await fs.pathExists(root)) roots.push(root) + } + return roots +} + +/** + * 获取路径的基本名称(小写) + */ +export function basenameLower(p: string) { + return path.basename(p).toLowerCase() +} + +/** + * 判断是否应跳过该可执行文件 + */ +export function shouldSkipExeByName(exeName: string) { + const lower = exeName.toLowerCase() + if (!lower.endsWith('.exe')) return true + if (GAME_SCAN_EXE_HARD_EXCLUDE.has(lower)) return true + return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k)) +} + +/** + * 递归收集目录下的所有 .exe 文件路径 + * @param maxDepth 最大递归深度 (-1 表示无限) + */ +export async function collectExePaths(dir: string, maxDepth: number, currentDepth: number = 1): Promise { + const results: string[] = [] + const dirents = await safeReadDir(dir) + + for (const dirent of dirents) { + const full = path.join(dir, dirent.name) + if (dirent.isDirectory()) { + const folderName = dirent.name.toLowerCase() + if (GAME_SCAN_IGNORE_DIR_NAMES.has(folderName)) continue + if (maxDepth === -1 || currentDepth < maxDepth) { + const sub = await collectExePaths(full, maxDepth, currentDepth + 1) + results.push(...sub) + } + continue + } + + if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue + if (shouldSkipExeByName(dirent.name)) continue + results.push(full) + } + + return results +} + +/** + * 启发式算法:从目录下选择最像是游戏主进程的可执行文件 + * + * 评分规则: + * - 文件名与文件夹名完全匹配 (+10) + * - 文件名包含文件夹名 (+6) + * - 文件夹名包含文件名 (+4) + * - 不包含 launcher 关键词 (+2) + * - 文件体积越大分数越高 (每 40MB +1, 上限 +8) + */ +export async function pickBestExecutable(gameDir: string, displayName: string): Promise { + const exes = await collectExePaths(gameDir, 3) + if (exes.length === 0) return null + if (exes.length === 1) return exes[0] || null + + const target = normalizeDisplayName(displayName).toLowerCase() + const ranked = await Promise.all(exes.map(async (exe) => { + let score = 0 + const base = basenameLower(exe).replace(/\.exe$/, '') + const full = exe.toLowerCase() + + if (base === target) score += 10 + if (base.includes(target)) score += 6 + if (target.includes(base)) score += 4 + if (!full.includes('launcher')) score += 2 + + try { + const stat = await fs.stat(exe) + score += Math.min(8, Math.floor((stat.size || 0) / (40 * 1024 * 1024))) + } catch { + // ignore stat errors and keep heuristic score + } + + return { exe, score } + })) + + ranked.sort((a, b) => b.score - a.score) + return ranked[0]?.exe || null +} diff --git a/electron/services/singbox.ts b/electron/services/singbox.ts deleted file mode 100644 index f4883e0..0000000 --- a/electron/services/singbox.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { spawn, ChildProcess } from 'child_process' -import path from 'path' -import fs from 'fs-extra' -import { app, BrowserWindow, ipcMain } from 'electron' -import AdmZip from 'adm-zip' -import * as tar from 'tar' -import { pipeline } from 'stream/promises' -import { createWriteStream } from 'fs' -import { Readable } from 'stream' -import https from 'node:https' -import { generateId } from '../utils/id' - -import { normalizeProcessNames, areStringArraysEqual } from '../utils/process-helper' - -const BIN_DIR = path.join(app.getPath('userData'), 'bin') - -/** - * Sing-box 核心服务 - * - * 负责管理 sing-box 内核的生命周期,包括: - * 1. 自动下载与安装内核 - * 2. 启动/停止/重启内核进程 - * 3. 实时日志捕获与分发 - * 4. 动态更新路由规则 (Process Rules) - */ -export class SingBoxService { - private process: ChildProcess | null = null - private mainWindow: BrowserWindow | null = null - private retryCount = 0 - private maxRetries = 3 - private downloadPromise: Promise | null = null - private lastLogs: string[] = [] - private isStopping = false - private suppressNextStoppedStatus = false - private processRuleUpdateQueue: Promise = Promise.resolve() - - constructor(window: BrowserWindow) { - this.mainWindow = window - this.setupIPC() - } - - private setupIPC() { - ipcMain.handle('singbox-start', async (_, configContent: string) => { - const configPath = path.join(app.getPath('userData'), 'config.json') - await fs.writeFile(configPath, configContent) - await this.start(configPath) - }) - ipcMain.handle('singbox-stop', () => this.stop()) - ipcMain.handle('singbox-restart', async (_, configContent: string) => { - this.suppressNextStoppedStatus = true - await this.stopAndWaitForExit(5000) - const configPath = path.join(app.getPath('userData'), 'config.json') - await fs.writeFile(configPath, configContent) - await this.start(configPath) - }) - } - - async checkAndDownloadBinary() { - await fs.ensureDir(BIN_DIR) - const platform = process.platform - const arch = process.arch - const ext = platform === 'win32' ? '.exe' : '' - const binPath = path.join(BIN_DIR, `sing-box${ext}`) - - if (await fs.pathExists(binPath)) { - return binPath - } - - if (!this.downloadPromise) { - this.downloadPromise = this.downloadAndInstallBinary(platform, arch) - .finally(() => { - this.downloadPromise = null - }) - } - - return this.downloadPromise - } - - async start(configPath: string) { - if (this.process) { - this.log('Process already running', 'info') - return - } - - this.isStopping = false - const binPath = await this.checkAndDownloadBinary() - if (!await fs.pathExists(binPath)) { - const msg = 'sing-box 可执行文件不存在,无法启动加速' - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - throw new Error(msg) - } - - await this.validateConfig(binPath, configPath) - - this.log('正在启动 sing-box...') - this.lastLogs = [] - const pushLog = (line: string) => { - const trimmed = this.stripAnsi(String(line || '').trim()) - if (!trimmed) return - this.lastLogs.push(trimmed) - if (this.lastLogs.length > 80) this.lastLogs.shift() - } - - this.process = spawn(binPath, ['run', '-c', configPath], { windowsHide: true, env: this.getSingboxEnv() }) - - this.process.stdout?.on('data', (data) => { - const text = data.toString().trim() - pushLog(text) - this.log(this.stripAnsi(text)) - }) - - this.process.stderr?.on('data', (data) => { - const text = data.toString().trim() - pushLog(text) - this.log(this.stripAnsi(text), 'error') - }) - - const started = await new Promise((resolve, reject) => { - let settled = false - const settle = (fn: () => void) => { - if (settled) return - settled = true - fn() - } - - const startupTimer = setTimeout(() => { - settle(() => resolve()) - }, 800) - - this.process?.once('error', (err) => { - clearTimeout(startupTimer) - const msg = `sing-box 启动失败:${String(err?.message || err)}` - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - settle(() => reject(new Error(msg))) - }) - - this.process?.once('close', (code) => { - if (!settled) { - clearTimeout(startupTimer) - const detail = this.buildHelpfulErrorDetail(this.lastLogs) - const last = this.lastLogs.at(-1) - const msg = `sing-box 启动后立即退出(code=${String(code)})${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}` - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - settle(() => reject(new Error(msg))) - } - }) - }) - - this.process.on('close', (code) => { - this.log(`sing-box exited with code ${code}`) - this.process = null - if (this.suppressNextStoppedStatus) { - this.suppressNextStoppedStatus = false - } else { - this.sendStatus('stopped') - } - - if (this.isStopping) return - if (code !== 0 && this.retryCount < this.maxRetries) { - this.retryCount++ - this.log(`Retrying start (${this.retryCount}/${this.maxRetries})...`) - setTimeout(() => this.start(configPath), 2000) - } else if (code !== 0) { - const detail = this.buildHelpfulErrorDetail(this.lastLogs) - const last = this.lastLogs.at(-1) - const msg = `sing-box 启动失败(已重试 ${this.maxRetries} 次)${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}` - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - } - }) - - await started - this.sendStatus('running') - this.retryCount = 0 - } - - /** - * 停止 sing-box - */ - stop() { - if (this.process) { - this.isStopping = true - this.retryCount = 0 - const current = this.process - const pid = typeof current.pid === 'number' ? current.pid : null - current.kill('SIGINT') - setTimeout(() => { - if (!this.process) return - if (this.process !== current) return - if (process.platform === 'win32' && pid) { - spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }) - } else { - this.process.kill('SIGKILL') - } - }, 800) -} - } - - private log(message: string, type: 'info' | 'error' = 'info') { - console.log(`[SingBox] ${message}`) - this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() }) - } - - private sendStatus(status: 'running' | 'stopped') { - this.mainWindow?.webContents.send('singbox-status', status) - } - - private async validateConfig(binPath: string, configPath: string): Promise { - const { code, output } = await this.runOnce(binPath, ['check', '-c', configPath]) - if (code === 0) return - - const lines = output - .split(/\r?\n/g) - .map(l => this.stripAnsi(l.trim())) - .filter(Boolean) - .slice(-12) - - const detail = this.buildHelpfulErrorDetail(lines) - const msg = `sing-box 配置校验失败(code=${String(code)})${lines.length ? `:${lines.at(-1)}` : ''}${detail ? `\n\n${detail}` : ''}` - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - throw new Error(msg) - } - - private runOnce(binPath: string, args: string[]): Promise<{ code: number, output: string }> { - return new Promise((resolve) => { - const p = spawn(binPath, args, { windowsHide: true, env: this.getSingboxEnv() }) - const chunks: Buffer[] = [] - const onData = (d: any) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d))) - p.stdout?.on('data', onData) - p.stderr?.on('data', onData) - p.on('close', (code) => { - resolve({ code: typeof code === 'number' ? code : -1, output: Buffer.concat(chunks).toString('utf8') }) - }) - p.on('error', () => { - resolve({ code: -1, output: '无法执行 sing-box,请检查可执行文件是否完整' }) - }) - }) - } - - private buildHelpfulErrorDetail(lines: string[]): string { - const text = lines.join('\n').toLowerCase() - const hints: string[] = [] -console.log(text); - - if (text.includes('create tun') || text.includes('tun') || text.includes('wintun') || text.includes('tunnel')) { - hints.push('提示:当前使用 TUN 模式,Windows 下通常需要管理员权限,并且需要可用的 TUN/Wintun 支持。请尝试“以管理员身份运行”LagZero。') - } - - if (text.includes('access is denied') || text.includes('permission denied') || text.includes('operation not permitted')) { - hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。') - } - - if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) { - hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。') - } - - if (text.includes('deprecated_special_outbounds') || text.includes('deprecated special outbounds') || text.includes('enable_deprecated_special_outbounds')) { - hints.push('提示:当前 sing-box 版本要求开启兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS=true。') - } - - if (text.includes('deprecated_tun_address_x') || text.includes('deprecated tun address x') || text.includes('enable_deprecated_tun_address_x')) { - hints.push('提示:当前 sing-box 版本要求开启 TUN 兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_TUN_ADDRESS_X=true。') - } - - if (hints.length === 0) return '' - return Array.from(new Set(hints)).join('\n') - } - - private getSingboxEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS: 'true', - ENABLE_DEPRECATED_TUN_ADDRESS_X: 'true' - } - } - - private stripAnsi(input: string): string { - return input.replace(/\x1B\[[0-9;]*m/g, '') - } - - /** - * 动态更新进程匹配规则 - * - * 修改 config.json 中的 route.rules 和 dns.rules,添加或更新进程名列表, - * 然后重启 sing-box 以生效。 - * - * @param processNames - 最新的需要代理的进程名列表 - */ - async updateProcessNames(processNames: string[]): Promise { - this.processRuleUpdateQueue = this.processRuleUpdateQueue - .then(() => this.applyProcessNameUpdate(processNames)) - .catch((err) => { - const msg = `更新进程规则失败: ${String((err as any)?.message || err)}` - this.log(msg, 'error') - }) - return this.processRuleUpdateQueue - } - - private async applyProcessNameUpdate(processNames: string[]): Promise { - const configPath = path.join(app.getPath('userData'), 'config.json') - if (!await fs.pathExists(configPath)) return - if (!this.process) return - - const normalized = normalizeProcessNames(processNames) - if (normalized.length === 0) return - - let configRaw = '' - try { - configRaw = await fs.readFile(configPath, 'utf8') - } catch { - return - } - - let config: any - try { - config = JSON.parse(configRaw) - } catch { - return - } - - let changed = false - const routeRules = Array.isArray(config?.route?.rules) ? config.route.rules : [] - const routeRule = routeRules.find((r: any) => Array.isArray(r?.process_name) && r?.outbound === 'proxy') - if (routeRule) { - if (!areStringArraysEqual(routeRule.process_name, normalized)) { - routeRule.process_name = normalized - changed = true - } - } else { - routeRules.push({ process_name: normalized, outbound: 'proxy' }) - if (config?.route) config.route.rules = routeRules - changed = true - } - - const dnsRules = Array.isArray(config?.dns?.rules) ? config.dns.rules : [] - const hasRemotePrimaryServer = Array.isArray(config?.dns?.servers) - && config.dns.servers.some((s: any) => s?.tag === 'remote-primary') - if (hasRemotePrimaryServer) { - const dnsRule = dnsRules.find((r: any) => Array.isArray(r?.process_name) && r?.server === 'remote-primary') - if (dnsRule) { - if (!areStringArraysEqual(dnsRule.process_name, normalized)) { - dnsRule.process_name = normalized - changed = true - } - } else { - dnsRules.unshift({ process_name: normalized, server: 'remote-primary' }) - if (config?.dns) config.dns.rules = dnsRules - changed = true - } - } - - if (!changed) return - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - this.log(`检测到子进程,已更新进程匹配规则(${normalized.length})并重启 sing-box`) - await this.restartWithCurrentConfig(configPath) - } - - private async restartWithCurrentConfig(configPath: string): Promise { - this.suppressNextStoppedStatus = true - await this.stopAndWaitForExit(5000) - await this.start(configPath) - } - - private async stopAndWaitForExit(timeoutMs: number): Promise { - if (!this.process) return - const current = this.process - this.stop() - await new Promise((resolve) => { - let done = false - const finish = () => { - if (done) return - done = true - resolve() - } - - const timer = setTimeout(finish, timeoutMs) - current.once('close', () => { - clearTimeout(timer) - finish() - }) - }) - } - - private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string): Promise { - const ext = platform === 'win32' ? '.exe' : '' - const binPath = path.join(BIN_DIR, `sing-box${ext}`) - - if (await fs.pathExists(binPath)) return binPath - - this.log('未检测到 sing-box,开始自动下载...') - - const { version, downloadUrl, archiveExt } = await this.resolveLatestReleaseDownload(platform, arch) - - const archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`) - const extractDir = path.join(BIN_DIR, `tmp-${generateId()}`) - - try { - await fs.ensureDir(extractDir) - await this.downloadToFile(downloadUrl, archivePath) - await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32') - - if (!await fs.pathExists(binPath)) { - throw new Error('安装完成后仍未找到 sing-box 可执行文件') - } - - this.log(`sing-box 已安装:${binPath}`) - return binPath - } catch (e: any) { - const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}` - this.log(msg, 'error') - this.mainWindow?.webContents.send('singbox-error', msg) - throw new Error(msg) - } finally { - await fs.remove(extractDir).catch(() => {}) - await fs.remove(archivePath).catch(() => {}) - } - } - - private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> { - const platformName = platform === 'win32' ? 'windows' - : platform === 'darwin' ? 'darwin' - : platform === 'linux' ? 'linux' - : null - - if (!platformName) { - throw new Error(`不支持的平台:${platform}`) - } - - const archName = arch === 'x64' ? 'amd64' - : arch === 'arm64' ? 'arm64' - : arch === 'ia32' ? '386' - : null - - if (!archName) { - throw new Error(`不支持的架构:${arch}`) - } - - const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz' - - const release = await this.fetchJson('https://api.github.com/repos/SagerNet/sing-box/releases/latest') - const tagName = String(release?.tag_name || '') - const version = tagName.startsWith('v') ? tagName.slice(1) : tagName - if (!version) throw new Error('无法解析 sing-box 版本号') - - const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}` - const assets: any[] = Array.isArray(release?.assets) ? release.assets : [] - const asset = assets.find(a => String(a?.name || '') === assetName) - const downloadUrl = String(asset?.browser_download_url || '') - - if (!downloadUrl) { - throw new Error(`未找到对应的发布包:${assetName}`) - } - - return { version, downloadUrl, archiveExt } - } - - private fetchJson(url: string): Promise { - return new Promise((resolve, reject) => { - const req = https.request(url, { - method: 'GET', - headers: { - 'User-Agent': 'LagZero', - 'Accept': 'application/vnd.github+json' - } - }, (res) => { - const status = res.statusCode || 0 - const chunks: Buffer[] = [] - - res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))) - res.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8') - if (status >= 300) { - reject(new Error(`请求失败(${status}):${body.slice(0, 200)}`)) - return - } - try { - resolve(JSON.parse(body) as T) - } catch (e) { - reject(new Error('解析 JSON 失败')) - } - }) - }) - - req.on('error', reject) - req.end() - }) - } - - private async downloadToFile(url: string, destPath: string): Promise { - await fs.ensureDir(path.dirname(destPath)) - - const doRequest = (u: string, redirectLeft: number): Promise => new Promise((resolve, reject) => { - const req = https.request(u, { - method: 'GET', - headers: { 'User-Agent': 'LagZero' } - }, (res) => { - const status = res.statusCode || 0 - const location = res.headers.location - if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) { - res.resume() - resolve(doRequest(location, redirectLeft - 1)) - return - } - if (status >= 300) { - reject(new Error(`下载失败(${status})`)) - res.resume() - return - } - - const total = Number(res.headers['content-length'] || 0) - let received = 0 - let lastLoggedBucket = -1 - const fileStream = createWriteStream(destPath) - - res.on('data', (chunk) => { - received += Buffer.byteLength(chunk) - if (total > 0) { - const pct = Math.floor((received / total) * 100) - const bucket = Math.floor(pct / 20) * 20 - if (bucket !== lastLoggedBucket && bucket <= 100) { - lastLoggedBucket = bucket - this.log(`正在下载 sing-box:${bucket}%`) - } - } - }) - - pipeline(res as unknown as Readable, fileStream) - .then(() => resolve()) - .catch(reject) - }) - - req.on('error', reject) - req.end() - }) - - await doRequest(url, 5) - } - - private async extractAndInstall( - archivePath: string, - archiveExt: string, - extractDir: string, - binPath: string, - isWindows: boolean - ): Promise { - if (archiveExt === '.zip') { - const zip = new AdmZip(archivePath) - zip.extractAllTo(extractDir, true) - } else if (archiveExt === '.tar.gz') { - await tar.x({ file: archivePath, cwd: extractDir }) - } else { - throw new Error(`不支持的压缩格式:${archiveExt}`) - } - - const exeName = isWindows ? 'sing-box.exe' : 'sing-box' - const exePath = await this.findFileRecursive(extractDir, exeName) - if (!exePath) { - throw new Error('解压后未找到 sing-box 可执行文件') - } - - await fs.copyFile(exePath, binPath) - - if (isWindows) { - const exeDir = path.dirname(exePath) - const entries = await fs.readdir(exeDir, { withFileTypes: true }) - const dlls = entries - .filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll')) - .map(e => path.join(exeDir, e.name)) - - for (const dll of dlls) { - const target = path.join(BIN_DIR, path.basename(dll)) - await fs.copyFile(dll, target) - } - } - - await fs.chmod(binPath, 0o755).catch(() => {}) - } - - private async findFileRecursive(rootDir: string, fileName: string): Promise { - const entries = await fs.readdir(rootDir, { withFileTypes: true }) - for (const entry of entries) { - const full = path.join(rootDir, entry.name) - if (entry.isFile() && entry.name === fileName) return full - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const found = await this.findFileRecursive(path.join(rootDir, entry.name), fileName) - if (found) return found - } - return null - } -} diff --git a/electron/services/singbox/config.ts b/electron/services/singbox/config.ts new file mode 100644 index 0000000..98a6283 --- /dev/null +++ b/electron/services/singbox/config.ts @@ -0,0 +1,165 @@ +import fs from 'fs-extra' +import path from 'path' +import { app } from 'electron' +import { normalizeProcessNames, areStringArraysEqual } from '../../utils/process-helper' +import { LogFn } from './installer' +import { getSingboxEnv, stripAnsi } from './utils' +import { spawn } from 'child_process' + +/** + * Sing-box 配置管理器 + * 负责配置文件校验、规则更新等操作 + */ +export class SingBoxConfigManager { + private log: LogFn + + constructor(logFn: LogFn) { + this.log = logFn + } + + /** + * 校验 sing-box 配置文件是否合法 + * @param binPath sing-box 可执行文件路径 + * @param configPath 配置文件路径 + */ + async validateConfig(binPath: string, configPath: string): Promise { + const { code, output } = await this.runCheck(binPath, configPath) + if (code === 0) return + + const lines = output + .split(/\r?\n/g) + .map(l => stripAnsi(l.trim())) + .filter(Boolean) + .slice(-12) + + const detail = this.buildHelpfulErrorDetail(lines) + const msg = `sing-box 配置校验失败(code=${String(code)})${lines.length ? `:${lines.at(-1)}` : ''}${detail ? `\n\n${detail}` : ''}` + this.log(msg, 'error') + throw new Error(msg) + } + + /** + * 运行 `sing-box check` 命令 + */ + private runCheck(binPath: string, configPath: string): Promise<{ code: number, output: string }> { + return new Promise((resolve) => { + const p = spawn(binPath, ['check', '-c', configPath], { windowsHide: true, env: getSingboxEnv() }) + const chunks: Buffer[] = [] + const onData = (d: any) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d))) + p.stdout?.on('data', onData) + p.stderr?.on('data', onData) + p.on('close', (code) => { + resolve({ code: typeof code === 'number' ? code : -1, output: Buffer.concat(chunks).toString('utf8') }) + }) + p.on('error', () => { + resolve({ code: -1, output: '无法执行 sing-box,请检查可执行文件是否完整' }) + }) + }) + } + + /** + * 根据错误日志构建用户友好的提示信息 + */ + buildHelpfulErrorDetail(lines: string[]): string { + const text = lines.join('\n').toLowerCase() + const hints: string[] = [] + + if (text.includes('create tun') || text.includes('tun') || text.includes('wintun') || text.includes('tunnel')) { + hints.push('提示:当前使用 TUN 模式,Windows 下通常需要管理员权限,并且需要可用的 TUN/Wintun 支持。请尝试“以管理员身份运行”LagZero。') + } + + if (text.includes('access is denied') || text.includes('permission denied') || text.includes('operation not permitted')) { + hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。') + } + + if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) { + hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。') + } + + if (text.includes('deprecated_special_outbounds') || text.includes('deprecated special outbounds') || text.includes('enable_deprecated_special_outbounds')) { + hints.push('提示:当前 sing-box 版本要求开启兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS=true。') + } + + if (text.includes('deprecated_tun_address_x') || text.includes('deprecated tun address x') || text.includes('enable_deprecated_tun_address_x')) { + hints.push('提示:当前 sing-box 版本要求开启 TUN 兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_TUN_ADDRESS_X=true。') + } + + if (text.includes('mtu') || text.includes('fragment') || text.includes('message too long') || text.includes('packet too large')) { + hints.push('提示:可能是 MTU/分片导致的问题。请在“会话网络优化”里尝试把 MTU 调低到 1280 或 1240。') + } + + if (text.includes('udp') || text.includes('quic') || text.includes('xudp')) { + hints.push('提示:检测到 UDP/QUIC 相关异常。请把 UDP 模式改为“自动”或“优先 TCP”,必要时关闭 xudp 覆盖。') + } + + if (text.includes('address already in use') || text.includes('only one usage of each socket address') || text.includes('bind:')) { + hints.push('提示:本地端口被占用。请修改“系统代理端口/本地代理端口”,或关闭占用该端口的软件后重试。') + } + + if (hints.length === 0) return '' + return Array.from(new Set(hints)).join('\n') + } + + /** + * 更新配置文件中的进程匹配规则 + * @param processNames 需要代理的进程名列表 + * @returns 是否有更新 + */ + async updateProcessNames(processNames: string[]): Promise { + const configPath = path.join(app.getPath('userData'), 'config.json') + if (!await fs.pathExists(configPath)) return false + + const normalized = normalizeProcessNames(processNames) + if (normalized.length === 0) return false + + let configRaw = '' + try { + configRaw = await fs.readFile(configPath, 'utf8') + } catch { + return false + } + + let config: any + try { + config = JSON.parse(configRaw) + } catch { + return false + } + + let changed = false + const routeRules = Array.isArray(config?.route?.rules) ? config.route.rules : [] + const routeRule = routeRules.find((r: any) => Array.isArray(r?.process_name) && r?.outbound === 'proxy') + if (routeRule) { + if (!areStringArraysEqual(routeRule.process_name, normalized)) { + routeRule.process_name = normalized + changed = true + } + } else { + routeRules.push({ process_name: normalized, outbound: 'proxy' }) + if (config?.route) config.route.rules = routeRules + changed = true + } + + const dnsRules = Array.isArray(config?.dns?.rules) ? config.dns.rules : [] + const hasRemotePrimaryServer = Array.isArray(config?.dns?.servers) + && config.dns.servers.some((s: any) => s?.tag === 'remote-primary') + if (hasRemotePrimaryServer) { + const dnsRule = dnsRules.find((r: any) => Array.isArray(r?.process_name) && r?.server === 'remote-primary') + if (dnsRule) { + if (!areStringArraysEqual(dnsRule.process_name, normalized)) { + dnsRule.process_name = normalized + changed = true + } + } else { + dnsRules.unshift({ process_name: normalized, server: 'remote-primary' }) + if (config?.dns) config.dns.rules = dnsRules + changed = true + } + } + + if (changed) { + await fs.writeFile(configPath, JSON.stringify(config, null, 2)) + } + return changed + } +} diff --git a/electron/services/singbox/constants.ts b/electron/services/singbox/constants.ts new file mode 100644 index 0000000..4c80642 --- /dev/null +++ b/electron/services/singbox/constants.ts @@ -0,0 +1,8 @@ +import path from 'path' +import { app } from 'electron' + +/** + * 二进制文件存储目录 + * 位于用户数据目录下的 bin 文件夹中 + */ +export const BIN_DIR = path.join(app.getPath('userData'), 'bin') diff --git a/electron/services/singbox/index.ts b/electron/services/singbox/index.ts new file mode 100644 index 0000000..f7fd0ac --- /dev/null +++ b/electron/services/singbox/index.ts @@ -0,0 +1,240 @@ +import { spawn, ChildProcess } from 'child_process' +import path from 'path' +import fs from 'fs-extra' +import { app, BrowserWindow, ipcMain } from 'electron' +import { SingBoxInstaller } from './installer' +import { SingBoxConfigManager } from './config' +import { getSingboxEnv, stripAnsi } from './utils' + +/** + * Sing-box 核心服务 + * + * 负责管理 sing-box 内核的生命周期,包括: + * 1. 自动下载与安装内核 + * 2. 启动/停止/重启内核进程 + * 3. 实时日志捕获与分发 + * 4. 动态更新路由规则 (Process Rules) + */ +export class SingBoxService { + private process: ChildProcess | null = null + private mainWindow: BrowserWindow | null = null + private retryCount = 0 + private maxRetries = 3 + private lastLogs: string[] = [] + private isStopping = false + private suppressNextStoppedStatus = false + private processRuleUpdateQueue: Promise = Promise.resolve() + + private installer: SingBoxInstaller + private configManager: SingBoxConfigManager + + constructor(window: BrowserWindow) { + this.mainWindow = window + const logFn = (msg: string, type: 'info' | 'error' = 'info') => this.log(msg, type) + this.installer = new SingBoxInstaller(logFn) + this.configManager = new SingBoxConfigManager(logFn) + this.setupIPC() + } + + /** + * 注册 IPC 监听器 + */ + private setupIPC() { + ipcMain.handle('singbox-start', async (_, configContent: string) => { + const configPath = path.join(app.getPath('userData'), 'config.json') + await fs.writeFile(configPath, configContent) + await this.start(configPath) + }) + ipcMain.handle('singbox-stop', () => this.stop()) + ipcMain.handle('singbox-restart', async (_, configContent: string) => { + this.suppressNextStoppedStatus = true + await this.stopAndWaitForExit(5000) + const configPath = path.join(app.getPath('userData'), 'config.json') + await fs.writeFile(configPath, configContent) + await this.start(configPath) + }) + } + + /** + * 启动 sing-box 内核 + * @param configPath 配置文件路径 + */ + async start(configPath: string) { + if (this.process) { + this.log('Process already running', 'info') + return + } + + this.isStopping = false + const binPath = await this.installer.checkAndDownloadBinary() + + try { + await this.configManager.validateConfig(binPath, configPath) + } catch (e: any) { + this.mainWindow?.webContents.send('singbox-error', e.message) + throw e + } + + this.log('正在启动 sing-box...') + this.lastLogs = [] + const pushLog = (line: string) => { + const trimmed = stripAnsi(String(line || '').trim()) + if (!trimmed) return + this.lastLogs.push(trimmed) + if (this.lastLogs.length > 80) this.lastLogs.shift() + } + + this.process = spawn(binPath, ['run', '-c', configPath], { windowsHide: true, env: getSingboxEnv() }) + + this.process.stdout?.on('data', (data) => { + const text = data.toString().trim() + pushLog(text) + this.log(stripAnsi(text)) + }) + + this.process.stderr?.on('data', (data) => { + const text = data.toString().trim() + pushLog(text) + this.log(stripAnsi(text), 'error') + }) + + const started = await new Promise((resolve, reject) => { + let settled = false + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } + + const startupTimer = setTimeout(() => { + settle(() => resolve()) + }, 800) + + this.process?.once('error', (err) => { + clearTimeout(startupTimer) + const msg = `sing-box 启动失败:${String(err?.message || err)}` + this.log(msg, 'error') + this.mainWindow?.webContents.send('singbox-error', msg) + settle(() => reject(new Error(msg))) + }) + + this.process?.once('close', (code) => { + if (!settled) { + clearTimeout(startupTimer) + const detail = this.configManager.buildHelpfulErrorDetail(this.lastLogs) + const last = this.lastLogs.at(-1) + const msg = `sing-box 启动后立即退出(code=${String(code)})${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}` + this.log(msg, 'error') + this.mainWindow?.webContents.send('singbox-error', msg) + settle(() => reject(new Error(msg))) + } + }) + }) + + this.process.on('close', (code) => { + this.log(`sing-box exited with code ${code}`) + this.process = null + if (this.suppressNextStoppedStatus) { + this.suppressNextStoppedStatus = false + } else { + this.sendStatus('stopped') + } + + if (this.isStopping) return + if (code !== 0 && this.retryCount < this.maxRetries) { + this.retryCount++ + this.log(`Retrying start (${this.retryCount}/${this.maxRetries})...`) + setTimeout(() => this.start(configPath), 2000) + } else if (code !== 0) { + const detail = this.configManager.buildHelpfulErrorDetail(this.lastLogs) + const last = this.lastLogs.at(-1) + const msg = `sing-box 启动失败(已重试 ${this.maxRetries} 次)${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}` + this.log(msg, 'error') + this.mainWindow?.webContents.send('singbox-error', msg) + } + }) + + await started + this.sendStatus('running') + this.retryCount = 0 + } + + /** + * 停止 sing-box 内核 + */ + stop() { + if (this.process) { + this.isStopping = true + this.retryCount = 0 + const current = this.process + const pid = typeof current.pid === 'number' ? current.pid : null + current.kill('SIGINT') + setTimeout(() => { + if (!this.process) return + if (this.process !== current) return + if (process.platform === 'win32' && pid) { + spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }) + } else { + this.process.kill('SIGKILL') + } + }, 800) + } + } + + private log(message: string, type: 'info' | 'error' = 'info') { + console.log(`[SingBox] ${message}`) + this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() }) + } + + private sendStatus(status: 'running' | 'stopped') { + this.mainWindow?.webContents.send('singbox-status', status) + } + + /** + * 动态更新进程匹配规则 + * + * @param processNames 需要代理的进程名列表 + */ + async updateProcessNames(processNames: string[]): Promise { + this.processRuleUpdateQueue = this.processRuleUpdateQueue + .then(async () => { + const changed = await this.configManager.updateProcessNames(processNames) + if (changed && this.process) { + const configPath = path.join(app.getPath('userData'), 'config.json') + this.log(`检测到子进程,已更新进程匹配规则(${processNames.length})并重启 sing-box`) + await this.restartWithCurrentConfig(configPath) + } + }) + .catch((err) => { + const msg = `更新进程规则失败: ${String((err as any)?.message || err)}` + this.log(msg, 'error') + }) + return this.processRuleUpdateQueue + } + + private async restartWithCurrentConfig(configPath: string): Promise { + this.suppressNextStoppedStatus = true + await this.stopAndWaitForExit(5000) + await this.start(configPath) + } + + private async stopAndWaitForExit(timeoutMs: number): Promise { + if (!this.process) return + const current = this.process + this.stop() + await new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + + const timer = setTimeout(finish, timeoutMs) + current.once('close', () => { + clearTimeout(timer) + finish() + }) + }) + } +} diff --git a/electron/services/singbox/installer.ts b/electron/services/singbox/installer.ts new file mode 100644 index 0000000..612da55 --- /dev/null +++ b/electron/services/singbox/installer.ts @@ -0,0 +1,267 @@ +import fs from 'fs-extra' +import path from 'path' +import AdmZip from 'adm-zip' +import * as tar from 'tar' +import { pipeline } from 'stream/promises' +import { createWriteStream } from 'fs' +import { Readable } from 'stream' +import https from 'node:https' +import { generateId } from '../../utils/id' +import { BIN_DIR } from './constants' + +/** + * 日志回调函数类型 + */ +export type LogFn = (message: string, type?: 'info' | 'error') => void + +/** + * Sing-box 安装器 + * 负责检查、下载和安装 sing-box 内核 + */ +export class SingBoxInstaller { + private log: LogFn + + constructor(logFn: LogFn) { + this.log = logFn + } + + /** + * 检查 sing-box 是否存在,不存在则自动下载 + * @returns 可执行文件的绝对路径 + */ + async checkAndDownloadBinary(): Promise { + await fs.ensureDir(BIN_DIR) + const platform = process.platform + const arch = process.arch + const ext = platform === 'win32' ? '.exe' : '' + const binPath = path.join(BIN_DIR, `sing-box${ext}`) + + if (await fs.pathExists(binPath)) { + return binPath + } + + return this.downloadAndInstallBinary(platform, arch, binPath) + } + + /** + * 执行下载和安装流程 + */ + private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string, binPath: string): Promise { + this.log('未检测到 sing-box,开始自动下载...') + + const { version, downloadUrl, archiveExt } = await this.resolveLatestReleaseDownload(platform, arch) + + const archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`) + const extractDir = path.join(BIN_DIR, `tmp-${generateId()}`) + + try { + await fs.ensureDir(extractDir) + await this.downloadToFile(downloadUrl, archivePath) + await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32') + + if (!await fs.pathExists(binPath)) { + throw new Error('安装完成后仍未找到 sing-box 可执行文件') + } + + this.log(`sing-box 已安装:${binPath}`) + return binPath + } catch (e: any) { + const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}` + this.log(msg, 'error') + throw new Error(msg) + } finally { + await fs.remove(extractDir).catch(() => {}) + await fs.remove(archivePath).catch(() => {}) + } + } + + /** + * 解析 GitHub 最新 Release 版本信息 + */ + private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> { + const platformName = platform === 'win32' ? 'windows' + : platform === 'darwin' ? 'darwin' + : platform === 'linux' ? 'linux' + : null + + if (!platformName) { + throw new Error(`不支持的平台:${platform}`) + } + + const archName = arch === 'x64' ? 'amd64' + : arch === 'arm64' ? 'arm64' + : arch === 'ia32' ? '386' + : null + + if (!archName) { + throw new Error(`不支持的架构:${arch}`) + } + + const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz' + + const release = await this.fetchJson('https://api.github.com/repos/SagerNet/sing-box/releases/latest') + const tagName = String(release?.tag_name || '') + const version = tagName.startsWith('v') ? tagName.slice(1) : tagName + if (!version) throw new Error('无法解析 sing-box 版本号') + + const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}` + const assets: any[] = Array.isArray(release?.assets) ? release.assets : [] + const asset = assets.find(a => String(a?.name || '') === assetName) + const downloadUrl = String(asset?.browser_download_url || '') + + if (!downloadUrl) { + throw new Error(`未找到对应的发布包:${assetName}`) + } + + return { version, downloadUrl, archiveExt } + } + + /** + * 简单的 HTTP GET JSON 请求封装 + */ + private fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.request(url, { + method: 'GET', + headers: { + 'User-Agent': 'LagZero', + 'Accept': 'application/vnd.github+json' + } + }, (res) => { + const status = res.statusCode || 0 + const chunks: Buffer[] = [] + + res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))) + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8') + if (status >= 300) { + reject(new Error(`请求失败(${status}):${body.slice(0, 200)}`)) + return + } + try { + resolve(JSON.parse(body) as T) + } catch (e) { + reject(new Error('解析 JSON 失败')) + } + }) + }) + + req.on('error', reject) + req.end() + }) + } + + /** + * 下载文件到本地 + */ + private async downloadToFile(url: string, destPath: string): Promise { + await fs.ensureDir(path.dirname(destPath)) + + const doRequest = (u: string, redirectLeft: number): Promise => new Promise((resolve, reject) => { + const req = https.request(u, { + method: 'GET', + headers: { 'User-Agent': 'LagZero' } + }, (res) => { + const status = res.statusCode || 0 + const location = res.headers.location + if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) { + res.resume() + resolve(doRequest(location, redirectLeft - 1)) + return + } + if (status >= 300) { + reject(new Error(`下载失败(${status})`)) + res.resume() + return + } + + const total = Number(res.headers['content-length'] || 0) + let received = 0 + let lastLoggedBucket = -1 + const fileStream = createWriteStream(destPath) + + res.on('data', (chunk) => { + received += Buffer.byteLength(chunk) + if (total > 0) { + const pct = Math.floor((received / total) * 100) + const bucket = Math.floor(pct / 20) * 20 + if (bucket !== lastLoggedBucket && bucket <= 100) { + lastLoggedBucket = bucket + this.log(`正在下载 sing-box:${bucket}%`) + } + } + }) + + pipeline(res as unknown as Readable, fileStream) + .then(() => resolve()) + .catch(reject) + }) + + req.on('error', reject) + req.end() + }) + + await doRequest(url, 5) + } + + /** + * 解压并安装二进制文件 + */ + private async extractAndInstall( + archivePath: string, + archiveExt: string, + extractDir: string, + binPath: string, + isWindows: boolean + ): Promise { + if (archiveExt === '.zip') { + const zip = new AdmZip(archivePath) + zip.extractAllTo(extractDir, true) + } else if (archiveExt === '.tar.gz') { + await tar.x({ file: archivePath, cwd: extractDir }) + } else { + throw new Error(`不支持的压缩格式:${archiveExt}`) + } + + const exeName = isWindows ? 'sing-box.exe' : 'sing-box' + const exePath = await this.findFileRecursive(extractDir, exeName) + if (!exePath) { + throw new Error('解压后未找到 sing-box 可执行文件') + } + + await fs.copyFile(exePath, binPath) + + if (isWindows) { + // 复制可能存在的 DLL 文件 + const exeDir = path.dirname(exePath) + const entries = await fs.readdir(exeDir, { withFileTypes: true }) + const dlls = entries + .filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll')) + .map(e => path.join(exeDir, e.name)) + + for (const dll of dlls) { + const target = path.join(BIN_DIR, path.basename(dll)) + await fs.copyFile(dll, target) + } + } + + await fs.chmod(binPath, 0o755).catch(() => {}) + } + + /** + * 递归查找文件 + */ + private async findFileRecursive(rootDir: string, fileName: string): Promise { + const entries = await fs.readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(rootDir, entry.name) + if (entry.isFile() && entry.name === fileName) return full + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const found = await this.findFileRecursive(path.join(rootDir, entry.name), fileName) + if (found) return found + } + return null + } +} diff --git a/electron/services/singbox/utils.ts b/electron/services/singbox/utils.ts new file mode 100644 index 0000000..c76076c --- /dev/null +++ b/electron/services/singbox/utils.ts @@ -0,0 +1,20 @@ +/** + * 移除字符串中的 ANSI 颜色代码 + * @param input 包含 ANSI 代码的字符串 + * @returns 纯文本字符串 + */ +export function stripAnsi(input: string): string { + return input.replace(/\x1B\[[0-9;]*m/g, '') +} + +/** + * 获取运行 sing-box 所需的环境变量 + * 注入了兼容性开关,以支持新版 sing-box 的某些过时特性 + */ +export function getSingboxEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS: 'true', + ENABLE_DEPRECATED_TUN_ADDRESS_X: 'true' + } +} diff --git a/electron/services/system.ts b/electron/services/system.ts index 7e4be7f..8ef0467 100644 --- a/electron/services/system.ts +++ b/electron/services/system.ts @@ -5,18 +5,59 @@ import { ping, tcpPing } from '../utils/ping' import nodeNet from 'node:net' import nodeTls from 'node:tls' +/** + * 系统命令执行结果 + */ type SystemCommandResult = { + /** 命令是否执行成功 */ ok: boolean + /** 退出代码 */ code: number + /** 标准输出内容 */ output: string + /** 结果消息或错误提示 */ message: string } +/** + * Windows 系统代理配置快照 + * 用于备份和恢复代理设置 + */ +type SystemProxySnapshot = { + ProxyEnable?: number + ProxyServer?: string + ProxyOverride?: string + AutoConfigURL?: string + AutoDetect?: number +} + +/** + * 系统代理操作结果 + */ +type SystemProxyResult = { + ok: boolean + message: string + snapshot?: SystemProxySnapshot +} + +/** + * 系统服务 + * + * 提供底层系统操作能力,包括: + * - 网络工具 (Ping, TCP Ping, DNS 刷新) + * - 虚拟网卡管理 + * - 系统代理设置与清除 + * - HTTP 代理连通性测试 + */ export class SystemService { constructor() { this.registerIPC() } + /** + * 刷新系统 DNS 缓存 + * 目前仅支持 Windows + */ async flushDnsCache(): Promise { if (process.platform === 'win32') { const { code, output } = await runCommand('ipconfig', ['/flushdns']) @@ -36,6 +77,11 @@ export class SystemService { } } + /** + * 重置 TUN 虚拟网卡 + * 尝试卸载指定的网络适配器,以便 sing-box 重新创建 + * @param interfaceName 网卡名称,默认为 'LagZero' + */ async reinstallTunAdapter(interfaceName: string = 'LagZero'): Promise { if (process.platform !== 'win32') { return { @@ -74,6 +120,16 @@ export class SystemService { } } + /** + * 测试 HTTP 代理连通性 + * + * 尝试通过指定的本地代理连接到目标服务器,支持 HTTPS 握手验证 + * + * @param proxyPort 代理端口 + * @param targetHost 目标主机 + * @param targetPort 目标端口 (默认 443) + * @param timeoutMs 超时时间 (默认 5000ms) + */ async testHttpProxyConnect(proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) { return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => { const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort }) @@ -151,17 +207,187 @@ export class SystemService { }) } + /** + * 获取当前系统代理状态 (仅 Windows) + */ + async getSystemProxyState(): Promise<{ ok: boolean, message: string, state?: SystemProxySnapshot }> { + if (process.platform !== 'win32') { + return { + ok: false, + message: `Unsupported platform: ${process.platform}` + } + } + + try { + const state = await this.readWindowsSystemProxyState() + return { ok: true, message: 'OK', state } + } catch (e: any) { + return { ok: false, message: String(e?.message || e) } + } + } + + /** + * 设置系统代理 (仅 Windows) + * 会自动备份当前代理设置 + * + * @param port 代理端口 + * @param bypass 不走代理的地址列表 + */ + async setSystemProxy(port: number, bypass: string = ''): Promise { + if (process.platform !== 'win32') { + return { + ok: false, + message: `Unsupported platform: ${process.platform}` + } + } + + const proxyPort = Math.max(1, Math.floor(Number(port || 0))) + if (!proxyPort) { + return { + ok: false, + message: 'Invalid proxy port' + } + } + + const snapshot = await this.readWindowsSystemProxyState() + const safeBypass = String(bypass || '').replace(/'/g, "''") + const safeServer = `127.0.0.1:${proxyPort}`.replace(/'/g, "''") + const psScript = [ + "$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'", + "$old = Get-ItemProperty -Path $path", + "Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 1", + `Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${safeServer}'`, + `Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${safeBypass}'`, + "Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0", + "Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue", + "Write-Output 'system proxy configured'" + ].join('; ') + + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000) + if (code !== 0) { + return { + ok: false, + message: `Failed to set system proxy: ${output || `code=${code}`}`, + snapshot + } + } + + return { + ok: true, + message: 'System proxy enabled', + snapshot + } + } + + /** + * 清除或恢复系统代理设置 + * @param snapshot 如果提供,则恢复到该快照状态;否则直接清除代理 + */ + async clearSystemProxy(snapshot?: SystemProxySnapshot): Promise { + if (process.platform !== 'win32') { + return { + ok: false, + message: `Unsupported platform: ${process.platform}` + } + } + + const psScript = this.buildClearSystemProxyScript(snapshot) + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000) + if (code !== 0) { + return { + ok: false, + message: `Failed to clear system proxy: ${output || `code=${code}`}` + } + } + + return { + ok: true, + message: 'System proxy cleared' + } + } + + /** + * 构建清除或恢复系统代理的 PowerShell 脚本 + */ + private buildClearSystemProxyScript(snapshot?: SystemProxySnapshot): string { + const hasSnapshot = !!snapshot && typeof snapshot === 'object' + if (!hasSnapshot) { + return [ + "$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'", + "Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 0", + "Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0", + "Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue", + "Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue", + "Write-Output 'system proxy cleared'" + ].join('; ') + } + + const proxyEnable = Number(snapshot?.ProxyEnable || 0) + const autoDetect = Number(snapshot?.AutoDetect || 0) + const proxyServer = String(snapshot?.ProxyServer || '').replace(/'/g, "''") + const proxyOverride = String(snapshot?.ProxyOverride || '').replace(/'/g, "''") + const autoConfigUrl = String(snapshot?.AutoConfigURL || '').replace(/'/g, "''") + + return [ + "$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'", + `Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value ${Number.isFinite(proxyEnable) ? proxyEnable : 0}`, + `Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value ${Number.isFinite(autoDetect) ? autoDetect : 0}`, + proxyServer + ? `Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${proxyServer}'` + : "Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue", + proxyOverride + ? `Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${proxyOverride}'` + : "Remove-ItemProperty -Path $path -Name ProxyOverride -ErrorAction SilentlyContinue", + autoConfigUrl + ? `Set-ItemProperty -Path $path -Name AutoConfigURL -Type String -Value '${autoConfigUrl}'` + : "Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue", + "Write-Output 'system proxy restored'" + ].join('; ') + } + + /** + * 读取 Windows 系统代理当前状态 + */ + private async readWindowsSystemProxyState(): Promise { + const psScript = ` +$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' +$p = Get-ItemProperty -Path $path +$result = [ordered]@{ + ProxyEnable = [int]($p.ProxyEnable) + ProxyServer = [string]($p.ProxyServer) + ProxyOverride = [string]($p.ProxyOverride) + AutoConfigURL = [string]($p.AutoConfigURL) + AutoDetect = [int]($p.AutoDetect) +} +$result | ConvertTo-Json -Compress +`.trim() + + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 10000) + if (code !== 0) { + throw new Error(output || `read system proxy state failed: code=${code}`) + } + + try { + return JSON.parse(output || '{}') as SystemProxySnapshot + } catch { + throw new Error(`parse system proxy state failed: ${output}`) + } + } + + /** + * 注册 IPC 监听器 + */ private registerIPC() { - ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache()) + ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache()) // 刷新 DNS 缓存 ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => { return this.reinstallTunAdapter(interfaceName || 'LagZero') - }) + }) // 重装 TUN 适配器 ipcMain.handle('system:find-available-port', async (_, port: number, count?: number) => { return findAvailablePort(port, count) - }) + }) // 查找可用端口 ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => { return this.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs) - }) + }) // 测试 HTTP 代理连接 ipcMain.handle('system:ping', async (_, host: string) => { try { const result = await ping(host) @@ -170,7 +396,7 @@ export class SystemService { console.warn('[SystemService] system:ping failed', host, e) return -1 } - }) + }) // 测试 ICMP 代理连接 ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => { try { const result = await tcpPing(host, port) @@ -179,6 +405,15 @@ export class SystemService { console.warn('[SystemService] system:tcp-ping failed', host, port, e) return -1 } - }) + }) // 测试 TCP 代理连接 + ipcMain.handle('system:set-system-proxy', async (_, port: number, bypass?: string) => { + return this.setSystemProxy(port, bypass) + }) // 设置系统代理 + ipcMain.handle('system:clear-system-proxy', async (_, snapshot?: SystemProxySnapshot) => { + return this.clearSystemProxy(snapshot) + }) // 清除系统代理 + ipcMain.handle('system:get-system-proxy-state', async () => { + return this.getSystemProxyState() + }) // 获取系统代理状态 } } diff --git a/electron/services/updater.ts b/electron/services/updater.ts index a8d82ef..777e345 100644 --- a/electron/services/updater.ts +++ b/electron/services/updater.ts @@ -1,14 +1,34 @@ import { net, app } from 'electron' +/** + * 更新检查结果信息 + */ export type UpdateInfo = { + /** 是否有可用更新 */ updateAvailable?: boolean + /** 最新版本号 */ version?: string + /** 发布日期 */ releaseDate?: string + /** 更新日志 */ releaseNotes?: string + /** 错误信息 */ error?: string } +/** + * 自动更新服务 + * + * 负责检查 GitHub Release 或 Tags 获取最新版本信息。 + * 仅提供检查功能,不包含自动下载和安装逻辑(通常由用户手动下载或跳转)。 + */ export class UpdaterService { + /** + * 检查更新 + * + * 对比当前应用版本与 GitHub 最新版本。 + * @returns Promise + */ async checkUpdate(): Promise { try { const fetchGithub = async (url: string): Promise => { diff --git a/electron/utils/ping.ts b/electron/utils/ping.ts index 8d33d51..33a6633 100644 --- a/electron/utils/ping.ts +++ b/electron/utils/ping.ts @@ -88,7 +88,8 @@ export function ping(host: string, timeout = 2000): Promise { export function tcpPing(host: string, port: number, timeout = 2000): Promise { return new Promise((resolve) => { const start = Date.now() - const socket = new net.Socket() + const socket = new net.Socket() + socket.setNoDelay(true) let resolved = false diff --git a/src/App.vue b/src/App.vue index 1443541..b9ff701 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,7 +22,7 @@ const { isDark, themeColor } = useTheme() const theme = computed(() => isDark.value ? darkTheme : null) const themeColors: Record = { - green: { primary: '#3eaf7c', hover: '#10b981', pressed: '#047857' }, + green: { primary: '#10b981', hover: '#059669', pressed: '#047857' }, blue: { primary: '#3b82f6', hover: '#2563eb', pressed: '#1d4ed8' }, purple: { primary: '#8b5cf6', hover: '#7c3aed', pressed: '#6d28d9' }, orange: { primary: '#f97316', hover: '#ea580c', pressed: '#c2410c' }, diff --git a/src/api/categories.ts b/src/api/categories.ts index 25625ef..d0fda99 100644 --- a/src/api/categories.ts +++ b/src/api/categories.ts @@ -4,4 +4,5 @@ export const categoryApi = { getAll: () => window.categories.getAll(), save: (category: Category) => window.categories.save(category), delete: (id: string) => window.categories.delete(id), + match: (name: string, processes: string | string[]) => window.categories.match(name, processes), } diff --git a/src/api/system.ts b/src/api/system.ts index 3ed0cc3..b0e4f66 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -1,3 +1,12 @@ +function toIpcSafeSnapshot(snapshot?: any) { + if (snapshot == null) return undefined + try { + return JSON.parse(JSON.stringify(snapshot)) + } catch { + return undefined + } +} + export const systemApi = { scanProcesses: () => window.system.scanProcesses(), scanLocalGames: () => window.system.scanLocalGames(), @@ -13,4 +22,34 @@ export const systemApi = { targetPort?: number, timeoutMs?: number ) => window.system.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs), + setSystemProxy: (port: number, bypass?: string) => { + const fn = (window.system as any)?.setSystemProxy + if (typeof fn !== 'function') { + return Promise.resolve({ + ok: false, + message: 'system.setSystemProxy is unavailable in current preload. Please restart app or update electron main/preload build.' + }) + } + return fn(port, bypass) + }, + clearSystemProxy: (snapshot?: any) => { + const fn = (window.system as any)?.clearSystemProxy + if (typeof fn !== 'function') { + return Promise.resolve({ + ok: false, + message: 'system.clearSystemProxy is unavailable in current preload. Please restart app or update electron main/preload build.' + }) + } + return fn(toIpcSafeSnapshot(snapshot)) + }, + getSystemProxyState: () => { + const fn = (window.system as any)?.getSystemProxyState + if (typeof fn !== 'function') { + return Promise.resolve({ + ok: false, + message: 'system.getSystemProxyState is unavailable in current preload. Please restart app or update electron main/preload build.' + }) + } + return fn() + }, } diff --git a/src/assets/main.css b/src/assets/main.css index b8dfbb2..2295c3d 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -35,6 +35,7 @@ --rgb-surface: 255, 255, 255; /* Pure white for cards */ --rgb-surface-panel: 244, 245, 247; /* Panel background */ --rgb-surface-overlay: 238, 240, 243; /* Hover/Input background */ + --rgb-surface-variant: 232, 235, 240; /* Stronger chips/media placeholders */ /* Text - Slate Palette for better legibility */ --rgb-on-surface: 15, 23, 42; /* Slate-900 */ @@ -45,7 +46,7 @@ --rgb-border: 226, 232, 240; /* Slate-200 */ --rgb-border-hover: 203, 213, 225; /* Slate-300 */ - --rgb-overlay: 0, 0, 0; + --rgb-overlay: 15, 23, 42; /* CSS Variables for direct usage */ --color-primary: rgb(var(--rgb-primary)); @@ -53,7 +54,7 @@ --color-surface: rgb(var(--rgb-surface)); --color-on-surface: rgb(var(--rgb-on-surface)); --color-border: rgb(var(--rgb-border)); - + /* Font Base */ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; @@ -121,11 +122,12 @@ html.dark { --rgb-surface: 18, 18, 22; /* Ebony surface */ --rgb-surface-panel: 28, 28, 32; /* Obsidian panel */ --rgb-surface-overlay: 38, 38, 44; /* Hover state */ + --rgb-surface-variant: 48, 48, 56; /* Chips/media placeholders */ /* Text - High Contrast for Dark Mode */ --rgb-on-surface: 248, 250, 252; /* Slate-50 */ - --rgb-on-surface-variant: 148, 163, 184; /* Slate-400 */ - --rgb-on-surface-muted: 71, 85, 105; /* Slate-600 */ + --rgb-on-surface-variant: 203, 213, 225; /* Slate-300 */ + --rgb-on-surface-muted: 161, 161, 170; /* Zinc-400, brighter than Slate-600 */ /* Borders - Subdued Obsidian */ --rgb-border: 38, 38, 44; /* Quiet borders */ @@ -133,9 +135,14 @@ html.dark { /* Adjusted Status Colors for Dark Mode (Vibrant but readable) */ --rgb-success: 52, 211, 153; /* Emerald-400 */ + --rgb-success-bg: 6, 78, 59; /* Emerald-900 */ --rgb-warning: 251, 191, 36; /* Amber-400 */ + --rgb-warning-bg: 120, 53, 15; /* Amber-900 */ --rgb-error: 251, 113, 133; /* Rose-400 */ + --rgb-error-bg: 136, 19, 55; /* Rose-900 */ --rgb-info: 56, 189, 248; /* Sky-400 */ + --rgb-info-bg: 12, 74, 110; /* Sky-900 */ + --rgb-overlay: 0, 0, 0; } /* Transitions */ @@ -283,61 +290,17 @@ html.dark { caused by Tailwind Preflight or style conflicts. */ -/* Green Theme (Default) */ -html.theme-green .n-button.n-button--primary-type, +.n-button.n-button--primary-type, html:not([class*="theme-"]) .n-button.n-button--primary-type { - background-color: #3eaf7c !important; - color: #ffffff !important; - border: 1px solid #3eaf7c !important; + background-color: rgb(var(--rgb-primary)) !important; + color: rgb(var(--rgb-on-primary)) !important; + border: 1px solid rgb(var(--rgb-primary)) !important; } -html.theme-green .n-button.n-button--primary-type:hover, + +.n-button.n-button--primary-type:hover, html:not([class*="theme-"]) .n-button.n-button--primary-type:hover { - background-color: #10b981 !important; - border-color: #10b981 !important; -} - -/* Blue Theme */ -html.theme-blue .n-button.n-button--primary-type { - background-color: #3b82f6 !important; - color: #ffffff !important; - border: 1px solid #3b82f6 !important; -} -html.theme-blue .n-button.n-button--primary-type:hover { - background-color: #2563eb !important; - border-color: #2563eb !important; -} - -/* Purple Theme */ -html.theme-purple .n-button.n-button--primary-type { - background-color: #8b5cf6 !important; - color: #ffffff !important; - border: 1px solid #8b5cf6 !important; -} -html.theme-purple .n-button.n-button--primary-type:hover { - background-color: #7c3aed !important; - border-color: #7c3aed !important; -} - -/* Orange Theme */ -html.theme-orange .n-button.n-button--primary-type { - background-color: #f97316 !important; - color: #ffffff !important; - border: 1px solid #f97316 !important; -} -html.theme-orange .n-button.n-button--primary-type:hover { - background-color: #ea580c !important; - border-color: #ea580c !important; -} - -/* Red Theme */ -html.theme-red .n-button.n-button--primary-type { - background-color: #ef4444 !important; - color: #ffffff !important; - border: 1px solid #ef4444 !important; -} -html.theme-red .n-button.n-button--primary-type:hover { - background-color: #dc2626 !important; - border-color: #dc2626 !important; + background-color: rgb(var(--rgb-primary-hover)) !important; + border-color: rgb(var(--rgb-primary-hover)) !important; } /* Fix for Error and Warning buttons transparency issue */ diff --git a/src/autoImport/components.d.ts b/src/autoImport/components.d.ts index 2ff0402..4bc30b1 100644 --- a/src/autoImport/components.d.ts +++ b/src/autoImport/components.d.ts @@ -17,9 +17,11 @@ declare module 'vue' { GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default'] GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default'] IconSelector: typeof import('./../components/common/IconSelector.vue')['default'] + LanguageToggle: typeof import('./../components/common/LanguageToggle.vue')['default'] LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default'] LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default'] NButton: typeof import('naive-ui')['NButton'] + NCheckbox: typeof import('naive-ui')['NCheckbox'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDivider: typeof import('naive-ui')['NDivider'] @@ -37,6 +39,7 @@ declare module 'vue' { NodeImportModal: typeof import('./../components/node/NodeImportModal.vue')['default'] NodeManager: typeof import('./../components/node/NodeManager.vue')['default'] NodeSelector: typeof import('./../components/dashboard/NodeSelector.vue')['default'] + NPopselect: typeof import('naive-ui')['NPopselect'] NRadio: typeof import('naive-ui')['NRadio'] NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NSelect: typeof import('naive-ui')['NSelect'] @@ -47,6 +50,7 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default'] + SubscriptionManagerModal: typeof import('./../components/node/SubscriptionManagerModal.vue')['default'] ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default'] } } diff --git a/src/components/common/LanguageToggle.vue b/src/components/common/LanguageToggle.vue new file mode 100644 index 0000000..cf88acb --- /dev/null +++ b/src/components/common/LanguageToggle.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/components/dashboard/LatencyChart.vue b/src/components/dashboard/LatencyChart.vue index 746870d..2624976 100644 --- a/src/components/dashboard/LatencyChart.vue +++ b/src/components/dashboard/LatencyChart.vue @@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import * as echarts from 'echarts' import { useNodeStore } from '@/stores/nodes' import { useIntervalFn } from '@vueuse/core' +import { useTheme } from '@/composables/useTheme' const props = defineProps<{ nodeKey?: string | null @@ -16,6 +17,8 @@ const nodeStore = useNodeStore() const chartRef = ref() let chart: echarts.ECharts | null = null +const { isDark, themeColor } = useTheme() + const activeNodeKey = computed(() => props.nodeKey || '') function formatTime(timestamp: number) { @@ -25,6 +28,15 @@ function formatTime(timestamp: number) { function renderChart(xData: string[], yData: (number | null)[]) { if (!chart) return + + const css = getComputedStyle(document.documentElement) + const primaryRgb = css.getPropertyValue('--rgb-primary').trim() || '16, 185, 129' + const borderRgb = css.getPropertyValue('--rgb-border').trim() || '226, 232, 240' + const mutedTextRgb = css.getPropertyValue('--rgb-on-surface-muted').trim() || '148, 163, 184' + const axisColor = `rgba(${borderRgb}, 0.9)` + const splitColor = `rgba(${borderRgb}, 0.45)` + const axisTextColor = `rgba(${mutedTextRgb}, 1)` + chart.setOption({ backgroundColor: 'transparent', tooltip: { @@ -41,12 +53,14 @@ function renderChart(xData: string[], yData: (number | null)[]) { xAxis: { type: 'category', data: xData, - axisLine: { lineStyle: { color: '#666' } } + axisLabel: { color: axisTextColor }, + axisLine: { lineStyle: { color: axisColor } } }, yAxis: { type: 'value', - splitLine: { lineStyle: { color: '#333' } }, - axisLine: { lineStyle: { color: '#666' } } + axisLabel: { color: axisTextColor }, + splitLine: { lineStyle: { color: splitColor } }, + axisLine: { lineStyle: { color: axisColor } } }, series: [ { @@ -57,11 +71,11 @@ function renderChart(xData: string[], yData: (number | null)[]) { connectNulls: false, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: 'rgba(62, 175, 124, 0.5)' }, - { offset: 1, color: 'rgba(62, 175, 124, 0.0)' } + { offset: 0, color: `rgba(${primaryRgb}, 0.5)` }, + { offset: 1, color: `rgba(${primaryRgb}, 0.0)` } ]) }, - itemStyle: { color: '#3eaf7c' } + itemStyle: { color: `rgba(${primaryRgb}, 1)` } } ] }) @@ -93,7 +107,7 @@ onMounted(() => { window.addEventListener('resize', resize) }) -watch(activeNodeKey, () => { +watch([activeNodeKey, themeColor, isDark], () => { void refreshChart() }) diff --git a/src/components/dashboard/NodeSelector.vue b/src/components/dashboard/NodeSelector.vue index 4c98f67..d4d8f43 100644 --- a/src/components/dashboard/NodeSelector.vue +++ b/src/components/dashboard/NodeSelector.vue @@ -1,64 +1,62 @@